diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 00164e779..68e7df879 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -11,7 +11,6 @@ concurrency: env: NODE_NO_WARNINGS: 1 - CI: true jobs: bench: @@ -22,7 +21,7 @@ jobs: - 10 - 100 - 1000 - name: Benchmark / ${{matrix.e2e_runner}} / ${{matrix.products_size}} items + name: ${{matrix.e2e_runner}} / ${{matrix.products_size}} items runs-on: ubuntu-latest steps: - name: Checkout @@ -36,4 +35,3 @@ jobs: env: PRODUCTS_SIZE: ${{matrix.products_size}} E2E_GATEWAY_RUNNER: ${{matrix.e2e_runner}} - CI: true diff --git a/.github/workflows/memtest.yml b/.github/workflows/memtest.yml new file mode 100644 index 000000000..f806152e3 --- /dev/null +++ b/.github/workflows/memtest.yml @@ -0,0 +1,42 @@ +name: Memtest +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_NO_WARNINGS: 1 + K6_VERSION: v0.56.0 + +jobs: + memtest: + strategy: + matrix: + e2e_runner: + - node + # - bun TODO: fix getting memory snaps for bun + name: ${{matrix.e2e_runner}} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install k6 + run: | + mkdir -p "$HOME/.local/bin" + cd "$HOME/.local/bin" + curl https://github.com/grafana/k6/releases/download/${{ env.K6_VERSION }}/k6-${{ env.K6_VERSION }}-linux-amd64.tar.gz -L | tar xvz --strip-components 1 + echo "$PWD" >> $GITHUB_PATH + - name: Set up env + uses: the-guild-org/shared-config/setup@v1 + with: + node-version-file: .node-version + - name: Test + # we want to run all tests in sequence to avoid memory capping and proper isolation + run: yarn test:mem --no-file-parallelism + env: + E2E_GATEWAY_RUNNER: ${{matrix.e2e_runner}} diff --git a/.gitignore b/.gitignore index 254bf6062..a879366ff 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ hive-gateway .cache/ *.pem /examples/**/*/supergraph.graphql +/e2e/**/*/memtest-memory-snapshots_*.svg diff --git a/e2e/federation-example/federation-example.memtest.ts b/e2e/federation-example/federation-example.memtest.ts new file mode 100644 index 000000000..1c546eb14 --- /dev/null +++ b/e2e/federation-example/federation-example.memtest.ts @@ -0,0 +1,18 @@ +import { createExampleSetup, createTenv } from '@internal/e2e'; +import { memtest } from '@internal/perf/memtest'; + +const cwd = __dirname; + +const { gateway } = createTenv(cwd); +const { supergraph, query } = createExampleSetup(cwd); + +memtest( + { + cwd, + query, + }, + async () => + gateway({ + supergraph: await supergraph(), + }), +); diff --git a/e2e/opentelemetry/opentelemetry.memtest.ts b/e2e/opentelemetry/opentelemetry.memtest.ts new file mode 100644 index 000000000..94d169026 --- /dev/null +++ b/e2e/opentelemetry/opentelemetry.memtest.ts @@ -0,0 +1,50 @@ +import { Container, createExampleSetup, createTenv } from '@internal/e2e'; +import { memtest } from '@internal/perf/memtest'; +import { beforeAll, describe } from 'vitest'; + +const cwd = __dirname; + +const { gateway, container } = createTenv(cwd); +const { supergraph, query } = createExampleSetup(cwd); + +(['grpc', 'http'] as const).forEach((OTLP_EXPORTER_TYPE) => { + describe(`OpenTelemetry ${OTLP_EXPORTER_TYPE} exporter`, () => { + let jaeger: Container; + beforeAll(async () => { + jaeger = await container({ + name: `jaeger-${OTLP_EXPORTER_TYPE}`, + image: 'jaegertracing/all-in-one:1.56', + env: { + COLLECTOR_OTLP_ENABLED: 'true', + }, + containerPort: 4318, + additionalContainerPorts: [16686, 4317], + healthcheck: ['CMD-SHELL', 'wget --spider http://0.0.0.0:14269'], + }); + }); + const jaegerUrls = { + get http() { + return `http://0.0.0.0:${jaeger.port}/v1/traces`; + }, + get grpc() { + return `http://0.0.0.0:${jaeger.additionalPorts[4317]}`; + }, + }; + + memtest( + { + cwd, + query, + }, + async () => + gateway({ + supergraph: await supergraph(), + env: { + OTLP_EXPORTER_TYPE, + OTLP_EXPORTER_URL: jaegerUrls[OTLP_EXPORTER_TYPE], + OTLP_SERVICE_NAME: `memtest-${OTLP_EXPORTER_TYPE}`, + }, + }), + ); + }); +}); diff --git a/internal/e2e/src/tenv.ts b/internal/e2e/src/tenv.ts index c572d4b2e..0edf733d8 100644 --- a/internal/e2e/src/tenv.ts +++ b/internal/e2e/src/tenv.ts @@ -14,7 +14,7 @@ import { fakePromise, registerAbortSignalListener, } from '@graphql-tools/utils'; -import { Proc, ProcOptions, spawn, waitForPort } from '@internal/proc'; +import { Proc, ProcOptions, Server, spawn, waitForPort } from '@internal/proc'; import { boolEnv, createOpt, @@ -82,11 +82,6 @@ yarn build && E2E_GATEWAY_RUNNER=bun-docker yarn workspace @graphql-hive/gateway return runner as ServeRunner; })(); -export interface Server extends Proc { - port: number; - protocol: string; -} - export interface GatewayOptions extends ProcOptions { port?: number; /** @@ -711,7 +706,12 @@ export function createTenv(cwd: string): Tenv { gatewayPort && createPortOpt(gatewayPort), ...args, ); - const service: Service = { ...proc, name, port, protocol }; + const service: Service = { + ...proc, + name, + port, + protocol, + }; await Promise.race([ waitForExit .then(() => { @@ -870,6 +870,7 @@ export function createTenv(cwd: string): Tenv { await ctr.start(); const container: Container = { + waitForExit: ctr.wait(), containerName, name, port: hostPort, diff --git a/internal/examples/src/convert.ts b/internal/examples/src/convert.ts index 2458ec11e..7f973b6ea 100644 --- a/internal/examples/src/convert.ts +++ b/internal/examples/src/convert.ts @@ -180,6 +180,8 @@ export async function convertE2EToExample(config: ConvertE2EToExampleConfig) { !path.basename(extraDirOrFile).includes('.e2e.') && // not a bench !path.basename(extraDirOrFile).includes('.bench.') && + // not a memtest + !path.basename(extraDirOrFile).includes('.memtest.') && // not a dockerile !path.basename(extraDirOrFile).includes('Dockerfile') && // not test snapshots diff --git a/internal/perf/package.json b/internal/perf/package.json new file mode 100644 index 000000000..02f6af444 --- /dev/null +++ b/internal/perf/package.json @@ -0,0 +1,17 @@ +{ + "name": "@internal/perf", + "type": "module", + "private": true, + "main": "./src/index.ts", + "dependencies": { + "canvas": "^3.1.0", + "chart.js": "^4.4.7", + "chartjs-plugin-trendline": "^2.1.6", + "parse-duration": "^2.0.0", + "regression": "^2.0.1" + }, + "devDependencies": { + "@types/k6": "^0.54.2", + "@types/regression": "^2" + } +} diff --git a/internal/perf/src/chart.ts b/internal/perf/src/chart.ts new file mode 100644 index 000000000..e2b44c84e --- /dev/null +++ b/internal/perf/src/chart.ts @@ -0,0 +1,73 @@ +import { Canvas, createCanvas } from 'canvas'; +import { Chart, ChartConfiguration } from 'chart.js/auto'; +import chartTrendline from // @ts-expect-error no type definitions +'chartjs-plugin-trendline'; + +export interface LineChartDataset { + /** The label of the line in the line chart. */ + label: string; + /** The Y data points in the line chart. */ + data: (number | null)[]; +} + +export interface LineChartOptions { + /** + * The tick label callbacks of {@link LineChartDataset.data y} entries. + * + * TODO: separate the tick callback for each data. + */ + yTicksCallback?: (tickValue: number | string) => string; +} + +export function createLineChart( + /** The X data points in the line chart, which are the labels. */ + labels: (number | string | null)[], + datasets: LineChartDataset[], + options: LineChartOptions = {}, +): Canvas { + const canvas = createCanvas(1366, 768, 'svg'); + + const chartConfig: ChartConfiguration = { + type: 'line', + data: { + labels, + datasets: datasets.map(({ label, data }) => ({ + label, + data, + trendlineLinear: { + width: 1, + lineStyle: 'dashed', + }, + })), + }, + options: { + responsive: false, // because we're rendering the chart statically + scales: { + y: { + ticks: { + callback: options.yTicksCallback, + }, + }, + }, + }, + plugins: [ + chartTrendline, + { + id: 'set-white-background', + beforeDraw: (chart) => { + chart.ctx.fillStyle = 'white'; + chart.ctx.fillRect(0, 0, chart.width, chart.height); + chart.ctx.restore(); + }, + }, + ], + }; + + new Chart( + // @ts-expect-error canvas types are of a different instance, but they fit + canvas.getContext('2d'), + chartConfig, + ); + + return canvas; +} diff --git a/internal/perf/src/index.ts b/internal/perf/src/index.ts new file mode 100644 index 000000000..43762c41b --- /dev/null +++ b/internal/perf/src/index.ts @@ -0,0 +1 @@ +export * from './loadtest'; diff --git a/internal/perf/src/loadtest-script.ts b/internal/perf/src/loadtest-script.ts new file mode 100644 index 000000000..268742a93 --- /dev/null +++ b/internal/perf/src/loadtest-script.ts @@ -0,0 +1,22 @@ +import { check } from 'k6'; +import { test } from 'k6/execution'; +import http from 'k6/http'; + +export default function () { + const url = __ENV['URL']; + if (!url) { + return test.abort('Environment variable "URL" not provided'); + } + + const query = __ENV['QUERY']; + if (!query) { + return test.abort('Environment variable "QUERY" not provided'); + } + + const res = http.post(url, { query }); + + check(res, { + 'status is 200': (res) => res.status === 200, + 'body contains data': (res) => !!res.body?.toString().includes('"data":{'), + }); +} diff --git a/internal/perf/src/loadtest.ts b/internal/perf/src/loadtest.ts new file mode 100644 index 000000000..f76578eeb --- /dev/null +++ b/internal/perf/src/loadtest.ts @@ -0,0 +1,132 @@ +import path from 'path'; +import { setTimeout } from 'timers/promises'; +import { ProcOptions, Server, spawn } from '@internal/proc'; +import { trimError } from '@internal/testing'; + +export interface LoadtestOptions extends ProcOptions { + cwd: string; + /** @default 100 */ + vus?: number; + /** Idling duration before loadtest in milliseconds. */ + idle: number; + /** Duration of the loadtest in milliseconds. */ + duration: number; + /** Calmdown duration of the loadtest in milliseconds. This should be enough allowing the GC to kick in. */ + calmdown: number; + /** The snapshotting window of the GraphQL server memory in milliseconds. */ + memorySnapshotWindow: number; + /** The GraphQL server on which the loadtest is running. */ + server: Server; + /** + * The GraphQL query to execute for the loadtest. + */ + query: string; + /** Callback for memory snapshots during the loadtest. */ + onMemorySnapshot?( + memoryUsageInMB: number, + phase: LoadtestPhase, + snapshots: LoadtestMemorySnapshots, + ): Promise | void; +} + +export type LoadtestPhase = 'idle' | 'loadtest' | 'calmdown'; + +/** Memory usage snapshots in MB of the {@link LoadtestOptions.server GraphQL server}.*/ +export type LoadtestMemorySnapshots = { + /** Memory usage snapshots in MB during the given loadtest phase.*/ + [phase in LoadtestPhase]: number[]; +} & { + /** All memory snapshots in MB of all the loadtest phases. */ + total: number[]; +}; + +export async function loadtest( + opts: LoadtestOptions, +): Promise { + const { + cwd, + vus = 100, + idle, + duration, + calmdown, + memorySnapshotWindow, + server, + query, + onMemorySnapshot, + ...procOptions + } = opts; + + if (duration < 3_000) { + throw new Error(`Duration has to be at least 3s, got "${duration}"`); + } + + const ctrl = new AbortController(); + using _ = { + [Symbol.dispose]() { + ctrl.abort(); + }, + }; + + let phase: LoadtestPhase = 'idle'; + const snapshots: LoadtestMemorySnapshots = { + loadtest: [], + idle: [], + calmdown: [], + total: [], + }; + + // we dont use a `setInterval` because the proc.getStats is async and we want stats ordered by time + (async () => { + while (!ctrl.signal.aborted) { + await setTimeout(memorySnapshotWindow); + try { + const { mem } = await server.getStats(); + snapshots[phase].push(mem); + snapshots.total.push(mem); + await onMemorySnapshot?.(mem, phase, snapshots); + } catch (err) { + if (!ctrl.signal.aborted) { + throw err; + } + return; // couldve been aborted after timeout or while waiting for stats + } + } + })(); + + const serverThrowOnExit = server.waitForExit.then(() => { + throw new Error( + `Server exited before the loadtest finished\n${trimError(server.getStd('both'))}`, + ); + }); + + await Promise.race([setTimeout(idle), serverThrowOnExit]); + + phase = 'loadtest'; + const [, waitForExit] = await spawn( + { + cwd, + ...procOptions, + signal: AbortSignal.any([ + ctrl.signal, + AbortSignal.timeout( + duration + + // allow 5s for the k6 process to exit gracefully + 5_000, + ), + ]), + }, + 'k6', + 'run', + `--vus=${vus}`, + `--duration=${duration}ms`, + `--env=URL=${server.protocol}://localhost:${server.port}/graphql`, + `--env=QUERY=${query}`, + path.join(__dirname, 'loadtest-script.ts'), + ); + await Promise.race([waitForExit, serverThrowOnExit]); + + phase = 'calmdown'; + await Promise.race([setTimeout(calmdown), serverThrowOnExit]); + + return snapshots; +} diff --git a/internal/perf/src/memtest.ts b/internal/perf/src/memtest.ts new file mode 100644 index 000000000..ce09e744c --- /dev/null +++ b/internal/perf/src/memtest.ts @@ -0,0 +1,169 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { Server } from '@internal/proc'; +import { isDebug } from '@internal/testing'; +import regression from 'regression'; +import { it } from 'vitest'; +import { createLineChart } from './chart'; +import { loadtest, LoadtestOptions } from './loadtest'; + +export interface MemtestOptions + extends Omit< + LoadtestOptions, + 'memorySnapshotWindow' | 'idle' | 'duration' | 'calmdown' | 'server' + > { + /** + * The snapshotting window of the GraphQL server memory in milliseconds. + * + * @default 1_000 + */ + memorySnapshotWindow?: number; + /** + * Idling duration before loadtest in milliseconds. + * + * @default 10_000 + */ + idle?: number; + /** + * Duration of the loadtest in milliseconds. + * + * @default 180_000 + */ + duration?: number; + /** + * Calmdown duration after loadtesting in milliseconds. + * + * @default 30_000 + */ + calmdown?: number; +} + +export function memtest(opts: MemtestOptions, setup: () => Promise) { + const { + cwd, + memorySnapshotWindow = 1_000, + idle = 10_000, + duration = 180_000, + calmdown = 30_000, + onMemorySnapshot, + ...loadtestOpts + } = opts; + it( + 'should have stable memory usage', + { + timeout: idle + duration + calmdown + 10_000, // allow 10s for the test teardown + }, + async ({ expect }) => { + const server = await setup(); + + const startTime = new Date() + .toISOString() + // replace time colons with dashes to make it a valid filename + .replaceAll(':', '-') + // remove milliseconds + .split('.')[0]; + + const snapshots = await loadtest({ + ...loadtestOpts, + cwd, + memorySnapshotWindow, + idle, + duration, + calmdown, + server, + async onMemorySnapshot(memoryUsageInMB, phase, snapshots) { + if (isDebug('memtest')) { + const chart = createLineChart( + snapshots.total.map( + (_, i) => `${i + memorySnapshotWindow / 1000}. sec`, + ), + [ + { + label: 'Idle', + data: snapshots.idle, + }, + ...(snapshots.loadtest.length + ? [ + { + label: 'Loadtest', + data: [ + ...snapshots.idle.map(() => null), // skip idle data + ...snapshots.loadtest, + ], + }, + ] + : []), + ...(snapshots.calmdown.length + ? [ + { + label: 'Calmdown', + data: [ + ...snapshots.idle.map(() => null), // skip idle data + ...snapshots.loadtest.map(() => null), // skip loadtest data + ...snapshots.calmdown, + ], + }, + ] + : []), + ], + { + yTicksCallback: (tickValue) => `${tickValue} MB`, + }, + ); + await fs.writeFile( + path.join(cwd, `memtest-memory-snapshots_${startTime}.svg`), + chart.toBuffer(), + ); + } + return onMemorySnapshot?.(memoryUsageInMB, phase, snapshots); + }, + }); + + const idleSlope = calculateRegressionSlope(snapshots.idle); + debugLog(`server memory idle regression slope: ${idleSlope}`); + expect + .soft(idleSlope, 'Memory increase detected while idling') + .toBeLessThanOrEqual(0); + + const loadtestSlope = calculateRegressionSlope(snapshots.loadtest); + debugLog(`server memory loadtest regression slope: ${loadtestSlope}`); + expect + .soft(loadtestSlope, 'Memory never stopped growing during loadtest') + .toBeLessThanOrEqual(1); + + const calmdownSlope = calculateRegressionSlope(snapshots.calmdown); + debugLog(`server memory calmdown regression slope: ${calmdownSlope}`); + expect + .soft(calmdownSlope, 'No memory decrease detected during calmdown') + .toBeLessThanOrEqual(-10); + }, + ); +} + +/** + * Detects a memory increase trend in an array of memory snapshots over time using linear regression. + * + * @param snapshots - An array of memory snapshots in MB. + * + * @returns The slope of the linear regression line. + */ +function calculateRegressionSlope(snapshots: number[]) { + if (snapshots.length < 2) { + throw new Error('Not enough snapshots to determine trend'); + } + + const data: [x: number, y: number][] = snapshots.map((memInMB, timestamp) => [ + timestamp, + memInMB, + ]); + const result = regression.linear(data); + const slope = result.equation[0]; + + return slope; +} + +function debugLog(msg: string) { + if (isDebug('memtest')) { + console.log(`[memtest] ${msg}`); + } +} diff --git a/internal/proc/src/index.ts b/internal/proc/src/index.ts index 3acd4a087..d836ef6c4 100644 --- a/internal/proc/src/index.ts +++ b/internal/proc/src/index.ts @@ -9,6 +9,7 @@ import { fetch } from '@whatwg-node/fetch'; import terminate from 'terminate/promise'; export interface Proc extends AsyncDisposable { + waitForExit: Promise; getStd(o: 'out' | 'err' | 'both'): string; getStats(): Promise<{ // Total CPU utilization (of all cores) as a percentage. @@ -18,6 +19,11 @@ export interface Proc extends AsyncDisposable { }>; } +export interface Server extends Proc { + port: number; + protocol: string; +} + export interface ProcOptions { /** * Pipe the logs from the spawned process to the current process, or to a file @@ -79,6 +85,7 @@ export function spawn( let stderr = ''; let stdboth = ''; const proc: Proc = { + waitForExit, getStd(o) { switch (o) { case 'out': diff --git a/internal/testing/src/env.ts b/internal/testing/src/env.ts index 73413e6d4..ec27c53e6 100644 --- a/internal/testing/src/env.ts +++ b/internal/testing/src/env.ts @@ -1,7 +1,20 @@ import os from 'node:os'; -/** Checks whether the `DEBUG` environment variable is truthy. */ -export function isDebug() { +/** + * Checks whether the `DEBUG` environment variable is truthy. + * If the {@link module} is provided, then the debug will only + * be activated for the specific module in code. + * + * For example, `isDebug('loadtest')` will only activate when the + * variable `loadtest` is listed in the `DEBUG` enviorment variable. + */ +export function isDebug(module?: string) { + if (module) { + const modules = String(process.env['DEBUG']) + .split(',') + .map((m) => m.trim()); + return modules.includes(module); + } return boolEnv('DEBUG'); } diff --git a/package.json b/package.json index 11e7b2f1b..4ad07e752 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "test": "vitest --project unit", "test:bun": "bun test --bail", "test:e2e": "vitest --project e2e", - "test:leaks": "cross-env \"LEAK_TEST=1\" jest --detectOpenHandles --detectLeaks" + "test:leaks": "cross-env \"LEAK_TEST=1\" jest --detectOpenHandles --detectLeaks", + "test:mem": "vitest --project memtest" }, "devDependencies": { "@babel/core": "7.26.8", diff --git a/tsconfig.json b/tsconfig.json index 012777265..3c7e46cbc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,8 @@ "@internal/testing": ["./internal/testing/src/index.ts"], "@internal/e2e": ["./internal/e2e/src/index.ts"], "@internal/proc": ["./internal/proc/src/index.ts"], + "@internal/perf": ["./internal/perf/src/index.ts"], + "@internal/perf/memtest": ["./internal/perf/src/memtest.ts"], "@internal/testing/to-be-similar-string": [ "./internal/testing/src/to-be-similar-string.ts" ], diff --git a/vitest.projects.ts b/vitest.projects.ts index 3e7df6045..b7d8df15a 100644 --- a/vitest.projects.ts +++ b/vitest.projects.ts @@ -44,4 +44,13 @@ export default defineWorkspace([ }, }, }, + { + extends: './vitest.config.ts', + test: { + name: 'memtest', + include: ['**/*.memtest.ts'], + hookTimeout: testTimeout, + testTimeout, + }, + }, ]); diff --git a/yarn.lock b/yarn.lock index cb360bb8e..cf6f1551c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4747,6 +4747,20 @@ __metadata: languageName: unknown linkType: soft +"@internal/perf@workspace:internal/perf": + version: 0.0.0-use.local + resolution: "@internal/perf@workspace:internal/perf" + dependencies: + "@types/k6": "npm:^0.54.2" + "@types/regression": "npm:^2" + canvas: "npm:^3.1.0" + chart.js: "npm:^4.4.7" + chartjs-plugin-trendline: "npm:^2.1.6" + parse-duration: "npm:^2.0.0" + regression: "npm:^2.0.1" + languageName: unknown + linkType: soft + "@internal/proc@workspace:internal/proc": version: 0.0.0-use.local resolution: "@internal/proc@workspace:internal/proc" @@ -5137,6 +5151,13 @@ __metadata: languageName: node linkType: hard +"@kurkle/color@npm:^0.3.0": + version: 0.3.4 + resolution: "@kurkle/color@npm:0.3.4" + checksum: 10c0/0e9fd55c614b005c5f0c4c755bca19ec0293bc7513b4ea3ec1725234f9c2fa81afbc78156baf555c8b9cb0d305619253c3f5bca016067daeebb3d00ebb4ea683 + languageName: node + linkType: hard + "@manypkg/find-root@npm:^1.1.0": version: 1.1.0 resolution: "@manypkg/find-root@npm:1.1.0" @@ -6657,6 +6678,13 @@ __metadata: languageName: node linkType: hard +"@types/k6@npm:^0.54.2": + version: 0.54.2 + resolution: "@types/k6@npm:0.54.2" + checksum: 10c0/8d44dbd11ac53cf426abed3dd3bba0a35b355b3ad18b9d220ec5295259d6a4ee5bb97d0b2c38ff6896b085a1ad151678498df137c66aafae6b760b746bb84be8 + languageName: node + linkType: hard + "@types/keyv@npm:^3.1.4": version: 3.1.4 resolution: "@types/keyv@npm:3.1.4" @@ -6743,6 +6771,13 @@ __metadata: languageName: node linkType: hard +"@types/regression@npm:^2": + version: 2.0.6 + resolution: "@types/regression@npm:2.0.6" + checksum: 10c0/c188d7cb023c9c77436b3522eb2ffd0f92e92c36230e591e13b0c45dc69c77d820498ecd6a2b4208ea3cb81df5771e24101732c6b69a5e30d7897406c5f92c18 + languageName: node + linkType: hard + "@types/resolve@npm:1.20.2": version: 1.20.2 resolution: "@types/resolve@npm:1.20.2" @@ -8666,6 +8701,17 @@ __metadata: languageName: node linkType: hard +"canvas@npm:^3.1.0": + version: 3.1.0 + resolution: "canvas@npm:3.1.0" + dependencies: + node-addon-api: "npm:^7.0.0" + node-gyp: "npm:latest" + prebuild-install: "npm:^7.1.1" + checksum: 10c0/28da5184c1d7e97049ba6a24f10690b9ed4b303bbd25517d95c892fa3a6331417791657a3a7467068e40af0dda2dcc9120d062f7426a3d796131e69a30e3cbf1 + languageName: node + linkType: hard + "capital-case@npm:^1.0.4": version: 1.0.4 resolution: "capital-case@npm:1.0.4" @@ -8744,6 +8790,22 @@ __metadata: languageName: node linkType: hard +"chart.js@npm:^4.4.7": + version: 4.4.7 + resolution: "chart.js@npm:4.4.7" + dependencies: + "@kurkle/color": "npm:^0.3.0" + checksum: 10c0/9db499993c561f11184112003956ba96cf00513e025f58846be36e75ebddc6cbab2f93626c3734c305bc801e9362a9ef193b9591e679c59903b2ecb48cfcb317 + languageName: node + linkType: hard + +"chartjs-plugin-trendline@npm:^2.1.6": + version: 2.1.6 + resolution: "chartjs-plugin-trendline@npm:2.1.6" + checksum: 10c0/26a8a6cb7d28a64a282afb2a1eed019f58086c7b789b9ba4de014a641086dfd9dd5d41b54fbfd003eae7741294683166ab214b0c62d5d82d96656ab6e32b03c2 + languageName: node + linkType: hard + "check-error@npm:^2.1.1": version: 2.1.1 resolution: "check-error@npm:2.1.1" @@ -9314,6 +9376,13 @@ __metadata: languageName: node linkType: hard +"deep-extend@npm:^0.6.0": + version: 0.6.0 + resolution: "deep-extend@npm:0.6.0" + checksum: 10c0/1c6b0abcdb901e13a44c7d699116d3d4279fdb261983122a3783e7273844d5f2537dc2e1c454a23fcf645917f93fbf8d07101c1d03c015a87faa662755212566 + languageName: node + linkType: hard + "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -9422,6 +9491,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.0.0": + version: 2.0.3 + resolution: "detect-libc@npm:2.0.3" + checksum: 10c0/88095bda8f90220c95f162bf92cad70bd0e424913e655c20578600e35b91edc261af27531cf160a331e185c0ced93944bc7e09939143225f56312d7fd800fdb7 + languageName: node + linkType: hard + "detect-newline@npm:^3.0.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" @@ -10263,6 +10339,13 @@ __metadata: languageName: node linkType: hard +"expand-template@npm:^2.0.3": + version: 2.0.3 + resolution: "expand-template@npm:2.0.3" + checksum: 10c0/1c9e7afe9acadf9d373301d27f6a47b34e89b3391b1ef38b7471d381812537ef2457e620ae7f819d2642ce9c43b189b3583813ec395e2938319abe356a9b2f51 + languageName: node + linkType: hard + "expect-type@npm:^1.1.0": version: 1.1.0 resolution: "expect-type@npm:1.1.0" @@ -10981,6 +11064,13 @@ __metadata: languageName: node linkType: hard +"github-from-package@npm:0.0.0": + version: 0.0.0 + resolution: "github-from-package@npm:0.0.0" + checksum: 10c0/737ee3f52d0a27e26332cde85b533c21fcdc0b09fb716c3f8e522cfaa9c600d4a631dec9fcde179ec9d47cca89017b7848ed4d6ae6b6b78f936c06825b1fcc12 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -11676,6 +11766,13 @@ __metadata: languageName: node linkType: hard +"ini@npm:~1.3.0": + version: 1.3.8 + resolution: "ini@npm:1.3.8" + checksum: 10c0/ec93838d2328b619532e4f1ff05df7909760b6f66d9c9e2ded11e5c1897d6f2f9980c54dd638f88654b00919ce31e827040631eab0a3969e4d1abefa0719516a + languageName: node + linkType: hard + "ink-text-input@npm:^4.0.3": version: 4.0.3 resolution: "ink-text-input@npm:4.0.3" @@ -13717,7 +13814,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.0, minimist@npm:^1.2.6, minimist@npm:^1.2.8": +"minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.6, minimist@npm:^1.2.8": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 @@ -13842,7 +13939,7 @@ __metadata: languageName: node linkType: hard -"mkdirp-classic@npm:^0.5.2": +"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3": version: 0.5.3 resolution: "mkdirp-classic@npm:0.5.3" checksum: 10c0/95371d831d196960ddc3833cc6907e6b8f67ac5501a6582f47dfae5eb0f092e9f8ce88e0d83afcae95d6e2b61a01741ba03714eeafb6f7a6e9dcc158ac85b168 @@ -13973,6 +14070,13 @@ __metadata: languageName: node linkType: hard +"napi-build-utils@npm:^2.0.0": + version: 2.0.0 + resolution: "napi-build-utils@npm:2.0.0" + checksum: 10c0/5833aaeb5cc5c173da47a102efa4680a95842c13e0d9cc70428bd3ee8d96bb2172f8860d2811799b5daa5cbeda779933601492a2028a6a5351c6d0fcf6de83db + languageName: node + linkType: hard + "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -14018,6 +14122,15 @@ __metadata: languageName: node linkType: hard +"node-abi@npm:^3.3.0": + version: 3.74.0 + resolution: "node-abi@npm:3.74.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10c0/a6c83c448d5e8b591f749a0157c9ec02f653021cdf3415c1a44fcb5fc8afc124acad186bc1ec76cb4db2485cc2dcdda187aacd382c54b6e3093ffc0389603643 + languageName: node + linkType: hard + "node-abort-controller@npm:^3.0.1, node-abort-controller@npm:^3.1.1": version: 3.1.1 resolution: "node-abort-controller@npm:3.1.1" @@ -14796,6 +14909,28 @@ __metadata: languageName: node linkType: hard +"prebuild-install@npm:^7.1.1": + version: 7.1.3 + resolution: "prebuild-install@npm:7.1.3" + dependencies: + detect-libc: "npm:^2.0.0" + expand-template: "npm:^2.0.3" + github-from-package: "npm:0.0.0" + minimist: "npm:^1.2.3" + mkdirp-classic: "npm:^0.5.3" + napi-build-utils: "npm:^2.0.0" + node-abi: "npm:^3.3.0" + pump: "npm:^3.0.0" + rc: "npm:^1.2.7" + simple-get: "npm:^4.0.0" + tar-fs: "npm:^2.0.0" + tunnel-agent: "npm:^0.6.0" + bin: + prebuild-install: bin.js + checksum: 10c0/25919a42b52734606a4036ab492d37cfe8b601273d8dfb1fa3c84e141a0a475e7bad3ab848c741d2f810cef892fcf6059b8c7fe5b29f98d30e0c29ad009bedff + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -15047,6 +15182,20 @@ __metadata: languageName: node linkType: hard +"rc@npm:^1.2.7": + version: 1.2.8 + resolution: "rc@npm:1.2.8" + dependencies: + deep-extend: "npm:^0.6.0" + ini: "npm:~1.3.0" + minimist: "npm:^1.2.0" + strip-json-comments: "npm:~2.0.1" + bin: + rc: ./cli.js + checksum: 10c0/24a07653150f0d9ac7168e52943cc3cb4b7a22c0e43c7dff3219977c2fdca5a2760a304a029c20811a0e79d351f57d46c9bde216193a0f73978496afc2b85b15 + languageName: node + linkType: hard + "react-devtools-core@npm:^4.19.1": version: 4.28.5 resolution: "react-devtools-core@npm:4.28.5" @@ -15259,6 +15408,13 @@ __metadata: languageName: node linkType: hard +"regression@npm:^2.0.1": + version: 2.0.1 + resolution: "regression@npm:2.0.1" + checksum: 10c0/ad3ca209409ed6e3ce1da64ff9779f616c1ef5cfdfc0785681aa670a046d674c8dcdaeac2a7c41f9cb3c22374051246d675e79c2ba3885deb6c9f9aadc756cd8 + languageName: node + linkType: hard + "relateurl@npm:^0.2.7": version: 0.2.7 resolution: "relateurl@npm:0.2.7" @@ -15870,6 +16026,24 @@ __metadata: languageName: node linkType: hard +"simple-concat@npm:^1.0.0": + version: 1.0.1 + resolution: "simple-concat@npm:1.0.1" + checksum: 10c0/62f7508e674414008910b5397c1811941d457dfa0db4fd5aa7fa0409eb02c3609608dfcd7508cace75b3a0bf67a2a77990711e32cd213d2c76f4fd12ee86d776 + languageName: node + linkType: hard + +"simple-get@npm:^4.0.0": + version: 4.0.1 + resolution: "simple-get@npm:4.0.1" + dependencies: + decompress-response: "npm:^6.0.0" + once: "npm:^1.3.1" + simple-concat: "npm:^1.0.0" + checksum: 10c0/b0649a581dbca741babb960423248899203165769747142033479a7dc5e77d7b0fced0253c731cd57cf21e31e4d77c9157c3069f4448d558ebc96cf9e1eebcf0 + languageName: node + linkType: hard + "sisteransi@npm:^1.0.5": version: 1.0.5 resolution: "sisteransi@npm:1.0.5" @@ -16298,6 +16472,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:~2.0.1": + version: 2.0.1 + resolution: "strip-json-comments@npm:2.0.1" + checksum: 10c0/b509231cbdee45064ff4f9fd73609e2bcc4e84a4d508e9dd0f31f70356473fde18abfb5838c17d56fb236f5a06b102ef115438de0600b749e818a35fbbc48c43 + languageName: node + linkType: hard + "strnum@npm:^1.0.5": version: 1.0.5 resolution: "strnum@npm:1.0.5" @@ -16355,6 +16536,18 @@ __metadata: languageName: node linkType: hard +"tar-fs@npm:^2.0.0": + version: 2.1.2 + resolution: "tar-fs@npm:2.1.2" + dependencies: + chownr: "npm:^1.1.1" + mkdirp-classic: "npm:^0.5.2" + pump: "npm:^3.0.0" + tar-stream: "npm:^2.1.4" + checksum: 10c0/9c704bd4a53be7565caf34ed001d1428532457fe3546d8fc1233f0f0882c3d2403f8602e8046e0b0adeb31fe95336572a69fb28851a391523126b697537670fc + languageName: node + linkType: hard + "tar-fs@npm:~2.0.1": version: 2.0.1 resolution: "tar-fs@npm:2.0.1" @@ -16367,7 +16560,7 @@ __metadata: languageName: node linkType: hard -"tar-stream@npm:^2.0.0, tar-stream@npm:^2.0.1": +"tar-stream@npm:^2.0.0, tar-stream@npm:^2.0.1, tar-stream@npm:^2.1.4": version: 2.2.0 resolution: "tar-stream@npm:2.2.0" dependencies: @@ -16845,6 +17038,15 @@ __metadata: languageName: node linkType: hard +"tunnel-agent@npm:^0.6.0": + version: 0.6.0 + resolution: "tunnel-agent@npm:0.6.0" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10c0/4c7a1b813e7beae66fdbf567a65ec6d46313643753d0beefb3c7973d66fcec3a1e7f39759f0a0b4465883499c6dc8b0750ab8b287399af2e583823e40410a17a + languageName: node + linkType: hard + "tunnel@npm:^0.0.6": version: 0.0.6 resolution: "tunnel@npm:0.0.6"