diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e2468933eeef..c331823234af 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -815,3 +815,51 @@ jobs: if: contains(needs.*.result, 'failure') run: | echo "One of the dependent jobs have failed. You may need to re-run it." && exit 1 + + replay_metrics: + name: Replay Metrics + needs: [job_get_metadata, job_build] + runs-on: ubuntu-20.04 + timeout-minutes: 30 + if: contains(github.event.pull_request.labels.*.name, 'ci-overhead-measurements') + steps: + - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) + uses: actions/checkout@v3 + with: + ref: ${{ env.HEAD_COMMIT }} + - name: Set up Node + uses: volta-cli/action@v4 + - name: Check dependency cache + uses: actions/cache@v3 + with: + path: ${{ env.CACHED_DEPENDENCY_PATHS }} + key: ${{ needs.job_build.outputs.dependency_cache_key }} + - name: Check build cache + uses: actions/cache@v3 + with: + path: ${{ env.CACHED_BUILD_PATHS }} + key: ${{ env.BUILD_CACHE_KEY }} + + - name: Setup + run: yarn install + working-directory: packages/replay/metrics + + - name: Collect + run: yarn ci:collect + working-directory: packages/replay/metrics + + - name: Process + id: process + run: yarn ci:process + working-directory: packages/replay/metrics + # Don't run on forks - the PR comment cannot be added. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Upload results + uses: actions/upload-artifact@v3 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + with: + name: ${{ steps.process.outputs.artifactName }} + path: ${{ steps.process.outputs.artifactPath }} diff --git a/.gitignore b/.gitignore index 17ef110da73a..d822f532c8cc 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,4 @@ tmp.js # eslint .eslintcache -eslintcache/* +**/eslintcache/* diff --git a/.vscode/launch.json b/.vscode/launch.json index 2fd396c8ddbf..6092a79fb1f6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -37,6 +37,22 @@ "internalConsoleOptions": "openOnSessionStart", "outputCapture": "std" }, + { + "type": "node", + "name": "Debug replay metrics collection script", + "request": "launch", + "cwd": "${workspaceFolder}/packages/replay/metrics/", + "program": "${workspaceFolder}/packages/replay/metrics/configs/dev/collect.ts", + "preLaunchTask": "Build Replay metrics script", + }, + { + "type": "node", + "name": "Debug replay metrics processing script", + "request": "launch", + "cwd": "${workspaceFolder}/packages/replay/metrics/", + "program": "${workspaceFolder}/packages/replay/metrics/configs/dev/process.ts", + "preLaunchTask": "Build Replay metrics script", + }, // Run rollup using the config file which is in the currently active tab. { "name": "Debug rollup (config from open file)", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6e797a064c61..e68b7996f8e9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -7,7 +7,13 @@ "type": "npm", "script": "predebug", "path": "packages/nextjs/test/integration/", - "detail": "Link the SDK (if not already linked) and build test app" + "detail": "Link the SDK (if not already linked) and build test app", + }, + { + "label": "Build Replay metrics script", + "type": "npm", + "script": "build", + "path": "packages/replay/metrics", } ] } diff --git a/packages/replay/.eslintignore b/packages/replay/.eslintignore index 0a749745f94c..c76c6c2d64d1 100644 --- a/packages/replay/.eslintignore +++ b/packages/replay/.eslintignore @@ -3,3 +3,4 @@ build/ demo/build/ # TODO: Check if we can re-introduce linting in demo demo +metrics diff --git a/packages/replay/metrics/.eslintrc.cjs b/packages/replay/metrics/.eslintrc.cjs new file mode 100644 index 000000000000..9f90433a8fa8 --- /dev/null +++ b/packages/replay/metrics/.eslintrc.cjs @@ -0,0 +1,14 @@ +module.exports = { + extends: ['../.eslintrc.js'], + ignorePatterns: ['test-apps'], + overrides: [ + { + files: ['*.ts'], + rules: { + 'no-console': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + 'import/no-unresolved': 'off', + }, + }, + ], +}; diff --git a/packages/replay/metrics/.gitignore b/packages/replay/metrics/.gitignore new file mode 100644 index 000000000000..505d701f0e12 --- /dev/null +++ b/packages/replay/metrics/.gitignore @@ -0,0 +1 @@ +out diff --git a/packages/replay/metrics/README.md b/packages/replay/metrics/README.md new file mode 100644 index 000000000000..6877d491c1b2 --- /dev/null +++ b/packages/replay/metrics/README.md @@ -0,0 +1,11 @@ +# Replay performance metrics + +Evaluates Replay impact on website performance by running a web app in Chromium via Playwright and collecting various metrics. + +The general idea is to run a web app without Sentry Replay and then run the same app again with Sentry and another one with Sentry+Replay included. +For the three scenarios, we collect some metrics (CPU, memory, vitals) and later compare them and post as a comment in a PR. +Changes in the metrics, compared to previous runs from the main branch, should be evaluated on case-by-case basis when preparing and reviewing the PR. + +## Resources + +* https://github.com/addyosmani/puppeteer-webperf diff --git a/packages/replay/metrics/configs/README.md b/packages/replay/metrics/configs/README.md new file mode 100644 index 000000000000..cb9724ba4619 --- /dev/null +++ b/packages/replay/metrics/configs/README.md @@ -0,0 +1,4 @@ +# Replay metrics configuration & entrypoints (scripts) + +* [dev](dev) contains scripts launched during local development +* [ci](ci) contains scripts launched in CI diff --git a/packages/replay/metrics/configs/ci/collect.ts b/packages/replay/metrics/configs/ci/collect.ts new file mode 100644 index 000000000000..67add4feb6f2 --- /dev/null +++ b/packages/replay/metrics/configs/ci/collect.ts @@ -0,0 +1,54 @@ +import { Metrics, MetricsCollector } from '../../src/collector.js'; +import { MetricsStats, NumberProvider } from '../../src/results/metrics-stats.js'; +import { JankTestScenario } from '../../src/scenarios.js'; +import { printStats } from '../../src/util/console.js'; +import { latestResultFile } from './env.js'; + +function checkStdDev(results: Metrics[], name: string, provider: NumberProvider, max: number): boolean { + const value = MetricsStats.stddev(results, provider); + if (value == undefined) { + console.warn(`✗ | Discarding results because StandardDeviation(${name}) is undefined`); + return false; + } else if (value > max) { + console.warn(`✗ | Discarding results because StandardDeviation(${name}) is larger than ${max}. Actual value: ${value}`); + return false; + } else { + console.log(`✓ | StandardDeviation(${name}) is ${value} (<= ${max})`) + } + return true; +} + +const collector = new MetricsCollector({ headless: true, cpuThrottling: 2 }); +const result = await collector.execute({ + name: 'jank', + scenarios: [ + new JankTestScenario('index.html'), + new JankTestScenario('with-sentry.html'), + new JankTestScenario('with-replay.html'), + ], + runs: 10, + tries: 10, + async shouldAccept(results: Metrics[]): Promise { + await printStats(results); + + if (!checkStdDev(results, 'lcp', MetricsStats.lcp, 50) + || !checkStdDev(results, 'cls', MetricsStats.cls, 0.1) + || !checkStdDev(results, 'cpu', MetricsStats.cpu, 1) + || !checkStdDev(results, 'memory-mean', MetricsStats.memoryMean, 1000 * 1024) + || !checkStdDev(results, 'memory-max', MetricsStats.memoryMax, 1000 * 1024)) { + return false; + } + + const cpuUsage = MetricsStats.mean(results, MetricsStats.cpu)!; + if (cpuUsage > 0.85) { + // Note: complexity on the "JankTest" is defined by the `minimum = ...,` setting in app.js - specifying the number of animated elements. + console.warn(`✗ | Discarding results because CPU usage is too high and may be inaccurate: ${(cpuUsage * 100).toFixed(2)} %.`, + 'Consider simplifying the scenario or changing the CPU throttling factor.'); + return false; + } + + return true; + }, +}); + +result.writeToFile(latestResultFile); diff --git a/packages/replay/metrics/configs/ci/env.ts b/packages/replay/metrics/configs/ci/env.ts new file mode 100644 index 000000000000..c41e4bcdf6c3 --- /dev/null +++ b/packages/replay/metrics/configs/ci/env.ts @@ -0,0 +1,4 @@ +export const previousResultsDir = 'out/previous-results'; +export const baselineResultsDir = 'out/baseline-results'; +export const latestResultFile = 'out/latest-result.json'; +export const artifactName = 'replay-sdk-metrics' diff --git a/packages/replay/metrics/configs/ci/process.ts b/packages/replay/metrics/configs/ci/process.ts new file mode 100644 index 000000000000..cf23744e4eab --- /dev/null +++ b/packages/replay/metrics/configs/ci/process.ts @@ -0,0 +1,44 @@ +import path from 'path'; + +import { ResultsAnalyzer } from '../../src/results/analyzer.js'; +import { PrCommentBuilder } from '../../src/results/pr-comment.js'; +import { Result } from '../../src/results/result.js'; +import { ResultsSet } from '../../src/results/results-set.js'; +import { Git } from '../../src/util/git.js'; +import { GitHub } from '../../src/util/github.js'; +import { artifactName, baselineResultsDir, latestResultFile, previousResultsDir } from './env.js'; + +const latestResult = Result.readFromFile(latestResultFile); +const branch = await Git.branch; +const baseBranch = await Git.baseBranch; + +await GitHub.downloadPreviousArtifact(baseBranch, baselineResultsDir, artifactName); +await GitHub.downloadPreviousArtifact(branch, previousResultsDir, artifactName); + +GitHub.writeOutput('artifactName', artifactName) +GitHub.writeOutput('artifactPath', path.resolve(previousResultsDir)); + +const previousResults = new ResultsSet(previousResultsDir); + +const prComment = new PrCommentBuilder(); +if (baseBranch != branch) { + const baseResults = new ResultsSet(baselineResultsDir); + await prComment.addCurrentResult(await ResultsAnalyzer.analyze(latestResult, baseResults), 'Baseline'); + await prComment.addAdditionalResultsSet( + `Baseline results on branch: ${baseBranch}`, + // We skip the first one here because it's already included as `Baseline` column above in addCurrentResult(). + baseResults.items().slice(1, 10) + ); +} else { + await prComment.addCurrentResult(await ResultsAnalyzer.analyze(latestResult, previousResults), 'Previous'); +} + +await prComment.addAdditionalResultsSet( + `Previous results on branch: ${branch}`, + previousResults.items().slice(0, 10) +); + +await GitHub.addOrUpdateComment(prComment); + +// Copy the latest test run results to the archived result dir. +await previousResults.add(latestResultFile, true); diff --git a/packages/replay/metrics/configs/dev/collect.ts b/packages/replay/metrics/configs/dev/collect.ts new file mode 100644 index 000000000000..a159d7a4f7b1 --- /dev/null +++ b/packages/replay/metrics/configs/dev/collect.ts @@ -0,0 +1,30 @@ +import { Metrics, MetricsCollector } from '../../src/collector.js'; +import { MetricsStats } from '../../src/results/metrics-stats.js'; +import { JankTestScenario } from '../../src/scenarios.js'; +import { printStats } from '../../src/util/console.js'; +import { latestResultFile } from './env.js'; + +const collector = new MetricsCollector(); +const result = await collector.execute({ + name: 'dummy', + scenarios: [ + new JankTestScenario('index.html'), + new JankTestScenario('with-sentry.html'), + new JankTestScenario('with-replay.html'), + ], + runs: 1, + tries: 1, + async shouldAccept(results: Metrics[]): Promise { + printStats(results); + + const cpuUsage = MetricsStats.mean(results, MetricsStats.cpu)!; + if (cpuUsage > 0.9) { + console.error(`CPU usage too high to be accurate: ${(cpuUsage * 100).toFixed(2)} %.`, + 'Consider simplifying the scenario or changing the CPU throttling factor.'); + return false; + } + return true; + }, +}); + +result.writeToFile(latestResultFile); diff --git a/packages/replay/metrics/configs/dev/env.ts b/packages/replay/metrics/configs/dev/env.ts new file mode 100644 index 000000000000..c2168763ea6e --- /dev/null +++ b/packages/replay/metrics/configs/dev/env.ts @@ -0,0 +1,2 @@ +export const outDir = 'out/results-dev'; +export const latestResultFile = 'out/latest-result.json'; diff --git a/packages/replay/metrics/configs/dev/process.ts b/packages/replay/metrics/configs/dev/process.ts new file mode 100644 index 000000000000..096244b5c750 --- /dev/null +++ b/packages/replay/metrics/configs/dev/process.ts @@ -0,0 +1,13 @@ +import { ResultsAnalyzer } from '../../src/results/analyzer.js'; +import { Result } from '../../src/results/result.js'; +import { ResultsSet } from '../../src/results/results-set.js'; +import { printAnalysis } from '../../src/util/console.js'; +import { latestResultFile, outDir } from './env.js'; + +const resultsSet = new ResultsSet(outDir); +const latestResult = Result.readFromFile(latestResultFile); + +const analysis = await ResultsAnalyzer.analyze(latestResult, resultsSet); +printAnalysis(analysis); + +await resultsSet.add(latestResultFile, true); diff --git a/packages/replay/metrics/package.json b/packages/replay/metrics/package.json new file mode 100644 index 000000000000..c119f1003c8f --- /dev/null +++ b/packages/replay/metrics/package.json @@ -0,0 +1,32 @@ +{ + "private": true, + "name": "metrics", + "main": "index.js", + "author": "Sentry", + "license": "MIT", + "type": "module", + "scripts": { + "build": "tsc", + "deps": "yarn --cwd ../ build:bundle && yarn --cwd ../../tracing/ build:bundle", + "dev:collect": "ts-node-esm ./configs/dev/collect.ts", + "dev:process": "ts-node-esm ./configs/dev/process.ts", + "ci:collect": "ts-node-esm ./configs/ci/collect.ts", + "ci:process": "ts-node-esm ./configs/ci/process.ts" + }, + "dependencies": { + "@octokit/rest": "^19.0.5", + "@types/node": "^18.11.17", + "axios": "^1.2.2", + "extract-zip": "^2.0.1", + "filesize": "^10.0.6", + "p-timeout": "^6.0.0", + "playwright": "^1.29.1", + "playwright-core": "^1.29.1", + "simple-git": "^3.15.1", + "simple-statistics": "^7.8.0", + "typescript": "^4.9.4" + }, + "devDependencies": { + "ts-node": "^10.9.1" + } +} diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts new file mode 100644 index 000000000000..d8673a8c4021 --- /dev/null +++ b/packages/replay/metrics/src/collector.ts @@ -0,0 +1,166 @@ +import pTimeout from 'p-timeout'; +import * as playwright from 'playwright'; + +import { CpuUsage, CpuUsageSampler, CpuUsageSerialized } from './perf/cpu.js'; +import { JsHeapUsage, JsHeapUsageSampler, JsHeapUsageSerialized } from './perf/memory.js'; +import { PerfMetricsSampler } from './perf/sampler.js'; +import { Result } from './results/result.js'; +import { Scenario, TestCase } from './scenarios.js'; +import { consoleGroup } from './util/console.js'; +import { WebVitals, WebVitalsCollector } from './vitals/index.js'; + +const networkConditions = 'Fast 3G'; + +// Same as puppeteer-core PredefinedNetworkConditions +const PredefinedNetworkConditions = Object.freeze({ + 'Slow 3G': { + download: ((500 * 1000) / 8) * 0.8, + upload: ((500 * 1000) / 8) * 0.8, + latency: 400 * 5, + connectionType: 'cellular3g', + }, + 'Fast 3G': { + download: ((1.6 * 1000 * 1000) / 8) * 0.9, + upload: ((750 * 1000) / 8) * 0.9, + latency: 150 * 3.75, + connectionType: 'cellular3g', + }, +}); + +export class Metrics { + constructor(public readonly vitals: WebVitals, public readonly cpu: CpuUsage, public readonly memory: JsHeapUsage) { } + + public static fromJSON(data: Partial<{ vitals: Partial, cpu: CpuUsageSerialized, memory: JsHeapUsageSerialized }>): Metrics { + return new Metrics( + WebVitals.fromJSON(data.vitals || {}), + CpuUsage.fromJSON(data.cpu || {}), + JsHeapUsage.fromJSON(data.memory || {}), + ); + } +} + +export interface MetricsCollectorOptions { + headless: boolean; + cpuThrottling: number; +} + +export class MetricsCollector { + private _options: MetricsCollectorOptions; + + constructor(options?: Partial) { + this._options = { + headless: false, + cpuThrottling: 4, + ...options + }; + } + + public async execute(testCase: TestCase): Promise { + console.log(`Executing test case ${testCase.name}`); + return consoleGroup(async () => { + const scenarioResults: Metrics[][] = []; + for (let s = 0; s < testCase.scenarios.length; s++) { + scenarioResults.push(await this._collect(testCase, s.toString(), testCase.scenarios[s])); + } + return new Result(testCase.name, this._options.cpuThrottling, networkConditions, scenarioResults); + }); + } + + private async _collect(testCase: TestCase, name: string, scenario: Scenario): Promise { + const label = `Scenario ${name} data collection (total ${testCase.runs} runs)`; + for (let try_ = 1; try_ <= testCase.tries; try_++) { + console.time(label); + const results: Metrics[] = []; + for (let run = 1; run <= testCase.runs; run++) { + const innerLabel = `Scenario ${name} data collection, run ${run}/${testCase.runs}`; + console.time(innerLabel); + try { + results.push(await this._run(scenario)); + } catch (e) { + console.warn(`${innerLabel} failed with ${e}`); + break; + } finally { + console.timeEnd(innerLabel); + } + } + console.timeEnd(label); + if ((results.length == testCase.runs) && await testCase.shouldAccept(results)) { + console.log(`Test case ${testCase.name}, scenario ${name} passed on try ${try_}/${testCase.tries}`); + return results; + } else if (try_ != testCase.tries) { + console.log(`Test case ${testCase.name} failed on try ${try_}/${testCase.tries}, retrying`); + } else { + throw `Test case ${testCase.name}, scenario ${name} failed after ${testCase.tries} tries.`; + } + } + // Unreachable code, if configured properly: + console.assert(testCase.tries >= 1); + return []; + } + + private async _run(scenario: Scenario): Promise { + const disposeCallbacks: (() => Promise)[] = []; + try { + return await pTimeout((async () => { + const browser = await playwright.chromium.launch({ + headless: this._options.headless, + }); + disposeCallbacks.push(() => browser.close()); + const page = await browser.newPage(); + disposeCallbacks.push(() => page.close()); + + const errorLogs: Array = []; + await page.on('console', message => { if (message.type() === 'error') errorLogs.push(message.text()) }); + await page.on('crash', _ => { errorLogs.push('Page crashed') }); + await page.on('pageerror', error => { errorLogs.push(`${error.name}: ${error.message}`) }); + + const cdp = await page.context().newCDPSession(page); + + // Simulate throttling. + await cdp.send('Network.emulateNetworkConditions', { + offline: false, + latency: PredefinedNetworkConditions[networkConditions].latency, + uploadThroughput: PredefinedNetworkConditions[networkConditions].upload, + downloadThroughput: PredefinedNetworkConditions[networkConditions].download, + }); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: this._options.cpuThrottling }); + + // Collect CPU and memory info 10 times per second. + const perfSampler = await PerfMetricsSampler.create(cdp, 100); + disposeCallbacks.push(async () => perfSampler.stop()); + const cpuSampler = new CpuUsageSampler(perfSampler); + const memSampler = new JsHeapUsageSampler(perfSampler); + + const vitalsCollector = await WebVitalsCollector.create(page); + await scenario.run(browser, page); + + // NOTE: FID needs some interaction to actually show a value + const vitals = await vitalsCollector.collect(); + + if (errorLogs.length > 0) { + throw `Error logs in browser console:\n\t\t${errorLogs.join('\n\t\t')}`; + } + + return new Metrics(vitals, cpuSampler.getData(), memSampler.getData()); + })(), { + milliseconds: 60 * 1000, + }); + } finally { + console.log('Disposing of browser and resources'); + disposeCallbacks.reverse(); + const errors = []; + for (const cb of disposeCallbacks) { + try { + await cb(); + } catch (e) { + errors.push(e instanceof Error ? `${e.name}: ${e.message}` : `${e}`); + } + } + if (errors.length > 0) { + console.warn(`All disposose callbacks have finished. Errors: ${errors}`); + } else { + console.warn(`All disposose callbacks have finished.`); + } + } + } +} diff --git a/packages/replay/metrics/src/perf/cpu.ts b/packages/replay/metrics/src/perf/cpu.ts new file mode 100644 index 000000000000..cd64fd6038f5 --- /dev/null +++ b/packages/replay/metrics/src/perf/cpu.ts @@ -0,0 +1,54 @@ +import { JsonObject } from '../util/json.js'; +import { PerfMetrics, PerfMetricsSampler, TimeBasedMap } from './sampler.js'; + +export { CpuUsageSampler, CpuUsage } + +export type CpuUsageSerialized = Partial<{ snapshots: JsonObject, average: number }>; + +class CpuUsage { + constructor(public snapshots: TimeBasedMap, public average: number) { }; + + public static fromJSON(data: CpuUsageSerialized): CpuUsage { + return new CpuUsage( + TimeBasedMap.fromJSON(data.snapshots || {}), + data.average as number, + ); + } +} + +class MetricsDataPoint { + constructor(public timestamp: number, public activeTime: number) { }; +} + +class CpuUsageSampler { + private _snapshots: TimeBasedMap = new TimeBasedMap(); + private _average: number = 0; + private _initial?: MetricsDataPoint = undefined; + private _startTime!: number; + private _lastTimestamp!: number; + private _cumulativeActiveTime!: number; + + public constructor(sampler: PerfMetricsSampler) { + sampler.subscribe(this._collect.bind(this)); + } + + public getData(): CpuUsage { + return new CpuUsage(this._snapshots, this._average); + } + + private async _collect(metrics: PerfMetrics): Promise { + const data = new MetricsDataPoint(metrics.Timestamp, metrics.Duration); + if (this._initial == undefined) { + this._initial = data; + this._startTime = data.timestamp; + } else { + const frameDuration = data.timestamp - this._lastTimestamp; + const usage = frameDuration == 0 ? 0 : (data.activeTime - this._cumulativeActiveTime) / frameDuration; + + this._snapshots.set(data.timestamp, usage); + this._average = data.activeTime / (data.timestamp - this._startTime); + } + this._lastTimestamp = data.timestamp; + this._cumulativeActiveTime = data.activeTime; + } +} diff --git a/packages/replay/metrics/src/perf/memory.ts b/packages/replay/metrics/src/perf/memory.ts new file mode 100644 index 000000000000..97ad3a490e04 --- /dev/null +++ b/packages/replay/metrics/src/perf/memory.ts @@ -0,0 +1,30 @@ +import { JsonObject } from '../util/json.js'; +import { PerfMetrics, PerfMetricsSampler, TimeBasedMap } from './sampler.js'; + +export { JsHeapUsageSampler, JsHeapUsage } + +export type JsHeapUsageSerialized = Partial<{ snapshots: JsonObject }>; + +class JsHeapUsage { + public constructor(public snapshots: TimeBasedMap) { } + + public static fromJSON(data: JsHeapUsageSerialized): JsHeapUsage { + return new JsHeapUsage(TimeBasedMap.fromJSON(data.snapshots || {})); + } +} + +class JsHeapUsageSampler { + private _snapshots: TimeBasedMap = new TimeBasedMap(); + + public constructor(sampler: PerfMetricsSampler) { + sampler.subscribe(this._collect.bind(this)); + } + + public getData(): JsHeapUsage { + return new JsHeapUsage(this._snapshots); + } + + private async _collect(metrics: PerfMetrics): Promise { + this._snapshots.set(metrics.Timestamp, metrics.JSHeapUsedSize!); + } +} diff --git a/packages/replay/metrics/src/perf/sampler.ts b/packages/replay/metrics/src/perf/sampler.ts new file mode 100644 index 000000000000..b4dd26013cd5 --- /dev/null +++ b/packages/replay/metrics/src/perf/sampler.ts @@ -0,0 +1,85 @@ +import * as playwright from 'playwright'; +import { Protocol } from 'playwright-core/types/protocol'; + +import { JsonObject } from '../util/json'; + +export type PerfMetricsConsumer = (metrics: PerfMetrics) => Promise; +export type TimestampSeconds = number; + +export class TimeBasedMap extends Map { + public static fromJSON(entries: JsonObject): TimeBasedMap { + const result = new TimeBasedMap(); + // eslint-disable-next-line guard-for-in + for (const key in entries) { + result.set(parseFloat(key), entries[key]); + } + return result; + } + + public toJSON(): JsonObject { + return Object.fromEntries(this.entries()); + } +} + +export class PerfMetrics { + constructor(private _metrics: Protocol.Performance.Metric[]) { } + + private _find(name: string): number { + return this._metrics.find((metric) => metric.name == name)!.value; + } + + public get Timestamp(): number { + return this._find('Timestamp'); + } + + public get Duration(): number { + return this._find('TaskDuration'); + } + + public get JSHeapUsedSize(): number { + return this._find('JSHeapUsedSize'); + } +} + +export class PerfMetricsSampler { + private _consumers: PerfMetricsConsumer[] = []; + private _timer!: NodeJS.Timer; + private _errorPrinted: boolean = false; + + private constructor(private _cdp: playwright.CDPSession) { } + + public static async create(cdp: playwright.CDPSession, interval: number): Promise { + const self = new PerfMetricsSampler(cdp); + await cdp.send('Performance.enable', { timeDomain: 'timeTicks' }) + + // collect first sample immediately + self._collectSample(); + + // and set up automatic collection in the given interval + self._timer = setInterval(self._collectSample.bind(self), interval); + + return self; + } + + public subscribe(consumer: PerfMetricsConsumer): void { + this._consumers.push(consumer); + } + + public stop(): void { + clearInterval(this._timer); + } + + private _collectSample(): void { + this._cdp.send('Performance.getMetrics').then(response => { + const metrics = new PerfMetrics(response.metrics); + this._consumers.forEach(cb => cb(metrics).catch(console.error)); + }, (e) => { + // This happens if the browser closed unexpectedly. No reason to try again. + if (!this._errorPrinted) { + this._errorPrinted = true; + console.log(e); + this.stop(); + } + }); + } +} diff --git a/packages/replay/metrics/src/results/analyzer.ts b/packages/replay/metrics/src/results/analyzer.ts new file mode 100644 index 000000000000..bdc2ee77864e --- /dev/null +++ b/packages/replay/metrics/src/results/analyzer.ts @@ -0,0 +1,147 @@ +import { filesize } from 'filesize'; + +import { GitHash } from '../util/git.js'; +import { JsonStringify } from '../util/json.js'; +import { AnalyticsFunction, MetricsStats, NumberProvider } from './metrics-stats.js'; +import { Result } from './result.js'; +import { ResultsSet } from './results-set.js'; + +// Compares latest result to previous/baseline results and produces the needed info. +export class ResultsAnalyzer { + private constructor(private _result: Result) { } + + public static async analyze(currentResult: Result, baselineResults?: ResultsSet): Promise { + const items = new ResultsAnalyzer(currentResult)._collect(); + + const baseline = baselineResults?.find( + (other) => other.cpuThrottling == currentResult.cpuThrottling + && other.name == currentResult.name + && other.networkConditions == currentResult.networkConditions + && JsonStringify(other) != JsonStringify(currentResult)); + + let otherHash: GitHash | undefined + if (baseline != undefined) { + otherHash = baseline[0]; + const baseItems = new ResultsAnalyzer(baseline[1])._collect(); + // update items with baseline results + for (const base of baseItems) { + for (const item of items) { + if (item.metric == base.metric) { + item.others = base.values; + } + } + } + } + + return { + items: items, + otherHash: otherHash, + }; + } + + private _collect(): AnalyzerItem[] { + const items = new Array(); + + const scenarioResults = this._result.scenarioResults; + + const pushIfDefined = function (metric: AnalyzerItemMetric, unit: AnalyzerItemUnit, source: NumberProvider, fn: AnalyticsFunction): void { + const values = scenarioResults.map(items => fn(items, source)); + // only push if at least one value is defined + if (values.findIndex(v => v != undefined) >= 0) { + items.push({ + metric: metric, + values: new AnalyzerItemNumberValues(unit, values) + }); + } + } + + pushIfDefined(AnalyzerItemMetric.lcp, AnalyzerItemUnit.ms, MetricsStats.lcp, MetricsStats.mean); + pushIfDefined(AnalyzerItemMetric.cls, AnalyzerItemUnit.ms, MetricsStats.cls, MetricsStats.mean); + pushIfDefined(AnalyzerItemMetric.cpu, AnalyzerItemUnit.ratio, MetricsStats.cpu, MetricsStats.mean); + pushIfDefined(AnalyzerItemMetric.memoryAvg, AnalyzerItemUnit.bytes, MetricsStats.memoryMean, MetricsStats.mean); + pushIfDefined(AnalyzerItemMetric.memoryMax, AnalyzerItemUnit.bytes, MetricsStats.memoryMax, MetricsStats.max); + + return items; + } +} + +export enum AnalyzerItemUnit { + ms, + ratio, // 1.0 == 100 % + bytes, +} + +export interface AnalyzerItemValues { + value(index: number): string; + diff(aIndex: number, bIndex: number): string; + percent(aIndex: number, bIndex: number): string; +} + +const AnalyzerItemValueNotAvailable = 'n/a'; + +class AnalyzerItemNumberValues implements AnalyzerItemValues { + constructor(private _unit: AnalyzerItemUnit, private _values: (number | undefined)[]) { } + + private _has(index: number): boolean { + return index >= 0 && index < this._values.length && this._values[index] != undefined; + } + + private _get(index: number): number { + return this._values[index]!; + } + + public value(index: number): string { + if (!this._has(index)) return AnalyzerItemValueNotAvailable; + return this._withUnit(this._get(index)); + } + + public diff(aIndex: number, bIndex: number): string { + if (!this._has(aIndex) || !this._has(bIndex)) return AnalyzerItemValueNotAvailable; + const diff = this._get(bIndex) - this._get(aIndex); + const str = this._withUnit(diff, true); + return diff > 0 ? `+${str}` : str; + } + + public percent(aIndex: number, bIndex: number): string { + if (!this._has(aIndex) || !this._has(bIndex) || this._get(aIndex) == 0.0) return AnalyzerItemValueNotAvailable; + const percent = this._get(bIndex) / this._get(aIndex) * 100 - 100; + const str = `${percent.toFixed(2)} %`; + return percent > 0 ? `+${str}` : str; + } + + private _withUnit(value: number, isDiff: boolean = false): string { + switch (this._unit) { + case AnalyzerItemUnit.bytes: + return filesize(value) as string; + case AnalyzerItemUnit.ratio: + return `${(value * 100).toFixed(2)} ${isDiff ? 'pp' : '%'}`; + default: + return `${value.toFixed(2)} ${AnalyzerItemUnit[this._unit]}`; + } + } +} + +export enum AnalyzerItemMetric { + lcp, + cls, + cpu, + memoryAvg, + memoryMax, +} + +export interface AnalyzerItem { + metric: AnalyzerItemMetric; + + // Current (latest) result. + values: AnalyzerItemValues; + + // Previous or baseline results, depending on the context. + others?: AnalyzerItemValues; +} + +export interface Analysis { + items: AnalyzerItem[]; + + // Commit hash that the the previous or baseline (depending on the context) result was collected for. + otherHash?: GitHash; +} diff --git a/packages/replay/metrics/src/results/metrics-stats.ts b/packages/replay/metrics/src/results/metrics-stats.ts new file mode 100644 index 000000000000..fd23d032c11d --- /dev/null +++ b/packages/replay/metrics/src/results/metrics-stats.ts @@ -0,0 +1,44 @@ +import * as ss from 'simple-statistics' + +import { Metrics } from '../collector'; + +export type NumberProvider = (metrics: Metrics) => number | undefined; +export type AnalyticsFunction = (items: Metrics[], dataProvider: NumberProvider) => number | undefined; + +export class MetricsStats { + static lcp: NumberProvider = metrics => metrics.vitals.lcp; + static cls: NumberProvider = metrics => metrics.vitals.cls; + static cpu: NumberProvider = metrics => metrics.cpu.average; + static memoryMean: NumberProvider = metrics => ss.mean(Array.from(metrics.memory.snapshots.values())); + static memoryMax: NumberProvider = metrics => ss.max(Array.from(metrics.memory.snapshots.values())); + + static mean: AnalyticsFunction = (items: Metrics[], dataProvider: NumberProvider) => { + const numbers = MetricsStats._filteredValues(MetricsStats._collect(items, dataProvider)); + return numbers.length > 0 ? ss.mean(numbers) : undefined; + } + + static max: AnalyticsFunction = (items: Metrics[], dataProvider: NumberProvider) => { + const numbers = MetricsStats._filteredValues(MetricsStats._collect(items, dataProvider)); + return numbers.length > 0 ? ss.max(numbers) : undefined; + } + + static stddev: AnalyticsFunction = (items: Metrics[], dataProvider: NumberProvider) => { + const numbers = MetricsStats._filteredValues(MetricsStats._collect(items, dataProvider)); + return numbers.length > 0 ? ss.standardDeviation(numbers) : undefined; + } + + private static _collect(items: Metrics[], dataProvider: NumberProvider): number[] { + return items.map(dataProvider).filter(v => v != undefined && !Number.isNaN(v)) as number[]; + } + + // See https://en.wikipedia.org/wiki/Interquartile_range#Outliers for details on filtering. + private static _filteredValues(numbers: number[]): number[] { + numbers.sort((a, b) => a - b) + + const q1 = ss.quantileSorted(numbers, 0.25); + const q3 = ss.quantileSorted(numbers, 0.75); + const iqr = q3 - q1 + + return numbers.filter(num => num >= (q1 - 1.5 * iqr) && num <= (q3 + 1.5 * iqr)) + } +} diff --git a/packages/replay/metrics/src/results/pr-comment.ts b/packages/replay/metrics/src/results/pr-comment.ts new file mode 100644 index 000000000000..618c1aa8d76e --- /dev/null +++ b/packages/replay/metrics/src/results/pr-comment.ts @@ -0,0 +1,153 @@ +import { Git } from '../util/git.js'; +import { Analysis, AnalyzerItemMetric, AnalyzerItemValues, ResultsAnalyzer } from './analyzer.js'; +import { Result } from './result.js'; +import { ResultSetItem } from './results-set.js'; + +function trimIndent(str: string): string { + return str.trim().split('\n').map(s => s.trim()).join('\n'); +} + +function printableMetricName(metric: AnalyzerItemMetric): string { + switch (metric) { + case AnalyzerItemMetric.lcp: + return 'LCP'; + case AnalyzerItemMetric.cls: + return 'CLS'; + case AnalyzerItemMetric.cpu: + return 'CPU'; + case AnalyzerItemMetric.memoryAvg: + return 'JS heap avg'; + case AnalyzerItemMetric.memoryMax: + return 'JS heap max'; + default: + return AnalyzerItemMetric[metric]; + } +} + +export class PrCommentBuilder { + private _buffer: string = ''; + + public get title(): string { + return 'Replay SDK metrics :rocket:'; + } + + public get body(): string { + const now = new Date(); + return trimIndent(` + ${this._buffer} +
+
+ *) pp - percentage points - an absolute difference between two percentages.
+ Last updated: +
+ `); + } + + public async addCurrentResult(analysis: Analysis, otherName: string): Promise { + // Decides whether to print the "Other" for comparison depending on it being set in the input data. + const hasOther = analysis.otherHash != undefined; + const maybeOther = function (content: () => string): string { + return hasOther ? content() : ''; + } + + const currentHash = await Git.hash + + this._buffer += `

