From 0b290758794bc8ed1467485e24badc3dc3715f69 Mon Sep 17 00:00:00 2001 From: Inomdzhon Mirdzhamolov Date: Thu, 27 Jun 2024 14:56:23 +0300 Subject: [PATCH] tech: create benchmark --- .eslintignore | 1 + benchmark/.env.default | 5 + benchmark/.eslintrc.json | 59 ++++++ benchmark/README.md | 88 ++++++++ benchmark/docker-compose.yml | 30 +++ benchmark/package.json | 32 +++ benchmark/runtime/http-server.mjs | 40 ++++ benchmark/runtime/index.template.html | 18 ++ benchmark/runtime/playwright.config.ts | 45 ++++ benchmark/runtime/shared/constants.ts | 9 + benchmark/runtime/shared/guards.ts | 5 + benchmark/runtime/shared/humanFleSize.ts | 32 +++ benchmark/runtime/shared/react.tsx | 52 +++++ benchmark/runtime/shared/urlOptions.ts | 29 +++ benchmark/runtime/src/noop/index.benchmark.ts | 12 ++ benchmark/runtime/src/noop/index.tsx | 7 + .../runtime/src/touch/index.benchmark.ts | 18 ++ benchmark/runtime/src/touch/index.tsx | 4 + benchmark/runtime/testing/fixtures/index.ts | 40 ++++ .../testing/reporter/BenchmarkReporter.ts | 194 ++++++++++++++++++ benchmark/runtime/testing/reporter/index.ts | 1 + benchmark/runtime/testing/reporter/math.ts | 43 ++++ benchmark/runtime/testing/reporter/types.ts | 30 +++ benchmark/runtime/webpack.config.mjs | 141 +++++++++++++ benchmark/tsconfig.json | 27 +++ benchmark/types/env.d.ts | 20 ++ package.json | 1 + tsconfig.json | 2 +- yarn.lock | 95 ++++++++- 29 files changed, 1075 insertions(+), 5 deletions(-) create mode 100644 benchmark/.env.default create mode 100644 benchmark/.eslintrc.json create mode 100644 benchmark/README.md create mode 100644 benchmark/docker-compose.yml create mode 100644 benchmark/package.json create mode 100644 benchmark/runtime/http-server.mjs create mode 100644 benchmark/runtime/index.template.html create mode 100644 benchmark/runtime/playwright.config.ts create mode 100644 benchmark/runtime/shared/constants.ts create mode 100644 benchmark/runtime/shared/guards.ts create mode 100644 benchmark/runtime/shared/humanFleSize.ts create mode 100644 benchmark/runtime/shared/react.tsx create mode 100644 benchmark/runtime/shared/urlOptions.ts create mode 100644 benchmark/runtime/src/noop/index.benchmark.ts create mode 100644 benchmark/runtime/src/noop/index.tsx create mode 100644 benchmark/runtime/src/touch/index.benchmark.ts create mode 100644 benchmark/runtime/src/touch/index.tsx create mode 100644 benchmark/runtime/testing/fixtures/index.ts create mode 100644 benchmark/runtime/testing/reporter/BenchmarkReporter.ts create mode 100644 benchmark/runtime/testing/reporter/index.ts create mode 100644 benchmark/runtime/testing/reporter/math.ts create mode 100644 benchmark/runtime/testing/reporter/types.ts create mode 100644 benchmark/runtime/webpack.config.mjs create mode 100644 benchmark/tsconfig.json create mode 100644 benchmark/types/env.d.ts diff --git a/.eslintignore b/.eslintignore index 8bb41d4fcdf..dedf04598c4 100644 --- a/.eslintignore +++ b/.eslintignore @@ -9,3 +9,4 @@ storybook-static/ playwright-report/ blob-report/ all-blob-reports/ +tmp/ diff --git a/benchmark/.env.default b/benchmark/.env.default new file mode 100644 index 00000000000..72be8f73f7e --- /dev/null +++ b/benchmark/.env.default @@ -0,0 +1,5 @@ +WEB_SERVER_HOST="127.0.0.1" + +WEB_SERVER_PORT=8888 + +STATIC_BUILD_DIR=/tmp/static/ diff --git a/benchmark/.eslintrc.json b/benchmark/.eslintrc.json new file mode 100644 index 00000000000..00df1dac5ee --- /dev/null +++ b/benchmark/.eslintrc.json @@ -0,0 +1,59 @@ +{ + "root": true, + "env": { + "node": true, + "browser": true + }, + "extends": ["plugin:react-hooks/recommended", "prettier"], + "plugins": ["import", "unicorn"], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2018, // Allows for the parsing of modern ECMAScript features + "sourceType": "module", // Allows for the use of imports + "ecmaFeatures": { + "jsx": true, // Allows for the parsing of JSX + "restParams": true, + "spread": true + } + }, + "rules": { + "react/prop-types": [0], + "no-shadow": "off", + "import/order": [ + "error", + { + "groups": ["builtin", "external", "internal", "parent", "sibling", "index"], + "newlines-between": "never", + "alphabetize": { + "order": "asc" + }, + "pathGroupsExcludedImportTypes": ["**/*.css", "react", "react-dom", "react-dom/**"], + "pathGroups": [ + { + "pattern": "{react,react-dom,react-dom/**}", + "group": "external", + "position": "before" + }, + { + "pattern": "{@vkui/**,@vkui}", + "group": "external", + "position": "after" + }, + { "pattern": "{.,..}/**/*.css", "group": "index", "position": "after" } + ] + } + ], + "sort-imports": [ + "error", + { + "ignoreCase": true, + "ignoreDeclarationSort": true, + "ignoreMemberSort": false, + "allowSeparatedGroups": true, + "memberSyntaxSortOrder": ["none", "single", "all", "multiple"] + } + ], + "curly": "error", // Enforce consistent brace style + "eqeqeq": "error" // Only type-safe equality operators + } +} diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 00000000000..9a7406c5054 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,88 @@ +# Benchmark + +## Runtime + +Выдаёт таблицу с результатами скорости первого рендера (см. [Performance: measure() method](https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure)), +а также некоторые браузерные метрики (см. [Chrome DevTools Protocol – Performance](https://chromedevtools.github.io/devtools-protocol/tot/Performance/)). + +```sh +yarn workspace benchmark runtime:start +``` + +Результат выводится в консоль, а также записывается во временный файл `benchmark.md` в папке `/benchmark/runtime/tmp/`. + +Так как цифры не абсолютные, мы вынуждены следить за изменениями производительности вручную. + +Ниже представлены результаты последнего запуска. + +> Docker запускался локально на ПК со следующими характеристиками: +> +> - Чип Apple M1 Pro +> - Память 16Gb +> - macOS 14.5 (23F79) + +> [!NOTE] +> +> 1. Используется `@vkontakte/vkui/dist/cssm` версия библиотеки. +> 2. `noop` задаёт базовые время и метрики, которые дают понять как выглядит результаты до применения библиотеки. +> 3. `noop with providers` как и **п.2** задаёт базу, но с учётом бойлерплейта библиотеки. + +### noop + +| sampleCount | mean | stdDev | min | median | max | +| ----------- | ----- | -------- | ---- | ------ | ---- | +| 15 | 06.05 | ±00.93ms | 04.3 | 06 | 07.8 | + +| JSEventListeners | JSHeapTotalSize | JSHeapUsedSize | LayoutCount | LayoutDuration | RecalcStyleCount | RecalcStyleDuration | ScriptDuration | TaskDuration | +| ---------------- | --------------- | -------------- | ----------- | -------------- | ---------------- | ------------------- | -------------- | ------------ | +| 140 | 3.4 MiB | 1.8 MiB | 2 | 0.000208 | 2 | 0.00121 | 0.012348 | 0.027074 | + +### noop with providers + +| sampleCount | mean | stdDev | min | median | max | +| ----------- | ----- | -------- | ---- | ------ | ---- | +| 15 | 07.61 | ±01.08ms | 06.2 | 07.2 | 10.1 | + +| JSEventListeners | JSHeapTotalSize | JSHeapUsedSize | LayoutCount | LayoutDuration | RecalcStyleCount | RecalcStyleDuration | ScriptDuration | TaskDuration | +| ---------------- | --------------- | -------------- | ----------- | -------------- | ---------------- | ------------------- | -------------- | ------------ | +| 146 | 3.6 MiB | 1.9 MiB | 2 | 0.000242 | 2 | 0.001816 | 0.013222 | 0.028641 | + +### touch (single) + +| sampleCount | mean | stdDev | min | median | max | +| ----------- | ----- | -------- | ---- | ------ | ---- | +| 15 | 07.98 | ±02.09ms | 06.6 | 07.2 | 15.4 | + +| JSEventListeners | JSHeapTotalSize | JSHeapUsedSize | LayoutCount | LayoutDuration | RecalcStyleCount | RecalcStyleDuration | ScriptDuration | TaskDuration | +| ---------------- | --------------- | -------------- | ----------- | -------------- | ---------------- | ------------------- | -------------- | ------------ | +| 143 | 3.4 MiB | 1.8 MiB | 2 | 0.001632 | 2 | 0.0012 | 0.012209 | 0.028316 | + +### touch width providers (single) + +| sampleCount | mean | stdDev | min | median | max | +| ----------- | ----- | -------- | ---- | ------ | ---- | +| 15 | 10.55 | ±02.38ms | 07.9 | 07.9 | 17.4 | + +| JSEventListeners | JSHeapTotalSize | JSHeapUsedSize | LayoutCount | LayoutDuration | RecalcStyleCount | RecalcStyleDuration | ScriptDuration | TaskDuration | +| ---------------- | --------------- | -------------- | ----------- | -------------- | ---------------- | ------------------- | -------------- | ------------ | +| 149 | 3.6 MiB | 1.9 MiB | 2 | 0.001637 | 2 | 0.001821 | 0.013611 | 0.03002 | + +### touch (multiple) + +| sampleCount | mean | stdDev | min | median | max | +| ----------- | ----- | -------- | ---- | ------ | ---- | +| 15 | 42.65 | ±06.64ms | 36.8 | 41.4 | 65.4 | + +| JSEventListeners | JSHeapTotalSize | JSHeapUsedSize | LayoutCount | LayoutDuration | RecalcStyleCount | RecalcStyleDuration | ScriptDuration | TaskDuration | +| ---------------- | --------------- | -------------- | ----------- | -------------- | ---------------- | ------------------- | -------------- | ------------ | +| 3140 | 27.0 MiB | 9.2 MiB | 2 | 0.011171 | 2 | 0.002869 | 0.035676 | 0.07151 | + +### touch with providers (multiple) + +| sampleCount | mean | stdDev | min | median | max | +| ----------- | ----- | -------- | ---- | ------ | ---- | +| 15 | 43.33 | ±04.62ms | 38.1 | 42 | 57.8 | + +| JSEventListeners | JSHeapTotalSize | JSHeapUsedSize | LayoutCount | LayoutDuration | RecalcStyleCount | RecalcStyleDuration | ScriptDuration | TaskDuration | +| ---------------- | --------------- | -------------- | ----------- | -------------- | ---------------- | ------------------- | -------------- | ------------ | +| 3146 | 26.5 MiB | 9.3 MiB | 3 | 0.010126 | 3 | 0.003575 | 0.037185 | 0.071412 | diff --git a/benchmark/docker-compose.yml b/benchmark/docker-compose.yml new file mode 100644 index 00000000000..3d8e5867c6b --- /dev/null +++ b/benchmark/docker-compose.yml @@ -0,0 +1,30 @@ +services: + benchmark: + image: ${IMAGE} + ipc: host + user: root + working_dir: /repo/benchmark + command: sh -c " + YARN_ENABLE_SCRIPTS=false yarn install --immutable && + yarn run runtime:start:ci ${UPDATE_SNAPSHOTS_FLAG:-} + " + volumes: + - ../:/repo + # Исключаем node_modules. + - /repo/node_modules + # Кешируем установленные внутри контейнера node_modules и кэш директории. + - benchmark_yarn_cache:/yarn + - benchmark_root_node_modules_cache:/repo/node_modules + # Исключаем всё то, что не потребуется в контейнере. + - /repo/benchmark/.swc + - /repo/benchmark/runtime/tmp/static + - /repo/packages/vkui/.swc + - /repo/packages/vkui/dist + - /repo/.git + - /repo/.husky + - /repo/.cache + - /repo/.swc + +volumes: + benchmark_yarn_cache: + benchmark_root_node_modules_cache: diff --git a/benchmark/package.json b/benchmark/package.json new file mode 100644 index 00000000000..13565f2b0bf --- /dev/null +++ b/benchmark/package.json @@ -0,0 +1,32 @@ +{ + "private": true, + "version": "1.0.0", + "name": "benchmark", + "packageManager": "yarn@3.6.3", + "scripts": { + "runtime:build": "yarn workspace @vkontakte/vkui build && webpack --config runtime/webpack.config.mjs", + "runtime:server": "node runtime/http-server.mjs", + "runtime:start": "../scripts/generate_env_docker.sh && docker compose --env-file=./.env.docker up --abort-on-container-exit", + "runtime:start:ci": "yarn run -T playwright test --config runtime/playwright.config.ts" + }, + "dependencies": { + "@playwright/test": "1.45.0", + "@swc/core": "^1.5.25", + "@vkontakte/vkui": "workspace:packages/vkui", + "cli-table3": "^0.6.1", + "css-loader": "^6.10.0", + "css-minimizer-webpack-plugin": "^7.0.0", + "dotenv": "^16.4.5", + "html-webpack-plugin": "^5.5.0", + "mini-css-extract-plugin": "^2.9.0", + "playwright": "1.45.0", + "postcss": "^8.4.38", + "postcss-modules": "^6.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "serve-handler": "^6.1.5", + "swc-loader": "^0.2.6", + "terser-webpack-plugin": "^5.3.10", + "webpack": "^5.91.0" + } +} diff --git a/benchmark/runtime/http-server.mjs b/benchmark/runtime/http-server.mjs new file mode 100644 index 00000000000..bbe0eb4d585 --- /dev/null +++ b/benchmark/runtime/http-server.mjs @@ -0,0 +1,40 @@ +import http from 'node:http'; +import path from 'node:path'; +import dotenv from 'dotenv'; +import handler from 'serve-handler'; + +dotenv.config({ + path: [ + path.resolve(import.meta.dirname, '../.env.default'), + path.resolve(import.meta.dirname, '../.env'), + ], + override: true, +}); + +function createServer({ host, port }) { + const server = http.createServer((request, response) => { + return handler(request, response, { + public: path.join(import.meta.dirname, process.env.STATIC_BUILD_DIR), + }); + }); + + function close() { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + return new Promise((resolve) => { + server.listen(port, host, () => { + resolve({ close }); + }); + }); +} + +void createServer({ host: process.env.WEB_SERVER_HOST, port: process.env.WEB_SERVER_PORT }); diff --git a/benchmark/runtime/index.template.html b/benchmark/runtime/index.template.html new file mode 100644 index 00000000000..23ba60ce95a --- /dev/null +++ b/benchmark/runtime/index.template.html @@ -0,0 +1,18 @@ + + + + + VKUI App + + + + + + + +
+ + diff --git a/benchmark/runtime/playwright.config.ts b/benchmark/runtime/playwright.config.ts new file mode 100644 index 00000000000..6a6f992de25 --- /dev/null +++ b/benchmark/runtime/playwright.config.ts @@ -0,0 +1,45 @@ +import path from 'path'; +import process from 'process'; +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; + +dotenv.config({ + path: [path.resolve(__dirname, '../.env.default'), path.resolve(__dirname, '../.env')], + override: true, +}); + +const webServerUrl = `http://${process.env.WEB_SERVER_HOST}:${process.env.WEB_SERVER_PORT}/`; + +export default defineConfig({ + timeout: 30 * 1000, + + expect: { timeout: 5000 }, + + fullyParallel: false, + + workers: 1, + + reporter: './testing/reporter/index.ts', + + use: { + baseURL: webServerUrl, + trace: 'retain-on-failure', + }, + + webServer: { + command: 'yarn run runtime:build && yarn run runtime:server', + url: webServerUrl, + reuseExistingServer: false, + }, + + projects: [ + { + name: 'Benchmark', + use: devices['Desktop Chrome'], + repeatEach: 15, + testDir: __dirname, + testMatch: '**/*.benchmark.ts', + outputDir: './tmp/playwright-test-results', + }, + ], +}); diff --git a/benchmark/runtime/shared/constants.ts b/benchmark/runtime/shared/constants.ts new file mode 100644 index 00000000000..e2e590b0a3a --- /dev/null +++ b/benchmark/runtime/shared/constants.ts @@ -0,0 +1,9 @@ +export const PERF_MARK_START = 'Perf:Start'; + +export const PERF_MARK_END = 'Perf:End'; + +export const PERF_MEASURE = 'Perf:Measure'; + +export const ATTACHMENT_SAMPLE_NAME = 'sample'; + +export const ATTACHMENT_METRICS_NAME = 'metrics'; diff --git a/benchmark/runtime/shared/guards.ts b/benchmark/runtime/shared/guards.ts new file mode 100644 index 00000000000..30fe71c0a27 --- /dev/null +++ b/benchmark/runtime/shared/guards.ts @@ -0,0 +1,5 @@ +export const isPerformanceMeasure = (data: any): data is PerformanceMeasure => + data !== null && + typeof data === 'object' && + data.hasOwnProperty('startTime') && + data.hasOwnProperty('duration'); diff --git a/benchmark/runtime/shared/humanFleSize.ts b/benchmark/runtime/shared/humanFleSize.ts new file mode 100644 index 00000000000..0b616fbf500 --- /dev/null +++ b/benchmark/runtime/shared/humanFleSize.ts @@ -0,0 +1,32 @@ +/** + * @file см. https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string + * + * Format bytes as human-readable text. + * + * @param bytes Number of bytes. + * @param si True to use metric (SI) units, aka powers of 1000. False to use + * binary (IEC), aka powers of 1024. + * @param dp Number of decimal places to display. + * + * @return Formatted string. + */ +export function humanFileSize(bytes: number, si = false, dp = 1) { + const thresh = si ? 1000 : 1024; + + if (Math.abs(bytes) < thresh) { + return bytes + ' B'; + } + + const units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + let u = -1; + const r = 10 ** dp; + + do { + bytes /= thresh; + ++u; + } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1); + + return bytes.toFixed(dp) + ' ' + units[u]; +} diff --git a/benchmark/runtime/shared/react.tsx b/benchmark/runtime/shared/react.tsx new file mode 100644 index 00000000000..b6116525a77 --- /dev/null +++ b/benchmark/runtime/shared/react.tsx @@ -0,0 +1,52 @@ +import { type ComponentType, type PropsWithChildren, useLayoutEffect, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AdaptivityProvider, AppRoot, ConfigProvider } from '@vkontakte/vkui'; +import { PERF_MARK_END, PERF_MARK_START, PERF_MEASURE } from './constants'; +import { parseOptions } from './urlOptions'; +import '@vkontakte/vkui/dist/cssm/styles/themes.css'; + +const PerformanceMeasure = ({ children }: PropsWithChildren) => { + const ref = useRef(null); + useLayoutEffect(function handleApplicationDidMount() { + ref.current?.getBoundingClientRect(); + performance.mark(PERF_MARK_END); + const performanceMeasure = performance.measure(PERF_MEASURE, PERF_MARK_START, PERF_MARK_END); + console.debug(performanceMeasure); + }, []); + return
{children}
; +}; + +export function render(Component: ComponentType) { + const root = createRoot(document.getElementById('root')!); + const { instanceCount, withProviders } = parseOptions(window.location); + + const items = instanceCount > 1 ? new Array(instanceCount).fill('Lorem ipsum') : ['Lorem ipsum']; + + if (withProviders) { + performance.mark(PERF_MARK_START); + + root.render( + + + + + {items.map((item, index) => ( + {`${item} ${index}`} + ))} + + + + , + ); + } else { + performance.mark(PERF_MARK_START); + + root.render( + + {items.map((item, index) => ( + {`${item} ${index}`} + ))} + , + ); + } +} diff --git a/benchmark/runtime/shared/urlOptions.ts b/benchmark/runtime/shared/urlOptions.ts new file mode 100644 index 00000000000..3e47059d000 --- /dev/null +++ b/benchmark/runtime/shared/urlOptions.ts @@ -0,0 +1,29 @@ +export type Options = { + instanceCount: number; + withProviders: boolean; +}; + +const DEFAULT_PARAMS: Options = { instanceCount: 1, withProviders: false }; + +export const withOptions = (url: string, optionsProp: Partial): string => { + const options = { ...DEFAULT_PARAMS, ...optionsProp }; + const searchParams = new URLSearchParams(); + Object.entries(options).forEach(([key, value]) => { + searchParams.set(key, JSON.stringify(value)); + }); + const result = searchParams.toString(); + return result !== '' ? `${url}?${result}` : url; +}; + +export const parseOptions = (location: Location): Options => { + const options = DEFAULT_PARAMS; + + new URLSearchParams(location.search).forEach((value, key) => { + if (options.hasOwnProperty(key)) { + // @ts-expect-error + options[key] = JSON.parse(value); + } + }); + + return options; +}; diff --git a/benchmark/runtime/src/noop/index.benchmark.ts b/benchmark/runtime/src/noop/index.benchmark.ts new file mode 100644 index 00000000000..8293e998db0 --- /dev/null +++ b/benchmark/runtime/src/noop/index.benchmark.ts @@ -0,0 +1,12 @@ +import { test } from '@playwright/test'; +import { withOptions } from '../../shared/urlOptions'; +import { runMeasure } from '../../testing/fixtures'; + +const URL = '/noop'; + +test.use({ viewport: { width: 1280, height: 760 } }); + +test('noop', ({ page }, testInfo) => runMeasure('noop', page, testInfo)); + +test('noop with providers', ({ page }, testInfo) => + runMeasure(withOptions(URL, { withProviders: true }), page, testInfo)); diff --git a/benchmark/runtime/src/noop/index.tsx b/benchmark/runtime/src/noop/index.tsx new file mode 100644 index 00000000000..58862840124 --- /dev/null +++ b/benchmark/runtime/src/noop/index.tsx @@ -0,0 +1,7 @@ +import { render } from '../../shared/react'; + +function Noop() { + return null; +} + +render(Noop); diff --git a/benchmark/runtime/src/touch/index.benchmark.ts b/benchmark/runtime/src/touch/index.benchmark.ts new file mode 100644 index 00000000000..613d0518011 --- /dev/null +++ b/benchmark/runtime/src/touch/index.benchmark.ts @@ -0,0 +1,18 @@ +import { test } from '@playwright/test'; +import { withOptions } from '../../shared/urlOptions'; +import { runMeasure } from '../../testing/fixtures'; + +const URL = '/touch'; + +test.use({ viewport: { width: 1280, height: 760 } }); + +test('touch (single)', ({ page }, testInfo) => runMeasure(URL, page, testInfo)); + +test('touch width providers (single)', ({ page }, testInfo) => + runMeasure(withOptions(URL, { withProviders: true }), page, testInfo)); + +test('touch (multiple)', ({ page }, testInfo) => + runMeasure(withOptions(URL, { instanceCount: 1000 }), page, testInfo)); + +test('touch with providers (multiple)', ({ page }, testInfo) => + runMeasure(withOptions(URL, { instanceCount: 1000, withProviders: true }), page, testInfo)); diff --git a/benchmark/runtime/src/touch/index.tsx b/benchmark/runtime/src/touch/index.tsx new file mode 100644 index 00000000000..d839c4055d0 --- /dev/null +++ b/benchmark/runtime/src/touch/index.tsx @@ -0,0 +1,4 @@ +import { Touch } from '@vkontakte/vkui'; +import { render } from '../../shared/react'; + +render(Touch); diff --git a/benchmark/runtime/testing/fixtures/index.ts b/benchmark/runtime/testing/fixtures/index.ts new file mode 100644 index 00000000000..7161af0f50e --- /dev/null +++ b/benchmark/runtime/testing/fixtures/index.ts @@ -0,0 +1,40 @@ +import type { Page, TestInfo } from '@playwright/test'; +import { ATTACHMENT_METRICS_NAME, ATTACHMENT_SAMPLE_NAME } from '../../shared/constants'; +import { isPerformanceMeasure } from '../../shared/guards'; + +export const runMeasure = async (url: string, page: Page, testInfo: TestInfo): Promise => { + const waitPerformanceMeasure = initializeWaitPerformanceMeasure(page); + const session = await page.context().newCDPSession(page); + await session.send('Performance.enable'); + + await page.goto(url); + + const performanceMeasure = await waitPerformanceMeasure; + + await testInfo.attach(ATTACHMENT_SAMPLE_NAME, { + body: JSON.stringify(performanceMeasure), + contentType: 'application/json', + }); + + const { metrics: performanceMetrics } = await session.send('Performance.getMetrics'); + + await testInfo.attach(ATTACHMENT_METRICS_NAME, { + body: JSON.stringify(performanceMetrics), + contentType: 'application/json', + }); + + await page.close(); +}; + +function initializeWaitPerformanceMeasure(page: Page): Promise { + return new Promise((resolve) => { + page.on('console', async (message) => { + if (message.type() === 'debug') { + const payload = await message.args()[0].jsonValue(); + if (isPerformanceMeasure(payload)) { + resolve(payload); + } + } + }); + }); +} diff --git a/benchmark/runtime/testing/reporter/BenchmarkReporter.ts b/benchmark/runtime/testing/reporter/BenchmarkReporter.ts new file mode 100644 index 00000000000..5627d35cbb1 --- /dev/null +++ b/benchmark/runtime/testing/reporter/BenchmarkReporter.ts @@ -0,0 +1,194 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter'; +import CliTable from 'cli-table3'; +import { ATTACHMENT_METRICS_NAME, ATTACHMENT_SAMPLE_NAME } from '../../shared/constants'; +import { isPerformanceMeasure } from '../../shared/guards'; +import { humanFileSize } from '../../shared/humanFleSize'; +import { format, getMean, getMedian, getStdDev } from './math'; +import type { + Attachment, + MeasureStats, + ParsedPerformanceMetrics, + PerformanceMetrics, + Table, +} from './types'; + +const OUTPUT_DIR = path.resolve(__dirname, '../../tmp'); + +export class BenchmarkReporter implements Reporter { + private allMeasures = new Map(); + private allMetrics = new Map(); + + onTestEnd(test: TestCase, result: TestResult) { + if (!BenchmarkReporter.isPassed(result)) { + return; + } + + const testMeasures = this.allMeasures.get(test.title) || []; + const testMetrics = this.allMetrics.get(test.title) || []; + + result.attachments.forEach((attachment) => { + const performanceMeasure = BenchmarkReporter.parseMeasureAttachment(attachment); + if (performanceMeasure !== null) { + testMeasures.push(performanceMeasure.duration); + return; + } + + const performanceMetrics = BenchmarkReporter.parseMetricsAttachment(attachment); + if (performanceMetrics !== null) { + testMetrics.push( + performanceMetrics + .filter(({ name }) => BenchmarkReporter.METRICS_INCLUDE_LIST.includes(name)) + .sort((a, b) => (a.name > b.name ? 1 : -1)), + ); + return; + } + }); + + this.allMeasures.set(test.title, testMeasures); + this.allMetrics.set(test.title, testMetrics); + } + + async onEnd() { + const reports: string[] = []; + + this.allMeasures.forEach((samples, testTitle) => { + const tables = BenchmarkReporter.resultsToTables( + BenchmarkReporter.getPerformanceMeasuresSummary(samples), + BenchmarkReporter.getPerformanceMetricsSummary(this.allMetrics.get(testTitle)), + ); + + BenchmarkReporter.log(testTitle, tables); + + reports.push(BenchmarkReporter.toMarkdown(testTitle, tables)); + }); + + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + } + + return fs.promises.writeFile(`${OUTPUT_DIR}/benchmark.md`, reports.join('\n\n'), 'utf-8'); + } + + private static isPassed(result: TestResult) { + return result.status === 'passed'; + } + + private static parseMeasureAttachment(attachment: Attachment): PerformanceMeasure | null { + if (attachment.name === ATTACHMENT_SAMPLE_NAME && Buffer.isBuffer(attachment.body)) { + const data = JSON.parse(attachment.body.toString()); + return isPerformanceMeasure(data) ? data : null; + } + return null; + } + + private static parseMetricsAttachment(attachment: Attachment): PerformanceMetrics | null { + if (attachment.name === ATTACHMENT_METRICS_NAME && Buffer.isBuffer(attachment.body)) { + const data = JSON.parse(attachment.body.toString()); + return Array.isArray(data) ? data : null; + } + return null; + } + + private static getPerformanceMeasuresSummary(measures: number[] = []): MeasureStats { + return { + samples: measures, + sampleCount: measures.length, + mean: getMean(measures), + stdDev: getStdDev(measures), + min: Math.min.apply(null, measures), + median: getMedian(measures), + max: Math.max.apply(null, measures), + }; + } + + private static getPerformanceMetricsSummary( + allMetrics: PerformanceMetrics[] = [], + ): ParsedPerformanceMetrics { + const result: ParsedPerformanceMetrics = []; + const metricValues = new Map(); + + allMetrics.forEach((metrics) => { + metrics.forEach(({ name, value }) => { + const values = metricValues.get(name) || []; + values.push(value); + metricValues.set(name, values); + }); + }); + + metricValues.forEach((values, name) => { + const medianValue = getMedian(values); + result.push({ + name, + value: BenchmarkReporter.METRICS_WITH_SIZE_VALUE.includes(name) + ? humanFileSize(medianValue) + : medianValue, + }); + }); + + return result; + } + + private static resultsToTables( + measureStats: MeasureStats, + metricsProp?: ParsedPerformanceMetrics, + ): Table[] { + const tables: Table[] = []; + + const { sampleCount, median, mean, min, max, stdDev } = measureStats; + + tables.push([ + ['sampleCount', 'mean', 'stdDev', 'min', 'median', 'max'], + [sampleCount, format(mean), `±${format(stdDev)}ms`, format(min), format(median), format(max)], + ]); + + if (metricsProp) { + const [heads, cells] = metricsProp.reduce( + (acc, { name, value }) => { + acc[0].push(name); + acc[1].push(value); + return acc; + }, + [[], []], + ); + tables.push([heads, cells]); + } + + return tables; + } + + private static log(title: string, result: Table[]) { + console.group(`${title}:`); + result.forEach((table) => { + const cliTable = new CliTable({ head: table[0] }); + cliTable.push(table[1]); + console.log(cliTable.toString()); + }); + console.groupEnd(); + } + + private static toMarkdown(title: string, tables: Table[]) { + const markdownTables = tables.map( + ([heads, cells]) => + `| ${heads.join(' | ')} |\n| ${new Array(heads.length).fill('---').join(' | ')} |\n| ${cells.join(' | ')} |`, + ); + + return `### ${title} + +${markdownTables.join('\n\n')}`; + } + + private static METRICS_WITH_SIZE_VALUE = ['JSHeapUsedSize', 'JSHeapTotalSize']; + + private static METRICS_INCLUDE_LIST = [ + 'JSEventListeners', + 'LayoutCount', + 'RecalcStyleCount', + 'LayoutDuration', + 'RecalcStyleDuration', + 'ScriptDuration', + 'TaskDuration', + ...BenchmarkReporter.METRICS_WITH_SIZE_VALUE, + ]; +} diff --git a/benchmark/runtime/testing/reporter/index.ts b/benchmark/runtime/testing/reporter/index.ts new file mode 100644 index 00000000000..e6eb0f23285 --- /dev/null +++ b/benchmark/runtime/testing/reporter/index.ts @@ -0,0 +1 @@ +export { BenchmarkReporter as default } from './BenchmarkReporter'; diff --git a/benchmark/runtime/testing/reporter/math.ts b/benchmark/runtime/testing/reporter/math.ts new file mode 100644 index 00000000000..091e0b0bbb3 --- /dev/null +++ b/benchmark/runtime/testing/reporter/math.ts @@ -0,0 +1,43 @@ +/** + * @file Реализация функций + * - getMedian() + * - getMean() + * - getStdDev() + * - format() + * взята из https://github.com/mui/material-ui/blob/v5.15.20/benchmark/browser/scripts/benchmark.js. + * + * Изменения: + * - Добавлены небольшие изменения, вызванные TypeScript. + * - У `getMedian()` исправил мутацию массива из аргумента. + */ + +export function getMedian(valuesProp: number[]): number { + const values = [...valuesProp].sort(); + const length = values.length; + if (length % 2 === 0) { + return (values[length / 2] + values[length / 2 - 1]) / 2; + } + return values[parseInt(`${length / 2}`, 10)]; +} + +export function getMean(values: number[]): number { + const sum = values.reduce((acc, value) => acc + value, 0); + return sum / values.length; +} + +export function getStdDev(values: number[]): number { + const mean = getMean(values); + + const squareDiffs = values.map((value) => { + const diff = value - mean; + return diff * diff; + }); + + return Math.sqrt(getMean(squareDiffs)); +} + +export function format(time: number): string | number { + const x = Number(`${time}e2`); + const i = Number(Number(`${Math.round(x)}e-2`).toFixed(2)); + return 10 / i > 1 ? `0${i}` : i; +} diff --git a/benchmark/runtime/testing/reporter/types.ts b/benchmark/runtime/testing/reporter/types.ts new file mode 100644 index 00000000000..3301bb7d2be --- /dev/null +++ b/benchmark/runtime/testing/reporter/types.ts @@ -0,0 +1,30 @@ +import type { CDPSession } from '@playwright/test'; +import type { TestResult } from '@playwright/test/reporter'; + +export type PerformanceMetrics = Extract< + Awaited>>, + { metrics: Array<{ name: string; value: number }> } +>['metrics']; + +export type ParsedPerformanceMetrics = Array<{ + name: PerformanceMetrics[number]['name']; + value: string | number; +}>; + +export type Attachment = TestResult['attachments'][0]; + +export type MeasureStats = { + samples: number[]; + sampleCount: number; + mean: number; + median: number; + min: number; + max: number; + stdDev: number; +}; + +type Heads = string[]; + +type Cells = (number | string)[]; + +export type Table = [Heads, Cells]; diff --git a/benchmark/runtime/webpack.config.mjs b/benchmark/runtime/webpack.config.mjs new file mode 100644 index 00000000000..a545fd5ed0f --- /dev/null +++ b/benchmark/runtime/webpack.config.mjs @@ -0,0 +1,141 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; +import dotenv from 'dotenv'; +import HtmlWebpackPlugin from 'html-webpack-plugin'; +import MiniCssExtractPlugin from 'mini-css-extract-plugin'; +import cssModules from 'postcss-modules'; +import TerserPlugin from 'terser-webpack-plugin'; + +dotenv.config({ + path: [ + path.resolve(import.meta.dirname, '../.env.default'), + path.resolve(import.meta.dirname, '../.env'), + ], + override: true, +}); + +const GENERATE_SCOPED_NAME = 'vkui[local]'; +const SWC_LOADER_BASE_OPTIONS = { + target: 'es2016', + externalHelpers: true, + parser: { syntax: 'typescript', tsx: true }, + transform: { react: { runtime: 'automatic' } }, +}; + +const workspaceRoot = path.resolve(import.meta.dirname, '../..'); +const sourceRoot = path.resolve(import.meta.dirname, 'src'); + +const cases = fs.readdirSync(sourceRoot); + +/** @type { import('webpack').Configuration } */ +const webpackConfig = { + context: workspaceRoot, + mode: 'production', + target: 'web', + entry: cases.reduce( + (entries, caseName) => { + entries[caseName] = { + import: path.join(sourceRoot, caseName, 'index.tsx'), + dependOn: 'vendors', + }; + return entries; + }, + { vendors: ['react', 'react-dom'] }, + ), + output: { + path: path.join(import.meta.dirname, process.env.STATIC_BUILD_DIR), + publicPath: '', + filename: '[name].js', + clean: true, + }, + module: { + rules: [ + { + test: /\.tsx?$/, + exclude: /node_modules/, + loader: 'swc-loader', + options: { jsc: SWC_LOADER_BASE_OPTIONS }, + }, + { + test: /\.jsx?$/, + include: /node_modules\/@vkontakte\/vkui/, + loader: 'swc-loader', + options: { + jsc: { + ...SWC_LOADER_BASE_OPTIONS, + experimental: { + plugins: [ + ['swc-plugin-css-modules', { generate_scoped_name: GENERATE_SCOPED_NAME }], + ['swc-plugin-transform-remove-imports', { test: '\\.css$' }], + ], + }, + }, + }, + }, + { + test: /\.css$/i, + exclude: /node_modules/, + use: [MiniCssExtractPlugin.loader, 'css-loader'], + }, + { + test: /\.module.css$/, + include: /node_modules\/@vkontakte\/vkui/, + use: [ + MiniCssExtractPlugin.loader, + { + loader: 'css-loader', + options: { importLoaders: 1 }, + }, + { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: [ + cssModules({ generateScopedName: GENERATE_SCOPED_NAME, getJSON: () => void 0 }), + ], + }, + }, + }, + ], + }, + ], + }, + resolve: { + extensions: ['.js', '.ts', '.tsx'], + alias: { + '@vkontakte/vkui$': '@vkontakte/vkui/dist/cssm', + }, + }, + plugins: [ + new MiniCssExtractPlugin({ filename: '[name].css' }), + ...cases.map( + (caseName) => + new HtmlWebpackPlugin({ + filename: `${caseName}.html`, + scriptLoading: 'module', + template: path.resolve(import.meta.dirname, 'index.template.html'), + chunks: ['vendors', caseName], + chunksSortMode: 'manual', + minify: false, + }), + ), + ], + optimization: { + minimizer: [ + new CssMinimizerPlugin(), + new TerserPlugin({ + extractComments: false, + terserOptions: { + format: { + comments: false, + }, + }, + }), + '...', + ], + }, +}; + +export default webpackConfig; diff --git a/benchmark/tsconfig.json b/benchmark/tsconfig.json new file mode 100644 index 00000000000..6777258f106 --- /dev/null +++ b/benchmark/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "jsx": "react-jsx", + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "lib": ["es6", "dom", "dom.Iterable"], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + "isolatedModules": true, + "strict": true, + "skipLibCheck": true, + "outDir": "tmp/.cache/ts", + "paths": { + "@vkontakte/vkui": ["../packages/vkui/src"] + } + }, + "include": ["*.ts*", "**/*.ts*", "*.mjs", "**/*.mjs", "*.js", "**/*.js"], + "exclude": ["**/node_modules", "**/dist", "**/tmp"], + "files": ["./types/env.d.ts"] +} diff --git a/benchmark/types/env.d.ts b/benchmark/types/env.d.ts new file mode 100644 index 00000000000..1a36b46b894 --- /dev/null +++ b/benchmark/types/env.d.ts @@ -0,0 +1,20 @@ +declare module '*.svg'; + +// Фиксируем process.env, так как vite передает только NODE_ENV +declare module 'process' { + declare global { + namespace NodeJS { + interface Process { + env: { + NODE_ENV: 'development' | 'production' | 'test'; + WEB_SERVER_HOST: string; + PLAYWRIGHT_BLOB_OUTPUT_DIR: string; + WEB_SERVER_PORT: number; + STATIC_BUILD_DIR: string; + CI?: boolean; + PLAYWRIGHT_WORKERS?: number; + }; + } + } + } +} diff --git a/package.json b/package.json index cd59158aeaa..8225f8d1ca2 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "packageManager": "yarn@3.6.3", "workspaces": { "packages": [ + "benchmark", "styleguide", "packages/*", "tools/*" diff --git a/tsconfig.json b/tsconfig.json index 8f61616d543..32deec53979 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,7 @@ "baseUrl": "./", "outDir": ".cache/ts", "paths": { - "@vkontakte/vkui": ["./packages/vkui"], + "@vkontakte/vkui": ["./packages/vkui/src"], // FIXME дублируем, чтобы не падал `yarn run lint:types`. // Не выкупается `paths`, который указан в `./packages/vkui` "@vkui-e2e/test": ["./packages/vkui/src/testing/e2e/index.playwright"], diff --git a/yarn.lock b/yarn.lock index 3ed5d35a386..a87ef56274c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4093,7 +4093,7 @@ __metadata: languageName: node linkType: hard -"@swc/core@npm:*, @swc/core@npm:1.6.5, @swc/core@npm:^1.6.5": +"@swc/core@npm:*, @swc/core@npm:1.6.5, @swc/core@npm:^1.5.25, @swc/core@npm:^1.6.5": version: 1.6.5 resolution: "@swc/core@npm:1.6.5" dependencies: @@ -6532,6 +6532,31 @@ __metadata: languageName: node linkType: hard +"benchmark@workspace:benchmark": + version: 0.0.0-use.local + resolution: "benchmark@workspace:benchmark" + dependencies: + "@playwright/test": 1.45.0 + "@swc/core": ^1.5.25 + "@vkontakte/vkui": "workspace:packages/vkui" + cli-table3: ^0.6.1 + css-loader: ^6.10.0 + css-minimizer-webpack-plugin: ^7.0.0 + dotenv: ^16.4.5 + html-webpack-plugin: ^5.5.0 + mini-css-extract-plugin: ^2.9.0 + playwright: 1.45.0 + postcss: ^8.4.38 + postcss-modules: ^6.0.0 + react: ^18.3.1 + react-dom: ^18.3.1 + serve-handler: ^6.1.5 + swc-loader: ^0.2.6 + terser-webpack-plugin: ^5.3.10 + webpack: ^5.91.0 + languageName: unknown + linkType: soft + "better-opn@npm:^3.0.2": version: 3.0.2 resolution: "better-opn@npm:3.0.2" @@ -7433,6 +7458,13 @@ __metadata: languageName: node linkType: hard +"content-disposition@npm:0.5.2": + version: 0.5.2 + resolution: "content-disposition@npm:0.5.2" + checksum: 298d7da63255a38f7858ee19c7b6aae32b167e911293174b4c1349955e97e78e1d0b0d06c10e229405987275b417cf36ff65cbd4821a98bc9df4e41e9372cde7 + languageName: node + linkType: hard + "content-disposition@npm:0.5.4, content-disposition@npm:^0.5.4": version: 0.5.4 resolution: "content-disposition@npm:0.5.4" @@ -9650,6 +9682,15 @@ __metadata: languageName: node linkType: hard +"fast-url-parser@npm:1.1.3": + version: 1.1.3 + resolution: "fast-url-parser@npm:1.1.3" + dependencies: + punycode: ^1.3.2 + checksum: 5043d0c4a8d775ff58504d56c096563c11b113e4cb8a2668c6f824a1cd4fb3812e2fdf76537eb24a7ce4ae7def6bd9747da630c617cf2a4b6ce0c42514e4f21c + languageName: node + linkType: hard + "fastest-levenshtein@npm:^1.0.12, fastest-levenshtein@npm:^1.0.16, fastest-levenshtein@npm:^1.0.9": version: 1.0.16 resolution: "fastest-levenshtein@npm:1.0.16" @@ -13304,6 +13345,22 @@ __metadata: languageName: node linkType: hard +"mime-db@npm:~1.33.0": + version: 1.33.0 + resolution: "mime-db@npm:1.33.0" + checksum: 281a0772187c9b8f6096976cb193ac639c6007ac85acdbb8dc1617ed7b0f4777fa001d1b4f1b634532815e60717c84b2f280201d55677fb850c9d45015b50084 + languageName: node + linkType: hard + +"mime-types@npm:2.1.18": + version: 2.1.18 + resolution: "mime-types@npm:2.1.18" + dependencies: + mime-db: ~1.33.0 + checksum: 729265eff1e5a0e87cb7f869da742a610679585167d2f2ec997a7387fc6aedf8e5cad078e99b0164a927bdf3ace34fca27430d6487456ad090cba5594441ba43 + languageName: node + linkType: hard + "mime-types@npm:^2.1.12, mime-types@npm:^2.1.27, mime-types@npm:^2.1.31, mime-types@npm:~2.1.17, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" @@ -13387,7 +13444,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^3.0.2, minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": +"minimatch@npm:3.1.2, minimatch@npm:^3.0.2, minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" dependencies: @@ -14323,7 +14380,7 @@ __metadata: languageName: node linkType: hard -"path-is-inside@npm:^1.0.2": +"path-is-inside@npm:1.0.2, path-is-inside@npm:^1.0.2": version: 1.0.2 resolution: "path-is-inside@npm:1.0.2" checksum: 0b5b6c92d3018b82afb1f74fe6de6338c4c654de4a96123cb343f2b747d5606590ac0c890f956ed38220a4ab59baddfd7b713d78a62d240b20b14ab801fa02cb @@ -14375,6 +14432,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:2.2.1": + version: 2.2.1 + resolution: "path-to-regexp@npm:2.2.1" + checksum: b921a74e7576e25b06ad1635abf7e8125a29220d2efc2b71d74b9591f24a27e6f09078fa9a1b27516a097ea0637b7cab79d19b83d7f36a8ef3ef5422770e89d9 + languageName: node + linkType: hard + "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -15355,7 +15419,7 @@ __metadata: languageName: node linkType: hard -"punycode@npm:^1.4.1": +"punycode@npm:^1.3.2, punycode@npm:^1.4.1": version: 1.4.1 resolution: "punycode@npm:1.4.1" checksum: fa6e698cb53db45e4628559e557ddaf554103d2a96a1d62892c8f4032cd3bc8871796cae9eabc1bc700e2b6677611521ce5bb1d9a27700086039965d0cf34518 @@ -15449,6 +15513,13 @@ __metadata: languageName: node linkType: hard +"range-parser@npm:1.2.0": + version: 1.2.0 + resolution: "range-parser@npm:1.2.0" + checksum: bdf397f43fedc15c559d3be69c01dedf38444ca7a1610f5bf5955e3f3da6057a892f34691e7ebdd8c7e1698ce18ef6c4d4811f70e658dda3ff230ef741f8423a + languageName: node + linkType: hard + "range-parser@npm:^1.2.1, range-parser@npm:~1.2.1": version: 1.2.1 resolution: "range-parser@npm:1.2.1" @@ -16712,6 +16783,22 @@ __metadata: languageName: node linkType: hard +"serve-handler@npm:^6.1.5": + version: 6.1.5 + resolution: "serve-handler@npm:6.1.5" + dependencies: + bytes: 3.0.0 + content-disposition: 0.5.2 + fast-url-parser: 1.1.3 + mime-types: 2.1.18 + minimatch: 3.1.2 + path-is-inside: 1.0.2 + path-to-regexp: 2.2.1 + range-parser: 1.2.0 + checksum: 7a98ca9cbf8692583b6cde4deb3941cff900fa38bf16adbfccccd8430209bab781e21d9a1f61c9c03e226f9f67689893bbce25941368f3ddaf985fc3858b49dc + languageName: node + linkType: hard + "serve-index@npm:^1.9.1": version: 1.9.1 resolution: "serve-index@npm:1.9.1"