diff --git a/.github/workflows/benchmarker.yml b/.github/workflows/benchmarker.yml new file mode 100644 index 000000000..1a1de4da6 --- /dev/null +++ b/.github/workflows/benchmarker.yml @@ -0,0 +1,40 @@ +name: Node.js CI + +on: push + +jobs: + benchmark: + # TODO should we use the same container as circle & central? + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [16.17.0] + services: + # see: https://docs.github.com/en/enterprise-server@3.5/actions/using-containerized-services/creating-postgresql-service-containers + postgres: + image: postgres:9.6 + env: + POSTGRES_PASSWORD: odktest + ports: + - 5432:5432 + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - run: npm ci + - run: node lib/bin/create-docker-databases.js + - name: Benchmark + timeout-minutes: 10 + run: ./benchmarker/scripts/ci-benchmark + - name: Backend Logs + if: always() + run: "! [[ -f ./backend.log ]] || cat ./backend.log" diff --git a/benchmarker/.eslintrc.cjs b/benchmarker/.eslintrc.cjs new file mode 100644 index 000000000..6daaf0715 --- /dev/null +++ b/benchmarker/.eslintrc.cjs @@ -0,0 +1,29 @@ +module.exports = { + env: { + commonjs: true, + es2021: true, + node: true, + }, + extends: 'eslint:recommended', + parserOptions: { + ecmaVersion: "latest", + }, + rules: { + 'indent': 'off', + 'key-spacing': 'off', + 'keyword-spacing': 'off', + 'no-console': 'off', + 'no-mixed-operators': 'off', + 'no-multi-spaces': 'off', + 'no-plusplus': 'off', + 'no-return-assign': 'off', + 'no-shadow': 'off', + 'no-undef-init': 'error', + 'no-unused-expressions': 'error', + 'no-use-before-define': [ 'error', { functions:false } ], + 'semi': [ 'error', 'always' ], + 'semi-style': 'off', + 'space-in-parens': 'off', + 'switch-colon-spacing': 'off', + }, +}; diff --git a/benchmarker/.gitignore b/benchmarker/.gitignore new file mode 100644 index 000000000..e63b9859d --- /dev/null +++ b/benchmarker/.gitignore @@ -0,0 +1,2 @@ +/node_modules/ +/logs/ diff --git a/benchmarker/250q-form.xml b/benchmarker/250q-form.xml new file mode 100644 index 000000000..cfaaf77ad --- /dev/null +++ b/benchmarker/250q-form.xml @@ -0,0 +1,1268 @@ + + + + 250 flat text questions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/benchmarker/250q-submission.template.xml b/benchmarker/250q-submission.template.xml new file mode 100644 index 000000000..ae5969d24 --- /dev/null +++ b/benchmarker/250q-submission.template.xml @@ -0,0 +1,8 @@ + + uuid:{{uuid}} + {{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}} + {{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}} + {{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}} + {{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}} + {{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}} + diff --git a/benchmarker/index.js b/benchmarker/index.js new file mode 100644 index 000000000..67d15aebb --- /dev/null +++ b/benchmarker/index.js @@ -0,0 +1,315 @@ +// Copyright 2022 ODK Central Developers +// See the NOTICE file at the top-level directory of this distribution and at +// https://github.com/getodk/central-backend/blob/master/NOTICE. +// This file is part of ODK Central. It is subject to the license terms in +// the LICENSE file found in the top-level directory of this distribution and at +// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +// including this file, may be copied, modified, propagated, or distributed +// except according to the terms contained in the LICENSE file. + +import fs from 'node:fs'; +import fetch, { fileFromSync } from 'node-fetch'; +import _ from 'lodash'; +import { v4 as uuid } from 'uuid'; +import { basename } from 'node:path'; +import { program } from 'commander'; + +const _log = (...args) => console.log(`[${new Date().toISOString()}]`, '[benchmarker]', ...args); +const log = (...args) => true && _log('INFO', ...args); +log.debug = (...args) => false && _log('DEBUG', ...args); +log.info = log; +log.error = (...args) => true && _log('ERROR', ...args); +log.report = (...args) => true && _log('REPORT', ...args); + +program + .option('-s, --server-url ', 'URL of ODK Central server', 'http://localhost:8989') + .option('-u, --user-email ', 'Email of central user', 'x@example.com') + .option('-P, --user-password ', 'Password of central user', 'secret') + .option('-f, --form-path ', 'Path to form file (XML, XLS, XLSX etc.)', './250q-form.xml') + .option('-L, --log-directory ', 'Log output directory (this should be an empty or non-existent directory)') + ; +program.parse(); +const { serverUrl, userEmail, userPassword, formPath, logDirectory } = program.opts(); + +const submissionTemplate = fs.readFileSync('./250q-submission.template.xml', { encoding:'utf8' }).trim(); + +log(`Using form: ${formPath}`); +log(`Connecting to ${serverUrl} with user ${userEmail}...`); + +const logPath = logDirectory || `./logs/${new Date().toISOString()}`; + +let bearerToken; + +benchmark(); + +async function benchmark() { + log.info('Setting up...'); + + log.info('Creating log directory:', logPath, '...'); + fs.mkdirSync(logPath, { recursive:true }); + + log.info('Creating session...'); + const { token } = await apiPostJson('sessions', { email:userEmail, password:userPassword }, { Authorization:null }); + bearerToken = token; + + log.info('Creating project...'); + const { id:projectId } = await apiPostJson('projects', { name:`benchmark-${new Date().toISOString().replace(/\..*/, '')}` }); + + log.info('Uploading form...'); + const { xmlFormId:formId } = await apiPostFile(`projects/${projectId}/forms`, formPath); + + log.info('Publishing form...'); + await apiPost(`projects/${projectId}/forms/${formId}/draft/publish`); + + log.info('Setup complete. Starting benchmarks...'); + + await doBenchmark('randomSubmission', 50, 1_000, 30_000, 100, n => randomSubmission(n, projectId, formId)); + + // TODO work out a more scientific sleep duration + const backgroundJobPause = 20_000; + log.info(`Sleeping ${durationForHumans(backgroundJobPause)} to allow central-backend to complete background jobs...`); + await new Promise(resolve => setTimeout(resolve, backgroundJobPause)); + log.info('Woke up.'); + + await doBenchmark('exportZipWithDataAndMedia', 10, 3_000, 300_000, 0, n => exportZipWithDataAndMedia(n, projectId, formId)); + + log.info(`Check for extra logs at ${logPath}`); + + log.info('Complete.'); + + // force exit in case some promise somewhere has failed to resolved (somehow required in Github Actions) + process.exit(0); +} + +function doBenchmark(name, throughput, throughputPeriod, testDuration, minimumSuccessThreshold, fn) { + log.info('Starting benchmark:', name); + log.info(' throughput:', throughput, 'per period'); + log.info(' throughputPeriod:', throughputPeriod, 'ms'); + log.info(' testDuration:', durationForHumans(testDuration)); + log.info('-------------------------------'); + return new Promise((resolve, reject) => { + try { + const successes = []; + const sizes = []; + const fails = []; + const results = []; + const sleepyTime = +throughputPeriod / +throughput; + + let iterationCount = 0; + let completedIterations = 0; + const iterate = async () => { + const n = iterationCount++; + const started = Date.now(); + try { + const size = await fn(n); + const finished = Date.now(); + const time = finished - started; + successes.push(time); + sizes.push(size); + results[n] = { success:true, started, finished, time, size }; + } catch(err) { + fails.push(err.message); + results[n] = { success:false, started, finished:Date.now(), err:{ message:err.message, stack:err.stack } }; + } finally { + ++completedIterations; + } + }; + + iterate(); + const timerId = setInterval(iterate, sleepyTime); + + setTimeout(async () => { + clearTimeout(timerId); + + const maxDrainDuration = 120_000; + await new Promise(resolve => { + log.info(`Waiting up to ${durationForHumans(maxDrainDuration)} for test drainage...`); + const maxDrainTimeout = Date.now() + maxDrainDuration; + const drainPulse = 500; + + checkDrain(); + + function checkDrain() { + log.debug('Checking drain status...'); + if(Date.now() > maxDrainTimeout) { + log.info('Drain timeout exceeded.'); + return resolve(); + } else if(completedIterations >= iterationCount) { + log.info('All connections have completed.'); + return resolve(); + } + log.debug(`Drainage not complete. Still Waiting for ${iterationCount - results.length} connections. Sleeping for ${durationForHumans(drainPulse)}...`); + setTimeout(checkDrain, drainPulse); + } + }); + + fs.writeFileSync(`${logPath}/${name}.extras.log.json`, JSON.stringify(results, null, 2)); + + const successPercent = 100 * successes.length / iterationCount; + + log.report('--------------------------'); + log.report(' Test:', name); + log.report(' Test duration:', testDuration); + log.report(' Total requests:', iterationCount); + log.report('Success % required:', `${minimumSuccessThreshold}%`); + log.report(' Successes:', successes.length, `(${Math.floor(successPercent)}%)`); + log.report(' Throughput:', oneDp((1000 * successes.length / testDuration)), 'reqs/s'); + log.report(' Failures:', fails.length); + log.report(' Response times:'); + log.report(' mean:', durationForHumans(_.mean(successes))); + log.report(' min:', _.min(successes), 'ms'); + log.report(' max:', _.max(successes), 'ms'); + log.report(' Response sizes:'); + log.report(' min:', _.min(sizes), 'b'); + log.report(' max:', _.max(sizes), 'b'); + if(fails.length) log.report(' Errors:'); + [ ...new Set(fails) ].map(m => log.report(` * ${m.replace(/\n/g, '\\n')}`)); + log.report('--------------------------'); + + if(_.min(sizes) !== _.max(sizes)) reportFatalError('VARIATION IN RESPONSE SIZES MAY INDICATE SERIOUS ERRORS SERVER-SIDE'); + + if(successPercent < minimumSuccessThreshold) reportFatalError('MINIMUM SUCCESS THRESHOLD WAS NOT MET'); + + if(fails.length) reportWarning('REQUEST FAILURES MAY AFFECT SUBSEQUENT BENCHMARKS'); + + resolve(); + }, +testDuration); + } catch(err) { + reject(err); + } + }); +} + +function reportFatalError(message) { + reportWarning(message); + process.exit(1); +} + +function reportWarning(message) { + log.report('!!!'); + log.report('!!!'); + log.report(`!!! ${message}!`); + log.report('!!!'); + log.report('!!!'); + log.report('--------------------------'); +} + +function apiPostFile(path, filePath) { + const mimeType = mimetypeFor(filePath); + const blob = fileFromSync(filePath, mimeType); + return apiPost(path, blob, { 'Content-Type':mimeType }); +} + +function apiPostJson(path, body, headers) { + return apiPost(path, JSON.stringify(body), { 'Content-Type':'application/json', ...headers }); +} + +function apiGetAndDump(prefix, n, path, headers) { + return fetchToFile(prefix, n, 'GET', path, undefined, headers); +} + +function apiPostAndDump(prefix, n, path, body, headers) { + return fetchToFile(prefix, n, 'POST', path, body, headers); +} + +async function fetchToFile(filenamePrefix, n, method, path, body, headers) { + const res = await apiFetch(method, path, body, headers); + + return new Promise((resolve, reject) => { + try { + let bytes = 0; + res.body.on('data', data => bytes += data.length); + res.body.on('error', reject); + + const file = fs.createWriteStream(`${logPath}/${filenamePrefix}.${n.toString().padStart(9, '0')}.dump`); + res.body.on('end', () => file.close(() => resolve(bytes))); + + file.on('error', reject); + + res.body.pipe(file); + } catch(err) { + console.log(err); + process.exit(99); + } + }); +} + +async function apiPost(path, body, headers) { + const res = await apiFetch('POST', path, body, headers); + return res.json(); +} + +async function apiFetch(method, path, body, headers) { + const url = `${serverUrl}/v1/${path}`; + + const Authorization = bearerToken ? `Bearer ${bearerToken}` : `Basic ${base64(`${userEmail}:${userPassword}`)}`; + + const res = await fetch(url, { + method, + body, + headers: { Authorization, ...headers }, + }); + log.debug(method, res.url, '->', res.status); + if(!res.ok) throw new Error(`${res.status}: ${await res.text()}`); + return res; +} + +function base64(s) { + return Buffer.from(s).toString('base64'); +} + +function mimetypeFor(f) { + const extension = fileExtensionFrom(f); + log.debug('fileExtensionFrom()', f, '->', extension); + switch(extension) { + case 'xls' : return 'application/vnd.ms-excel'; + case 'xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + case 'xml' : return 'application/xml'; + default: throw new Error(`Unsure what mime type to use for: ${f}`); + } +} + +function fileExtensionFrom(f) { + try { + return basename(f).match(/\.([^.]*)$/)[1]; + } catch(err) { + throw new Error(`Could not get file extension from filename '${f}'!`); + } +} + +function randomSubmission(n, projectId, formId) { + const headers = { + 'Content-Type': 'multipart/form-data; boundary=foo', + 'X-OpenRosa-Version': '1.0', + }; + + const body = `--foo\r +Content-Disposition: form-data; name="xml_submission_file"; filename="submission.xml"\r +Content-Type: application/xml\r +\r +${submissionTemplate + .replace(/{{uuid}}/g, () => uuid()) + .replace(/{{randInt}}/g, randInt) +} +\r +--foo--`; + + return apiPostAndDump('randomSubmission', n, `projects/${projectId}/forms/${formId}/submissions`, body, headers); +} + +function randInt() { + return Math.floor(Math.random() * 9999); +} + +function exportZipWithDataAndMedia(n, projectId, formId) { + return apiGetAndDump('exportZipWithDataAndMedia', n, `projects/${projectId}/forms/${formId}/submissions.csv.zip?splitSelectMultiples=true&groupPaths=true&deletedFields=true`); +} + +function durationForHumans(ms) { + if(ms > 1000) return oneDp((ms / 1000)) + 's'; + else return oneDp( ms) + 'ms'; +} + +function oneDp(n) { + return Number(n.toFixed(1)); +} diff --git a/benchmarker/package-lock.json b/benchmarker/package-lock.json new file mode 100644 index 000000000..5ffe63398 --- /dev/null +++ b/benchmarker/package-lock.json @@ -0,0 +1,1480 @@ +{ + "name": "benchmarker", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "benchmarker", + "version": "1.0.0", + "license": "SEE LICENSE IN ../LICENSE", + "dependencies": { + "commander": "^9.4.0", + "lodash": "^4.17.21", + "node-fetch": "^3.2.9", + "uuid": "^8.3.2" + }, + "devDependencies": { + "eslint": "^8.20.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.3.2", + "globals": "^13.15.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.9.5", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^1.2.1", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.8.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "9.4.0", + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.20.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint/eslintrc": "^1.3.0", + "@humanwhocodes/config-array": "^0.9.2", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.1.1", + "eslint-utils": "^3.0.0", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.3.2", + "esquery": "^1.4.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^6.0.1", + "globals": "^13.15.0", + "ignore": "^5.2.0", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "regexpp": "^3.2.0", + "strip-ansi": "^6.0.1", + "strip-json-comments": "^3.1.0", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.1.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/eslint-utils": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.3.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/espree": { + "version": "9.3.2", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.7.1", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/esquery": { + "version": "1.4.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flat-cache": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.2.6", + "dev": true, + "license": "ISC" + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.17.0", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.2.9", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/once": { + "version": "1.4.0", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/regexpp": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache": { + "version": "2.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.2.1", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "dev": true, + "license": "ISC" + } + }, + "dependencies": { + "@eslint/eslintrc": { + "version": "1.3.0", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.3.2", + "globals": "^13.15.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + } + }, + "@humanwhocodes/config-array": { + "version": "0.9.5", + "dev": true, + "requires": { + "@humanwhocodes/object-schema": "^1.2.1", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + } + }, + "@humanwhocodes/object-schema": { + "version": "1.2.1", + "dev": true + }, + "acorn": { + "version": "8.8.0", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.2", + "dev": true, + "requires": {} + }, + "ajv": { + "version": "6.12.6", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-regex": { + "version": "5.0.1", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "argparse": { + "version": "2.0.1", + "dev": true + }, + "balanced-match": { + "version": "1.0.2", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "callsites": { + "version": "3.1.0", + "dev": true + }, + "chalk": { + "version": "4.1.2", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "dev": true + }, + "commander": { + "version": "9.4.0" + }, + "concat-map": { + "version": "0.0.1", + "dev": true + }, + "cross-spawn": { + "version": "7.0.3", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "data-uri-to-buffer": { + "version": "4.0.0" + }, + "debug": { + "version": "4.3.4", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "deep-is": { + "version": "0.1.4", + "dev": true + }, + "doctrine": { + "version": "3.0.0", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "escape-string-regexp": { + "version": "4.0.0", + "dev": true + }, + "eslint": { + "version": "8.20.0", + "dev": true, + "requires": { + "@eslint/eslintrc": "^1.3.0", + "@humanwhocodes/config-array": "^0.9.2", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.1.1", + "eslint-utils": "^3.0.0", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.3.2", + "esquery": "^1.4.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^6.0.1", + "globals": "^13.15.0", + "ignore": "^5.2.0", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "regexpp": "^3.2.0", + "strip-ansi": "^6.0.1", + "strip-json-comments": "^3.1.0", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + } + }, + "eslint-scope": { + "version": "7.1.1", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + } + }, + "eslint-utils": { + "version": "3.0.0", + "dev": true, + "requires": { + "eslint-visitor-keys": "^2.0.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "2.1.0", + "dev": true + } + } + }, + "eslint-visitor-keys": { + "version": "3.3.0", + "dev": true + }, + "espree": { + "version": "9.3.2", + "dev": true, + "requires": { + "acorn": "^8.7.1", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.3.0" + } + }, + "esquery": { + "version": "1.4.0", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + } + }, + "esrecurse": { + "version": "4.3.0", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + } + }, + "estraverse": { + "version": "5.3.0", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "dev": true + }, + "fetch-blob": { + "version": "3.2.0", + "requires": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + } + }, + "file-entry-cache": { + "version": "6.0.1", + "dev": true, + "requires": { + "flat-cache": "^3.0.4" + } + }, + "flat-cache": { + "version": "3.0.4", + "dev": true, + "requires": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + } + }, + "flatted": { + "version": "3.2.6", + "dev": true + }, + "formdata-polyfill": { + "version": "4.0.10", + "requires": { + "fetch-blob": "^3.1.2" + } + }, + "fs.realpath": { + "version": "1.0.0", + "dev": true + }, + "functional-red-black-tree": { + "version": "1.0.1", + "dev": true + }, + "glob": { + "version": "7.2.3", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "6.0.2", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + }, + "globals": { + "version": "13.17.0", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + }, + "has-flag": { + "version": "4.0.0", + "dev": true + }, + "ignore": { + "version": "5.2.0", + "dev": true + }, + "import-fresh": { + "version": "3.3.0", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "isexe": { + "version": "2.0.0", + "dev": true + }, + "js-yaml": { + "version": "4.1.0", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true + }, + "levn": { + "version": "0.4.1", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "lodash": { + "version": "4.17.21" + }, + "lodash.merge": { + "version": "4.6.2", + "dev": true + }, + "minimatch": { + "version": "3.1.2", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "ms": { + "version": "2.1.2", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "dev": true + }, + "node-domexception": { + "version": "1.0.0" + }, + "node-fetch": { + "version": "3.2.9", + "requires": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + } + }, + "once": { + "version": "1.4.0", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "optionator": { + "version": "0.9.1", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "parent-module": { + "version": "1.0.1", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "dev": true + }, + "prelude-ls": { + "version": "1.2.1", + "dev": true + }, + "punycode": { + "version": "2.1.1", + "dev": true + }, + "regexpp": { + "version": "3.2.0", + "dev": true + }, + "resolve-from": { + "version": "4.0.0", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "shebang-command": { + "version": "2.0.0", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "dev": true + }, + "strip-ansi": { + "version": "6.0.1", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-json-comments": { + "version": "3.1.1", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "text-table": { + "version": "0.2.0", + "dev": true + }, + "type-check": { + "version": "0.4.0", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-fest": { + "version": "0.20.2", + "dev": true + }, + "uri-js": { + "version": "4.4.1", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "uuid": { + "version": "8.3.2" + }, + "v8-compile-cache": { + "version": "2.3.0", + "dev": true + }, + "web-streams-polyfill": { + "version": "3.2.1" + }, + "which": { + "version": "2.0.2", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "word-wrap": { + "version": "1.2.3", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "dev": true + } + } +} diff --git a/benchmarker/package.json b/benchmarker/package.json new file mode 100644 index 000000000..e8f7cf681 --- /dev/null +++ b/benchmarker/package.json @@ -0,0 +1,20 @@ +{ + "name": "benchmarker", + "version": "1.0.0", + "main": "index.js", + "type": "module", + "scripts": { + "start": "node index.js", + "test": "eslint ." + }, + "dependencies": { + "commander": "^9.4.0", + "lodash": "^4.17.21", + "node-fetch": "^3.2.9", + "uuid": "^8.3.2" + }, + "devDependencies": { + "eslint": "^8.20.0" + }, + "license": "SEE LICENSE IN ../LICENSE" +} diff --git a/benchmarker/scripts/ci-benchmark b/benchmarker/scripts/ci-benchmark new file mode 100755 index 000000000..0b2c9eb47 --- /dev/null +++ b/benchmarker/scripts/ci-benchmark @@ -0,0 +1,62 @@ +#!/bin/bash -eu +set -o pipefail + +serverUrl="http://localhost:8383" +userEmail="x@example.com" +userPassword="secret1234" + +log() { + echo "[ci-benchmark] $*" +} + +fail_job() { + log 'Job failed.' + exit 1 +} + +make base + +node ./lib/bin/cli.js user-create -u "$userEmail" -p "$userPassword" +node ./lib/bin/cli.js user-promote -u "$userEmail" + +make run >backend.log 2>&1 & + +cd benchmarker +npm ci + +log 'Waiting for backend to start...' +timeout 30 bash -c "while ! curl -s -o /dev/null $serverUrl; do sleep 1; done" +log 'Backend started!' + +node index.js -s "$serverUrl" -P "$userPassword" -L /tmp/benchmarker-logs + +if ! curl -s -o /dev/null "$serverUrl"; then + log 'Backend died.' + fail_job +fi + +responseLog="$(mktemp)" +requestBody='{"email":"'"$userEmail"'","password":"'"$userPassword"'"}' +loginStatus="$(curl -s -o "$responseLog" -w '%{http_code}' \ + --header 'Content-Type: application/json' --data "$requestBody" \ + "$serverUrl/v1/sessions" +)" +if [[ "$loginStatus" = "200" ]]; then + log 'Backend survived; job should pass.' +else + log 'Backend behaving badly:' + log "$(cat "$responseLog")" + fail_job +fi + +log 'Checking open DB query count...' +(cd .. && node lib/bin/check-open-db-queries.js) + +# TODO upload results to getodk.cloud for graphing. Include: +# +# * key metrics (for graphing against other branches) +# * throughput +# * average response time +# * response time range (quickest, slowest) +# * did export response sizes vary? +# * individual response timings (to see if e.g. response times degrade over time)