${this.title}

`; + if (!hasOther) { + this._buffer += `Latest data for: ${currentHash}`; + } + this._buffer += ` + + + + + ${maybeOther(() => ``)} + + + + + + ${maybeOther(() => ``)} + + + + + + + + `; + + const valueColumns = function (values: AnalyzerItemValues): string { + return ` + + + + + + + + `; + } + + for (const item of analysis.items) { + if (hasOther) { + this._buffer += ` + + + + + ` + } else { + this._buffer += ` + + + ${valueColumns(item.values)} + ` + } + } + + this._buffer += ` +
  Plain+Sentry+Replay
RevisionValueValueDiffRatioValueDiffRatio
${values.value(0)}${values.value(1)}${values.diff(0, 1)}${values.percent(0, 1)}${values.value(2)}${values.diff(1, 2)}${values.percent(1, 2)}
${printableMetricName(item.metric)}This PR ${currentHash} + ${valueColumns(item.values)} +
${otherName} ${analysis.otherHash} + ${valueColumns(item.others!)} +
${printableMetricName(item.metric)}
`; + } + + public async addAdditionalResultsSet(name: string, resultFiles: ResultSetItem[]): Promise { + if (resultFiles.length == 0) return; + + this._buffer += ` +
+

${name}

+ `; + + // Each `resultFile` will be printed as a single row - with metrics as table columns. + for (let i = 0; i < resultFiles.length; i++) { + const resultFile = resultFiles[i]; + // Load the file and "analyse" - collect stats we want to print. + const analysis = await ResultsAnalyzer.analyze(Result.readFromFile(resultFile.path)); + + if (i == 0) { + // Add table header + this._buffer += ''; + for (const item of analysis.items) { + this._buffer += ``; + } + this._buffer += ''; + } + + // Add table row + this._buffer += ``; + for (const item of analysis.items) { + // TODO maybe find a better way of showing this. After the change to multiple scenarios, this shows diff between "With Sentry" and "With Sentry + Replay" + this._buffer += ``; + } + this._buffer += ''; + } + + this._buffer += ` +
Revision${printableMetricName(item.metric)}
${resultFile.hash}${item.values.diff(1, 2)}
+
`; + } +} diff --git a/packages/replay/metrics/src/results/result.ts b/packages/replay/metrics/src/results/result.ts new file mode 100644 index 000000000000..ed4ca476b039 --- /dev/null +++ b/packages/replay/metrics/src/results/result.ts @@ -0,0 +1,32 @@ +import * as fs from 'fs'; +import path from 'path'; + +import { Metrics } from '../collector.js'; +import { JsonObject, JsonStringify } from '../util/json.js'; + +export class Result { + constructor( + public readonly name: string, public readonly cpuThrottling: number, + public readonly networkConditions: string, + public readonly scenarioResults: Metrics[][]) { } + + public static readFromFile(filePath: string): Result { + const json = fs.readFileSync(filePath, { encoding: 'utf-8' }); + const data = JSON.parse(json) as JsonObject; + return new Result( + data.name as string, + data.cpuThrottling as number, + data.networkConditions as string, + (data.scenarioResults as Partial[][] || []).map(list => list.map(Metrics.fromJSON.bind(Metrics))) + ); + } + + public writeToFile(filePath: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir); + } + const json = JsonStringify(this); + fs.writeFileSync(filePath, json); + } +} diff --git a/packages/replay/metrics/src/results/results-set.ts b/packages/replay/metrics/src/results/results-set.ts new file mode 100644 index 000000000000..ee38efd8a9f9 --- /dev/null +++ b/packages/replay/metrics/src/results/results-set.ts @@ -0,0 +1,87 @@ +import assert from 'assert'; +import * as fs from 'fs'; +import path from 'path'; + +import { Git, GitHash } from '../util/git.js'; +import { Result } from './result.js'; + +const delimiter = '-'; + +export class ResultSetItem { + public constructor(public path: string) { } + + public get name(): string { + return path.basename(this.path); + } + + public get number(): number { + return parseInt(this.parts[0]); + } + + public get hash(): GitHash { + return this.parts[1]; + } + + get parts(): string[] { + return path.basename(this.path).split(delimiter); + } +} + +/// Wraps a directory containing multiple (N--result.json) files. +/// The files are numbered from the most recently added one, to the oldest one. +export class ResultsSet { + public constructor(private _directory: string) { + if (!fs.existsSync(_directory)) { + fs.mkdirSync(_directory, { recursive: true }); + } + } + + public find(predicate: (value: Result) => boolean): [GitHash, Result] | undefined { + for (const item of this.items()) { + const result = Result.readFromFile(item.path); + if (predicate(result)) { + return [item.hash, result]; + } + } + return undefined; + } + + public items(): ResultSetItem[] { + return this._files().map((file) => { + return new ResultSetItem(path.join(this._directory, file.name)); + }).filter((item) => !isNaN(item.number)).sort((a, b) => a.number - b.number); + } + + public async add(newFile: string, onlyIfDifferent: boolean = false): Promise { + console.log(`Preparing to add ${newFile} to ${this._directory}`); + assert(fs.existsSync(newFile)); + + // Get the list of file sorted by the prefix number in the descending order (starting with the oldest files). + const files = this.items().sort((a, b) => b.number - a.number); + + if (onlyIfDifferent && files.length > 0) { + const latestFile = files[files.length - 1]; + if (fs.readFileSync(latestFile.path, { encoding: 'utf-8' }) == fs.readFileSync(newFile, { encoding: 'utf-8' })) { + console.log(`Skipping - it's already stored as ${latestFile.name}`); + return; + } + } + + // Rename all existing files, increasing the prefix + for (const file of files) { + const parts = file.name.split(delimiter); + parts[0] = (file.number + 1).toString(); + const newPath = path.join(this._directory, parts.join(delimiter)); + console.log(`Renaming ${file.path} to ${newPath}`); + fs.renameSync(file.path, newPath); + } + + const newName = `1${delimiter}${await Git.hash}${delimiter}result.json`; + console.log(`Adding ${newFile} to ${this._directory} as ${newName}`); + fs.copyFileSync(newFile, path.join(this._directory, newName)); + } + + private _files(): fs.Dirent[] { + return fs.readdirSync(this._directory, { withFileTypes: true }).filter((v) => v.isFile()) + } +} diff --git a/packages/replay/metrics/src/scenarios.ts b/packages/replay/metrics/src/scenarios.ts new file mode 100644 index 000000000000..86974272394d --- /dev/null +++ b/packages/replay/metrics/src/scenarios.ts @@ -0,0 +1,47 @@ +import assert from 'assert'; +import * as fs from 'fs'; +import path from 'path'; +import * as playwright from 'playwright'; + +import { Metrics } from './collector'; + +// A testing scenario we want to collect metrics for. +export interface Scenario { + run(browser: playwright.Browser, page: playwright.Page): Promise; +} + +// Two scenarios that are compared to each other. +export interface TestCase { + name: string; + scenarios: Scenario[]; + runs: number; + tries: number; + + // Test function that will be executed and given a scenarios result set with exactly `runs` number of items. + // Should returns true if this "try" should be accepted and collected. + // If false is returned, `Collector` will retry up to `tries` number of times. + shouldAccept(results: Metrics[]): Promise; +} + +// A simple scenario that just loads the given URL. +export class LoadPageScenario implements Scenario { + public constructor(public url: string) { } + + public async run(_: playwright.Browser, page: playwright.Page): Promise { + await page.goto(this.url, { waitUntil: 'load', timeout: 60000 }); + } +} + +// Loads test-apps/jank/ as a page source & waits for a short time before quitting. +export class JankTestScenario implements Scenario { + public constructor(private _indexFile: string) { } + + public async run(_: playwright.Browser, page: playwright.Page): Promise { + let url = path.resolve(`./test-apps/jank/${this._indexFile}`); + assert(fs.existsSync(url)); + url = `file:///${url.replace('\\', '/')}`; + console.log('Navigating to ', url); + await page.goto(url, { waitUntil: 'load', timeout: 60000 }); + await new Promise(resolve => setTimeout(resolve, 5000)); + } +} diff --git a/packages/replay/metrics/src/util/console.ts b/packages/replay/metrics/src/util/console.ts new file mode 100644 index 000000000000..9af66f36eb90 --- /dev/null +++ b/packages/replay/metrics/src/util/console.ts @@ -0,0 +1,40 @@ +import { filesize } from 'filesize'; +import { Metrics } from '../collector.js'; + +import { Analysis, AnalyzerItemMetric } from '../results/analyzer.js'; +import { MetricsStats } from '../results/metrics-stats.js'; + +export async function consoleGroup(code: () => Promise): Promise { + console.group(); + return code().finally(console.groupEnd); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type PrintableTable = { [k: string]: any }; + +export function printStats(items: Metrics[]): void { + console.table({ + lcp: `${MetricsStats.mean(items, MetricsStats.lcp)?.toFixed(2)} ms`, + cls: `${MetricsStats.mean(items, MetricsStats.cls)?.toFixed(2)} ms`, + cpu: `${((MetricsStats.mean(items, MetricsStats.cpu) || 0) * 100).toFixed(2)} %`, + memoryMean: filesize(MetricsStats.mean(items, MetricsStats.memoryMean)), + memoryMax: filesize(MetricsStats.max(items, MetricsStats.memoryMax)), + }); +} + +export function printAnalysis(analysis: Analysis): void { + const table: PrintableTable = {}; + for (const item of analysis.items) { + table[AnalyzerItemMetric[item.metric]] = { + value: item.values.value(0), + withSentry: item.values.diff(0, 1), + withReplay: item.values.diff(1, 2), + ...((item.others == undefined) ? {} : { + previous: item.others.value(0), + previousWithSentry: item.others.diff(0, 1), + previousWithReplay: item.others.diff(1, 2) + }) + }; + } + console.table(table); +} diff --git a/packages/replay/metrics/src/util/git.ts b/packages/replay/metrics/src/util/git.ts new file mode 100644 index 000000000000..9ec6acae0600 --- /dev/null +++ b/packages/replay/metrics/src/util/git.ts @@ -0,0 +1,66 @@ +import { simpleGit } from 'simple-git'; + +export type GitHash = string; +const git = simpleGit(); + +async function defaultBranch(): Promise { + const remoteInfo = await git.remote(['show', 'origin']) as string; + for (let line of remoteInfo.split('\n')) { + line = line.trim(); + if (line.startsWith('HEAD branch:')) { + return line.substring('HEAD branch:'.length).trim(); + } + } + throw "Couldn't find base branch name"; +} + +export const Git = { + get repository(): Promise { + return (async () => { + if (typeof process.env.GITHUB_REPOSITORY == 'string' && process.env.GITHUB_REPOSITORY.length > 0) { + return `github.com/${process.env.GITHUB_REPOSITORY}`; + } else { + let url = await git.remote(['get-url', 'origin']) as string; + url = url.trim(); + url = url.replace(/^git@/, ''); + url = url.replace(/\.git$/, ''); + return url.replace(':', '/'); + } + })(); + }, + + get branch(): Promise { + return (async () => { + if (typeof process.env.GITHUB_HEAD_REF == 'string' && process.env.GITHUB_HEAD_REF.length > 0) { + return process.env.GITHUB_HEAD_REF; + } else if (typeof process.env.GITHUB_REF == 'string' && process.env.GITHUB_REF.startsWith('refs/heads/')) { + return process.env.GITHUB_REF.substring('refs/heads/'.length); + } else { + const branches = (await git.branchLocal()).branches; + for (const name in branches) { + if (branches[name].current) return name; + } + throw "Couldn't find current branch name"; + } + })(); + }, + + get baseBranch(): Promise { + if (typeof process.env.GITHUB_BASE_REF == 'string' && process.env.GITHUB_BASE_REF.length > 0) { + return Promise.resolve(process.env.GITHUB_BASE_REF); + } else { + return defaultBranch(); + } + }, + + get hash(): Promise { + return (async () => { + let gitHash = await git.revparse('HEAD'); + const diff = await git.diff(); + if (diff.trim().length > 0) { + gitHash += '+dirty'; + } + return gitHash; + })(); + } +} diff --git a/packages/replay/metrics/src/util/github.ts b/packages/replay/metrics/src/util/github.ts new file mode 100644 index 000000000000..01b0e9fbe991 --- /dev/null +++ b/packages/replay/metrics/src/util/github.ts @@ -0,0 +1,195 @@ +import { Octokit } from '@octokit/rest'; +import axios from 'axios'; +import extract from 'extract-zip'; +import * as fs from 'fs'; +import path from 'path'; + +import { PrCommentBuilder } from '../results/pr-comment.js'; +import { consoleGroup } from './console.js'; +import { Git } from './git.js'; + +const octokit = new Octokit({ + auth: process.env.GITHUB_TOKEN, + // log: console, +}); + +const [, owner, repo] = (await Git.repository).split('/'); +const defaultArgs = { owner, repo } + +async function downloadArtifact(url: string, path: string): Promise { + const writer = fs.createWriteStream(path); + return axios({ + method: 'get', + url: url, + responseType: 'stream', + headers: { + 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}` + } + }).then(response => { + return new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + response.data.pipe(writer); + let error: Error; + writer.on('error', err => { + error = err; + writer.close(); + reject(err); + }); + writer.on('close', () => { + if (!error) resolve(); + }); + }); + }); +} + +async function tryAddOrUpdateComment(commentBuilder: PrCommentBuilder): Promise { + /* Env var GITHUB_REF is only set if a branch or tag is available for the current CI event trigger type. + The ref given is fully-formed, meaning that + * for branches the format is refs/heads/, + * for pull requests it is refs/pull//merge, + * and for tags it is refs/tags/. + For example, refs/heads/feature-branch-1. + */ + let prNumber: number | undefined; + if (typeof process.env.GITHUB_REF == 'string' && process.env.GITHUB_REF.length > 0 && process.env.GITHUB_REF.startsWith('refs/pull/')) { + prNumber = parseInt(process.env.GITHUB_REF.split('/')[2]); + console.log(`Determined PR number ${prNumber} based on GITHUB_REF environment variable: '${process.env.GITHUB_REF}'`); + } else { + prNumber = (await octokit.rest.pulls.list({ + ...defaultArgs, + base: await Git.baseBranch, + head: await Git.branch + })).data[0].number; + if (prNumber != undefined) { + console.log(`Found PR number ${prNumber} based on base and head branches`); + } + } + + if (prNumber == undefined) return false; + + // Determine the PR comment author: + // Trying to fetch `octokit.users.getAuthenticated()` throws (in CI only): + // {"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest/reference/users#get-the-authenticated-user"} + // Let's make this conditional on some env variable that's unlikely to be set locally but will be set in GH Actions. + // Do not use "CI" because that's commonly set during local development and testing. + const author = typeof process.env.GITHUB_ACTION == 'string' ? 'github-actions[bot]' : (await octokit.users.getAuthenticated()).data.login; + + // Try to find an existing comment by the author and title. + const comment = await (async () => { + for await (const comments of octokit.paginate.iterator(octokit.rest.issues.listComments, { + ...defaultArgs, + issue_number: prNumber, + })) { + const found = comments.data.find((comment) => { + return comment.user?.login == author + && comment.body != undefined + && comment.body.indexOf(commentBuilder.title) >= 0; + }); + if (found) return found; + } + return undefined; + })(); + + if (comment != undefined) { + console.log(`Updating PR comment ${comment.html_url} body`) + await octokit.rest.issues.updateComment({ + ...defaultArgs, + comment_id: comment.id, + body: commentBuilder.body, + }); + } else { + console.log(`Adding a new comment to PR ${prNumber}`) + await octokit.rest.issues.createComment({ + ...defaultArgs, + issue_number: prNumber, + body: commentBuilder.body, + }); + } + + return true; +} + +export const GitHub = { + writeOutput(name: string, value: string): void { + if (typeof process.env.GITHUB_OUTPUT == 'string' && process.env.GITHUB_OUTPUT.length > 0) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); + } + console.log(`Output ${name} = ${value}`); + }, + + downloadPreviousArtifact(branch: string, targetDir: string, artifactName: string): Promise { + console.log(`Trying to download previous artifact '${artifactName}' for branch '${branch}'`); + return consoleGroup(async () => { + fs.mkdirSync(targetDir, { recursive: true }); + + const workflow = await (async () => { + for await (const workflows of octokit.paginate.iterator(octokit.rest.actions.listRepoWorkflows, defaultArgs)) { + const found = workflows.data.find((w) => w.name == process.env.GITHUB_WORKFLOW); + if (found) return found; + } + return undefined; + })(); + if (workflow == undefined) { + console.log( + `Skipping previous artifact '${artifactName}' download for branch '${branch}' - not running in CI?`, + "Environment variable GITHUB_WORKFLOW isn't set." + ); + return; + } + + const workflowRuns = await octokit.actions.listWorkflowRuns({ + ...defaultArgs, + workflow_id: workflow.id, + branch: branch, + status: 'success', + }); + + if (workflowRuns.data.total_count == 0) { + console.warn(`Couldn't find any successful run for workflow '${workflow.name}'`); + return; + } + + const artifact = (await octokit.actions.listWorkflowRunArtifacts({ + ...defaultArgs, + run_id: workflowRuns.data.workflow_runs[0].id, + })).data.artifacts.find((it) => it.name == artifactName); + + if (artifact == undefined) { + console.warn(`Couldn't find any artifact matching ${artifactName}`); + return; + } + + console.log(`Downloading artifact ${artifact.archive_download_url} and extracting to ${targetDir}`); + + const tempFilePath = path.resolve(targetDir, '../tmp-artifacts.zip'); + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + + try { + await downloadArtifact(artifact.archive_download_url, tempFilePath); + await extract(tempFilePath, { dir: path.resolve(targetDir) }); + } finally { + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + } + }); + }, + + async addOrUpdateComment(commentBuilder: PrCommentBuilder): Promise { + console.log('Adding/updating PR comment'); + return consoleGroup(async () => { + let successful = false; + try { + successful = await tryAddOrUpdateComment(commentBuilder); + } finally { + if (!successful) { + const file = 'out/comment.html'; + console.log(`Writing built comment to ${path.resolve(file)}`); + fs.writeFileSync(file, commentBuilder.body); + } + } + }); + } +} diff --git a/packages/replay/metrics/src/util/json.ts b/packages/replay/metrics/src/util/json.ts new file mode 100644 index 000000000000..095614fd115e --- /dev/null +++ b/packages/replay/metrics/src/util/json.ts @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + +export type JsonObject = { [k: string]: T }; + +export function JsonStringify(object: T): string { + return JSON.stringify(object, (_: unknown, value: any): unknown => { + if (typeof value != 'undefined' && typeof value.toJSON == 'function') { + return value.toJSON(); + } else { + return value; + } + }, 2); +} diff --git a/packages/replay/metrics/src/vitals/cls.ts b/packages/replay/metrics/src/vitals/cls.ts new file mode 100644 index 000000000000..abe6d63fa58b --- /dev/null +++ b/packages/replay/metrics/src/vitals/cls.ts @@ -0,0 +1,41 @@ +import * as playwright from 'playwright'; + +export { CLS }; + +// https://web.dev/cls/ +class CLS { + constructor( + private _page: playwright.Page) { } + + public async setup(): Promise { + await this._page.context().addInitScript(`{ + window.cumulativeLayoutShiftScore = undefined; + + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (!entry.hadRecentInput) { + if (window.cumulativeLayoutShiftScore === undefined) { + window.cumulativeLayoutShiftScore = entry.value; + } else { + window.cumulativeLayoutShiftScore += entry.value; + } + } + } + }); + + observer.observe({type: 'layout-shift', buffered: true}); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + observer.takeRecords(); + observer.disconnect(); + } + }); + }`); + } + + public async collect(): Promise { + const result = await this._page.evaluate('window.cumulativeLayoutShiftScore'); + return result as number; + } +} diff --git a/packages/replay/metrics/src/vitals/fid.ts b/packages/replay/metrics/src/vitals/fid.ts new file mode 100644 index 000000000000..fb6baa41537a --- /dev/null +++ b/packages/replay/metrics/src/vitals/fid.ts @@ -0,0 +1,35 @@ +import * as playwright from 'playwright'; + +export { FID }; + +// https://web.dev/fid/ +class FID { + constructor( + private _page: playwright.Page) { } + + public async setup(): Promise { + await this._page.context().addInitScript(`{ + window.firstInputDelay = undefined; + + const observer = new PerformanceObserver((entryList) => { + for (const entry of entryList.getEntries()) { + window.firstInputDelay = entry.processingStart - entry.startTime; + } + }) + + observer.observe({type: 'first-input', buffered: true}); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + observer.takeRecords(); + observer.disconnect(); + } + }); + }`); + } + + public async collect(): Promise { + const result = await this._page.evaluate('window.firstInputDelay'); + return result as number; + } +} diff --git a/packages/replay/metrics/src/vitals/index.ts b/packages/replay/metrics/src/vitals/index.ts new file mode 100644 index 000000000000..3170a6c73cb2 --- /dev/null +++ b/packages/replay/metrics/src/vitals/index.ts @@ -0,0 +1,38 @@ +import * as playwright from 'playwright'; + +import { CLS } from './cls.js'; +import { FID } from './fid.js'; +import { LCP } from './lcp.js'; + +export { WebVitals, WebVitalsCollector }; + +class WebVitals { + constructor(public lcp: number | undefined, public cls: number | undefined, public fid: number | undefined) { } + + public static fromJSON(data: Partial): WebVitals { + return new WebVitals(data.lcp as number, data.cls as number, data.fid as number); + } +} + +class WebVitalsCollector { + private constructor(private _lcp: LCP, private _cls: CLS, private _fid: FID) { + } + + public static async create(page: playwright.Page): + Promise { + const result = + new WebVitalsCollector(new LCP(page), new CLS(page), new FID(page)); + await result._lcp.setup(); + await result._cls.setup(); + await result._fid.setup(); + return result; + } + + public async collect(): Promise { + return new WebVitals( + await this._lcp.collect(), + await this._cls.collect(), + await this._fid.collect(), + ); + } +} diff --git a/packages/replay/metrics/src/vitals/lcp.ts b/packages/replay/metrics/src/vitals/lcp.ts new file mode 100644 index 000000000000..2f817ba97297 --- /dev/null +++ b/packages/replay/metrics/src/vitals/lcp.ts @@ -0,0 +1,35 @@ +import * as playwright from 'playwright'; + +export { LCP }; + +// https://web.dev/lcp/ +class LCP { + constructor( + private _page: playwright.Page) { } + + public async setup(): Promise { + await this._page.context().addInitScript(`{ + window.largestContentfulPaint = undefined; + + const observer = new PerformanceObserver((list) => { + const entries = list.getEntries(); + const lastEntry = entries[entries.length - 1]; + window.largestContentfulPaint = lastEntry.renderTime || lastEntry.loadTime; + }); + + observer.observe({ type: 'largest-contentful-paint', buffered: true }); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + observer.takeRecords(); + observer.disconnect(); + } + }); + }`); + } + + public async collect(): Promise { + const result = await this._page.evaluate('window.largestContentfulPaint'); + return result as number; + } +} diff --git a/packages/replay/metrics/test-apps/jank/README.md b/packages/replay/metrics/test-apps/jank/README.md new file mode 100644 index 000000000000..3e0f46b66a1e --- /dev/null +++ b/packages/replay/metrics/test-apps/jank/README.md @@ -0,0 +1,4 @@ +# Chrome DevTools Jank article sample code + +* Originally coming from [devtools-samples](https://github.com/GoogleChrome/devtools-samples/tree/4818abc9dbcdb954d0eb9b70879f4ea18756451f/jank), licensed under Apache 2.0. +* Linking article: diff --git a/packages/replay/metrics/test-apps/jank/app.js b/packages/replay/metrics/test-apps/jank/app.js new file mode 100644 index 000000000000..a854fd00d187 --- /dev/null +++ b/packages/replay/metrics/test-apps/jank/app.js @@ -0,0 +1,171 @@ +/* Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ + +document.addEventListener("DOMContentLoaded", function() { + 'use strict'; + + var app = {}, + proto = document.querySelector('.proto'), + movers, + bodySize = document.body.getBoundingClientRect(), + ballSize = proto.getBoundingClientRect(), + maxHeight = Math.floor(bodySize.height - ballSize.height), + maxWidth = 97, // 100vw - width of square (3vw) + incrementor = 10, + distance = 3, + frame, + minimum = 20, + subtract = document.querySelector('.subtract'), + add = document.querySelector('.add'); + + app.optimize = true; + app.count = minimum; + app.enableApp = true; + + app.init = function () { + if (movers) { + bodySize = document.body.getBoundingClientRect(); + for (var i = 0; i < movers.length; i++) { + document.body.removeChild(movers[i]); + } + document.body.appendChild(proto); + ballSize = proto.getBoundingClientRect(); + document.body.removeChild(proto); + maxHeight = Math.floor(bodySize.height - ballSize.height); + } + for (var i = 0; i < app.count; i++) { + var m = proto.cloneNode(); + var top = Math.floor(Math.random() * (maxHeight)); + if (top === maxHeight) { + m.classList.add('up'); + } else { + m.classList.add('down'); + } + m.style.left = (i / (app.count / maxWidth)) + 'vw'; + m.style.top = top + 'px'; + document.body.appendChild(m); + } + movers = document.querySelectorAll('.mover'); + }; + + app.update = function (timestamp) { + for (var i = 0; i < app.count; i++) { + var m = movers[i]; + if (!app.optimize) { + var pos = m.classList.contains('down') ? + m.offsetTop + distance : m.offsetTop - distance; + if (pos < 0) pos = 0; + if (pos > maxHeight) pos = maxHeight; + m.style.top = pos + 'px'; + if (m.offsetTop === 0) { + m.classList.remove('up'); + m.classList.add('down'); + } + if (m.offsetTop === maxHeight) { + m.classList.remove('down'); + m.classList.add('up'); + } + } else { + var pos = parseInt(m.style.top.slice(0, m.style.top.indexOf('px'))); + m.classList.contains('down') ? pos += distance : pos -= distance; + if (pos < 0) pos = 0; + if (pos > maxHeight) pos = maxHeight; + m.style.top = pos + 'px'; + if (pos === 0) { + m.classList.remove('up'); + m.classList.add('down'); + } + if (pos === maxHeight) { + m.classList.remove('down'); + m.classList.add('up'); + } + } + } + frame = window.requestAnimationFrame(app.update); + } + + document.querySelector('.stop').addEventListener('click', function (e) { + if (app.enableApp) { + cancelAnimationFrame(frame); + e.target.textContent = 'Start'; + app.enableApp = false; + } else { + frame = window.requestAnimationFrame(app.update); + e.target.textContent = 'Stop'; + app.enableApp = true; + } + }); + + document.querySelector('.optimize').addEventListener('click', function (e) { + if (e.target.textContent === 'Optimize') { + app.optimize = true; + e.target.textContent = 'Un-Optimize'; + } else { + app.optimize = false; + e.target.textContent = 'Optimize'; + } + }); + + add.addEventListener('click', function (e) { + cancelAnimationFrame(frame); + app.count += incrementor; + subtract.disabled = false; + app.init(); + frame = requestAnimationFrame(app.update); + }); + + subtract.addEventListener('click', function () { + cancelAnimationFrame(frame); + app.count -= incrementor; + app.init(); + frame = requestAnimationFrame(app.update); + if (app.count === minimum) { + subtract.disabled = true; + } + }); + + function debounce(func, wait, immediate) { + var timeout; + return function() { + var context = this, args = arguments; + var later = function() { + timeout = null; + if (!immediate) func.apply(context, args); + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) func.apply(context, args); + }; + }; + + var onResize = debounce(function () { + if (app.enableApp) { + cancelAnimationFrame(frame); + app.init(); + frame = requestAnimationFrame(app.update); + } + }, 500); + + window.addEventListener('resize', onResize); + + add.textContent = 'Add ' + incrementor; + subtract.textContent = 'Subtract ' + incrementor; + document.body.removeChild(proto); + proto.classList.remove('.proto'); + app.init(); + window.app = app; + frame = window.requestAnimationFrame(app.update); + +}); diff --git a/packages/replay/metrics/test-apps/jank/favicon-96x96.png b/packages/replay/metrics/test-apps/jank/favicon-96x96.png new file mode 100644 index 000000000000..7f01723cee0f Binary files /dev/null and b/packages/replay/metrics/test-apps/jank/favicon-96x96.png differ diff --git a/packages/replay/metrics/test-apps/jank/index.html b/packages/replay/metrics/test-apps/jank/index.html new file mode 100644 index 000000000000..bcdb2ee1acb9 --- /dev/null +++ b/packages/replay/metrics/test-apps/jank/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + Janky Animation + + + + + + + +
+ + + + + + + +
+ + + diff --git a/packages/replay/metrics/test-apps/jank/logo-1024px.png b/packages/replay/metrics/test-apps/jank/logo-1024px.png new file mode 100644 index 000000000000..84df3e22f6b0 Binary files /dev/null and b/packages/replay/metrics/test-apps/jank/logo-1024px.png differ diff --git a/packages/replay/metrics/test-apps/jank/styles.css b/packages/replay/metrics/test-apps/jank/styles.css new file mode 100644 index 000000000000..1f340f179d0f --- /dev/null +++ b/packages/replay/metrics/test-apps/jank/styles.css @@ -0,0 +1,59 @@ +/* Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing permissions + * and limitations under the License. */ + + * { + margin: 0; + padding: 0; +} + +body { + height: 100vh; + width: 100vw; +} + +.controls { + position: fixed; + top: 2vw; + left: 2vw; + z-index: 1; +} + +.controls button { + display: block; + font-size: 1em; + padding: 1em; + margin: 1em; + background-color: beige; + color: black; +} + +.subtract:disabled { + opacity: 0.2; +} + +.mover { + height: 3vw; + position: absolute; + z-index: 0; +} + +.border { + border: 1px solid black; +} + +@media (max-width: 600px) { + .controls button { + min-width: 20vw; + } +} diff --git a/packages/replay/metrics/test-apps/jank/with-replay.html b/packages/replay/metrics/test-apps/jank/with-replay.html new file mode 100644 index 000000000000..7331eacfdd7f --- /dev/null +++ b/packages/replay/metrics/test-apps/jank/with-replay.html @@ -0,0 +1,56 @@ + + + + + + + + + + Janky Animation + + + + + + + + + + + +
+ + + + + + + +
+ + + diff --git a/packages/replay/metrics/test-apps/jank/with-sentry.html b/packages/replay/metrics/test-apps/jank/with-sentry.html new file mode 100644 index 000000000000..3d43051eaf5a --- /dev/null +++ b/packages/replay/metrics/test-apps/jank/with-sentry.html @@ -0,0 +1,50 @@ + + + + + + + + + + Janky Animation + + + + + + + + + + +
+ + + + + + + +
+ + + diff --git a/packages/replay/metrics/tsconfig.json b/packages/replay/metrics/tsconfig.json new file mode 100644 index 000000000000..1709316f0ea8 --- /dev/null +++ b/packages/replay/metrics/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "outDir": "build", + "esModuleInterop": true, + }, + "include": [ + "src/**/*.ts", + "configs/**/*.ts" + ] +} diff --git a/packages/replay/metrics/yarn.lock b/packages/replay/metrics/yarn.lock new file mode 100644 index 000000000000..b8837974c2a1 --- /dev/null +++ b/packages/replay/metrics/yarn.lock @@ -0,0 +1,457 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + +"@jridgewell/resolve-uri@^3.0.3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" + integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== + +"@jridgewell/sourcemap-codec@^1.4.10": + version "1.4.14" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" + integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== + +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@kwsites/file-exists@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@kwsites/file-exists/-/file-exists-1.1.1.tgz#ad1efcac13e1987d8dbaf235ef3be5b0d96faa99" + integrity sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw== + dependencies: + debug "^4.1.1" + +"@kwsites/promise-deferred@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" + integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== + +"@octokit/auth-token@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-3.0.2.tgz#a0fc8de149fd15876e1ac78f6525c1c5ab48435f" + integrity sha512-pq7CwIMV1kmzkFTimdwjAINCXKTajZErLB4wMLYapR2nuB/Jpr66+05wOTZMSCBXP6n4DdDWT2W19Bm17vU69Q== + dependencies: + "@octokit/types" "^8.0.0" + +"@octokit/core@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-4.1.0.tgz#b6b03a478f1716de92b3f4ec4fd64d05ba5a9251" + integrity sha512-Czz/59VefU+kKDy+ZfDwtOIYIkFjExOKf+HA92aiTZJ6EfWpFzYQWw0l54ji8bVmyhc+mGaLUbSUmXazG7z5OQ== + dependencies: + "@octokit/auth-token" "^3.0.0" + "@octokit/graphql" "^5.0.0" + "@octokit/request" "^6.0.0" + "@octokit/request-error" "^3.0.0" + "@octokit/types" "^8.0.0" + before-after-hook "^2.2.0" + universal-user-agent "^6.0.0" + +"@octokit/endpoint@^7.0.0": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-7.0.3.tgz#0b96035673a9e3bedf8bab8f7335de424a2147ed" + integrity sha512-57gRlb28bwTsdNXq+O3JTQ7ERmBTuik9+LelgcLIVfYwf235VHbN9QNo4kXExtp/h8T423cR5iJThKtFYxC7Lw== + dependencies: + "@octokit/types" "^8.0.0" + is-plain-object "^5.0.0" + universal-user-agent "^6.0.0" + +"@octokit/graphql@^5.0.0": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-5.0.4.tgz#519dd5c05123868276f3ae4e50ad565ed7dff8c8" + integrity sha512-amO1M5QUQgYQo09aStR/XO7KAl13xpigcy/kI8/N1PnZYSS69fgte+xA4+c2DISKqUZfsh0wwjc2FaCt99L41A== + dependencies: + "@octokit/request" "^6.0.0" + "@octokit/types" "^8.0.0" + universal-user-agent "^6.0.0" + +"@octokit/openapi-types@^14.0.0": + version "14.0.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-14.0.0.tgz#949c5019028c93f189abbc2fb42f333290f7134a" + integrity sha512-HNWisMYlR8VCnNurDU6os2ikx0s0VyEjDYHNS/h4cgb8DeOxQ0n72HyinUtdDVxJhFy3FWLGl0DJhfEWk3P5Iw== + +"@octokit/plugin-paginate-rest@^5.0.0": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-5.0.1.tgz#93d7e74f1f69d68ba554fa6b888c2a9cf1f99a83" + integrity sha512-7A+rEkS70pH36Z6JivSlR7Zqepz3KVucEFVDnSrgHXzG7WLAzYwcHZbKdfTXHwuTHbkT1vKvz7dHl1+HNf6Qyw== + dependencies: + "@octokit/types" "^8.0.0" + +"@octokit/plugin-request-log@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz#5e50ed7083a613816b1e4a28aeec5fb7f1462e85" + integrity sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA== + +"@octokit/plugin-rest-endpoint-methods@^6.7.0": + version "6.7.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-6.7.0.tgz#2f6f17f25b6babbc8b41d2bb0a95a8839672ce7c" + integrity sha512-orxQ0fAHA7IpYhG2flD2AygztPlGYNAdlzYz8yrD8NDgelPfOYoRPROfEyIe035PlxvbYrgkfUZIhSBKju/Cvw== + dependencies: + "@octokit/types" "^8.0.0" + deprecation "^2.3.1" + +"@octokit/request-error@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-3.0.2.tgz#f74c0f163d19463b87528efe877216c41d6deb0a" + integrity sha512-WMNOFYrSaX8zXWoJg9u/pKgWPo94JXilMLb2VManNOby9EZxrQaBe/QSC4a1TzpAlpxofg2X/jMnCyZgL6y7eg== + dependencies: + "@octokit/types" "^8.0.0" + deprecation "^2.0.0" + once "^1.4.0" + +"@octokit/request@^6.0.0": + version "6.2.2" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-6.2.2.tgz#a2ba5ac22bddd5dcb3f539b618faa05115c5a255" + integrity sha512-6VDqgj0HMc2FUX2awIs+sM6OwLgwHvAi4KCK3mT2H2IKRt6oH9d0fej5LluF5mck1lRR/rFWN0YIDSYXYSylbw== + dependencies: + "@octokit/endpoint" "^7.0.0" + "@octokit/request-error" "^3.0.0" + "@octokit/types" "^8.0.0" + is-plain-object "^5.0.0" + node-fetch "^2.6.7" + universal-user-agent "^6.0.0" + +"@octokit/rest@^19.0.5": + version "19.0.5" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-19.0.5.tgz#4dbde8ae69b27dca04b5f1d8119d282575818f6c" + integrity sha512-+4qdrUFq2lk7Va+Qff3ofREQWGBeoTKNqlJO+FGjFP35ZahP+nBenhZiGdu8USSgmq4Ky3IJ/i4u0xbLqHaeow== + dependencies: + "@octokit/core" "^4.1.0" + "@octokit/plugin-paginate-rest" "^5.0.0" + "@octokit/plugin-request-log" "^1.0.4" + "@octokit/plugin-rest-endpoint-methods" "^6.7.0" + +"@octokit/types@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-8.0.0.tgz#93f0b865786c4153f0f6924da067fe0bb7426a9f" + integrity sha512-65/TPpOJP1i3K4lBJMnWqPUJ6zuOtzhtagDvydAWbEXpbFYA0oMKKyLb95NFZZP0lSh/4b6K+DQlzvYQJQQePg== + dependencies: + "@octokit/openapi-types" "^14.0.0" + +"@tsconfig/node10@^1.0.7": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" + integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" + integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== + +"@types/node@*", "@types/node@^18.11.17": + version "18.11.17" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.17.tgz#5c009e1d9c38f4a2a9d45c0b0c493fe6cdb4bcb5" + integrity sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng== + +"@types/yauzl@^2.9.1": + version "2.10.0" + resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599" + integrity sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw== + dependencies: + "@types/node" "*" + +acorn-walk@^8.1.1: + version "8.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" + integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== + +acorn@^8.4.1: + version "8.8.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.1.tgz#0a3f9cbecc4ec3bea6f0a80b66ae8dd2da250b73" + integrity sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA== + +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +axios@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.2.2.tgz#72681724c6e6a43a9fea860fc558127dbe32f9f1" + integrity sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q== + dependencies: + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + +before-after-hook@^2.2.0: + version "2.2.3" + resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c" + integrity sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ== + +buffer-crc32@~0.2.3: + version "0.2.13" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" + integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + +debug@^4.1.1, debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +deprecation@^2.0.0, deprecation@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" + integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== + +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + +end-of-stream@^1.1.0: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +extract-zip@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" + integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== + dependencies: + debug "^4.1.1" + get-stream "^5.1.0" + yauzl "^2.10.0" + optionalDependencies: + "@types/yauzl" "^2.9.1" + +fd-slicer@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" + integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g== + dependencies: + pend "~1.2.0" + +filesize@^10.0.6: + version "10.0.6" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.0.6.tgz#5f4cd2721664cd925db3a7a5a87bbfd6ab5ebb1a" + integrity sha512-rzpOZ4C9vMFDqOa6dNpog92CoLYjD79dnjLk2TYDDtImRIyLTOzqojCb05Opd1WuiWjs+fshhCgTd8cl7y5t+g== + +follow-redirects@^1.15.0: + version "1.15.2" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" + integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +get-stream@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + +is-plain-object@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +node-fetch@^2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + +once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +p-timeout@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-6.0.0.tgz#84c210f5500da1af4c31ab2768d794e5e081dd91" + integrity sha512-5iS61MOdUMemWH9CORQRxVXTp9g5K8rPnI9uQpo97aWgsH3vVXKjkIhDi+OgIDmN3Ly9+AZ2fZV01Wut1yzfKA== + +pend@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" + integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== + +playwright-core@1.29.1, playwright-core@^1.29.1: + version "1.29.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.29.1.tgz#9ec15d61c4bd2f386ddf6ce010db53a030345a47" + integrity sha512-20Ai3d+lMkWpI9YZYlxk8gxatfgax5STW8GaMozAHwigLiyiKQrdkt7gaoT9UQR8FIVDg6qVXs9IoZUQrDjIIg== + +playwright@^1.29.1: + version "1.29.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.29.1.tgz#fc04b34f42e3bfc0edadb1c45ef9bffd53c21f70" + integrity sha512-lasC+pMqsQ2uWhNurt3YK3xo0gWlMjslYUylKbHcqF/NTjwp9KStRGO7S6wwz2f52GcSnop8XUK/GymJjdzrxw== + dependencies: + playwright-core "1.29.1" + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +simple-git@^3.15.1: + version "3.15.1" + resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.15.1.tgz#57f595682cb0c2475d5056da078a05c8715a25ef" + integrity sha512-73MVa5984t/JP4JcQt0oZlKGr42ROYWC3BcUZfuHtT3IHKPspIvL0cZBnvPXF7LL3S/qVeVHVdYYmJ3LOTw4Rg== + dependencies: + "@kwsites/file-exists" "^1.1.1" + "@kwsites/promise-deferred" "^1.1.1" + debug "^4.3.4" + +simple-statistics@^7.8.0: + version "7.8.0" + resolved "https://registry.yarnpkg.com/simple-statistics/-/simple-statistics-7.8.0.tgz#1033d2d613656c7bd34f0e134fd7e69c803e6836" + integrity sha512-lTWbfJc0u6GZhBojLOrlHJMTHu6PdUjSsYLrpiH902dVBiYJyWlN/LdSoG8b5VvfG1D30gIBgarqMNeNmU5nAA== + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +ts-node@^10.9.1: + version "10.9.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b" + integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + +typescript@^4.9.4: + version "4.9.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78" + integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg== + +universal-user-agent@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee" + integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w== + +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +yauzl@^2.10.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" + integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g== + dependencies: + buffer-crc32 "~0.2.3" + fd-slicer "~1.1.0" + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==