From f0b858f6b0e9e5e7f8afb88b63f487ad6550ef51 Mon Sep 17 00:00:00 2001 From: Sergey Gultyayev Date: Fri, 6 Mar 2026 12:31:27 +0100 Subject: [PATCH 1/5] feat(e2e): add automated performance benchmarks with Playwright Introduces a Playwright-based performance testing infrastructure using CDP for metrics collection. Four benchmark scenarios (scroll, drag within list, drag between lists with autoscroll, dynamic-height scroll) run with 4x CPU throttling, 5 iterations each, and produce aggregated statistics (mean, median, p95, stddev). Results can be compared against a baseline to detect regressions above a configurable threshold. --- .github/workflows/perf.yml | 47 ++++ .gitignore | 1 + package.json | 3 + perf/baselines/baseline.json | 299 ++++++++++++++++++++++ perf/compare.ts | 167 ++++++++++++ perf/fixtures/metrics-collector.ts | 152 +++++++++++ perf/fixtures/perf.page.ts | 139 ++++++++++ perf/fixtures/statistics.ts | 32 +++ perf/playwright.perf.config.ts | 25 ++ perf/results/.gitkeep | 0 perf/scenarios/drag-between-lists.perf.ts | 95 +++++++ perf/scenarios/drag-within-list.perf.ts | 73 ++++++ perf/scenarios/dynamic-height.perf.ts | 88 +++++++ perf/scenarios/scroll.perf.ts | 65 +++++ tsconfig.e2e.json | 2 +- 15 files changed, 1187 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/perf.yml create mode 100644 perf/baselines/baseline.json create mode 100644 perf/compare.ts create mode 100644 perf/fixtures/metrics-collector.ts create mode 100644 perf/fixtures/perf.page.ts create mode 100644 perf/fixtures/statistics.ts create mode 100644 perf/playwright.perf.config.ts create mode 100644 perf/results/.gitkeep create mode 100644 perf/scenarios/drag-between-lists.perf.ts create mode 100644 perf/scenarios/drag-within-list.perf.ts create mode 100644 perf/scenarios/dynamic-height.perf.ts create mode 100644 perf/scenarios/scroll.perf.ts diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 0000000..45ab7b2 --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,47 @@ +name: Performance Benchmarks + +on: + pull_request: + branches: [master] + +permissions: + contents: read + +concurrency: + group: perf-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + benchmark: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build Angular Library + run: npm run build:lib + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run performance benchmarks + run: npm run perf + + - name: Upload results + uses: actions/upload-artifact@v4 + with: + name: perf-results + path: perf/results/ + + - name: Compare against baseline + run: npm run perf:compare -- --threshold 25 + continue-on-error: true diff --git a/.gitignore b/.gitignore index 8e9f591..a31ed88 100644 --- a/.gitignore +++ b/.gitignore @@ -53,5 +53,6 @@ storybook-static /reference /playwright-report /test-results +/perf/results/*.json .codemie diff --git a/package.json b/package.json index 931d071..72bf29d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "e2e": "playwright test", "e2e:ui": "playwright test --ui", "e2e:headed": "playwright test --headed", + "perf": "playwright test --config perf/playwright.perf.config.ts", + "perf:compare": "node --experimental-strip-types perf/compare.ts", + "perf:baseline": "npm run perf && cp perf/results/latest.json perf/baselines/baseline.json", "storybook": "ng run dnd:storybook", "build-storybook": "ng run dnd:build-storybook", "release": "node scripts/release.js", diff --git a/perf/baselines/baseline.json b/perf/baselines/baseline.json new file mode 100644 index 0000000..bf1c5a0 --- /dev/null +++ b/perf/baselines/baseline.json @@ -0,0 +1,299 @@ +{ + "config": { + "configFile": "/Users/crush/Projects/dnd/perf/playwright.perf.config.ts", + "rootDir": "/Users/crush/Projects/dnd/perf/scenarios", + "forbidOnly": false, + "fullyParallel": false, + "globalSetup": null, + "globalTeardown": null, + "globalTimeout": 0, + "grep": {}, + "grepInvert": null, + "maxFailures": 0, + "metadata": { + "actualWorkers": 1 + }, + "preserveOutput": "always", + "reporter": [ + ["list", null], + [ + "json", + { + "outputFile": "results/latest.json" + } + ] + ], + "reportSlowTests": { + "max": 5, + "threshold": 300000 + }, + "quiet": false, + "projects": [ + { + "outputDir": "/Users/crush/Projects/dnd/test-results", + "repeatEach": 1, + "retries": 0, + "metadata": { + "actualWorkers": 1 + }, + "id": "", + "name": "", + "testDir": "/Users/crush/Projects/dnd/perf/scenarios", + "testIgnore": [], + "testMatch": ["**/*.perf.ts"], + "timeout": 120000 + } + ], + "shard": null, + "tags": [], + "updateSnapshots": "missing", + "updateSourceMethod": "patch", + "version": "1.57.0", + "workers": 1, + "webServer": { + "command": "npm start -- --host 127.0.0.1 --port 4200 -c production", + "url": "http://127.0.0.1:4200", + "reuseExistingServer": true, + "timeout": 120000 + } + }, + "suites": [ + { + "title": "drag-between-lists.perf.ts", + "file": "drag-between-lists.perf.ts", + "column": 0, + "line": 0, + "specs": [], + "suites": [ + { + "title": "Drag Between Lists Performance", + "file": "drag-between-lists.perf.ts", + "line": 10, + "column": 6, + "specs": [ + { + "title": "drag from list1 to list2 with autoscroll - 1000 items", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 120000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "", + "projectName": "", + "results": [ + { + "workerIndex": 0, + "parallelIndex": 0, + "status": "passed", + "duration": 22486, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-03-06T10:47:04.873Z", + "annotations": [], + "attachments": [ + { + "name": "drag-between-lists-autoscroll-1000", + "contentType": "application/json", + "body": "ewogICJzY2VuYXJpbyI6ICJkcmFnLWJldHdlZW4tbGlzdHMtYXV0b3Njcm9sbC0xMDAwIiwKICAiY3B1VGhyb3R0bGUiOiA0LAogICJpdGVyYXRpb25zIjogNSwKICAiYXV0b3Njcm9sbEhvbGRNcyI6IDMwMDAsCiAgInRvdGFsQmxvY2tpbmdUaW1lIjogewogICAgIm1lYW4iOiAwLAogICAgIm1lZGlhbiI6IDAsCiAgICAicDk1IjogMCwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDAsCiAgICAibWF4IjogMCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxvbmdUYXNrQ291bnQiOiB7CiAgICAibWVhbiI6IDAsCiAgICAibWVkaWFuIjogMCwKICAgICJwOTUiOiAwLAogICAgInN0ZGRldiI6IDAsCiAgICAibWluIjogMCwKICAgICJtYXgiOiAwLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibGF5b3V0Q291bnQiOiB7CiAgICAibWVhbiI6IDcyLAogICAgIm1lZGlhbiI6IDcxLAogICAgInA5NSI6IDc0LAogICAgInN0ZGRldiI6IDEuMjY0OTExMDY0MDY3MzUxOCwKICAgICJtaW4iOiA3MSwKICAgICJtYXgiOiA3NCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInJlY2FsY1N0eWxlQ291bnQiOiB7CiAgICAibWVhbiI6IDEwNy42LAogICAgIm1lZGlhbiI6IDEwNywKICAgICJwOTUiOiAxMTAsCiAgICAic3RkZGV2IjogMS4zNTY0NjU5OTY2MjUwNTM2LAogICAgIm1pbiI6IDEwNiwKICAgICJtYXgiOiAxMTAsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJhdmdGcmFtZVRpbWUiOiB7CiAgICAibWVhbiI6IDguMTkyLAogICAgIm1lZGlhbiI6IDguMzIsCiAgICAicDk1IjogOC4zMywKICAgICJzdGRkZXYiOiAwLjE3MTc0Mzk5NTUyODIyOCwKICAgICJtaW4iOiA3LjksCiAgICAibWF4IjogOC4zMywKICAgICJzYW1wbGVzIjogNQogIH0sCiAgIm1heEZyYW1lR2FwIjogewogICAgIm1lYW4iOiAxMS45LAogICAgIm1lZGlhbiI6IDExLjksCiAgICAicDk1IjogMTMuMSwKICAgICJzdGRkZXYiOiAwLjcwNDI3MjY3NDQ2NjM2MDIsCiAgICAibWluIjogMTEuMSwKICAgICJtYXgiOiAxMy4xLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAianNIZWFwRGVsdGEiOiB7CiAgICAibWVhbiI6IC02MzcuODA1OTk5OTk5OTk5OSwKICAgICJtZWRpYW4iOiAtMTQ3Mi41NCwKICAgICJwOTUiOiAyMjMxLjA1LAogICAgInN0ZGRldiI6IDE2OTcuNzU1MjEzMjkzMTI5NiwKICAgICJtaW4iOiAtMjYwNy40MywKICAgICJtYXgiOiAyMjMxLjA1LAogICAgInNhbXBsZXMiOiA1CiAgfQp9" + } + ] + } + ], + "status": "expected" + } + ], + "id": "627006642c7ff841ce75-d042198824336d6b100d", + "file": "drag-between-lists.perf.ts", + "line": 11, + "column": 3 + } + ] + } + ] + }, + { + "title": "drag-within-list.perf.ts", + "file": "drag-within-list.perf.ts", + "column": 0, + "line": 0, + "specs": [], + "suites": [ + { + "title": "Drag Within List Performance", + "file": "drag-within-list.perf.ts", + "line": 9, + "column": 6, + "specs": [ + { + "title": "drag item 0 to item 10 - 1000 items", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 120000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "", + "projectName": "", + "results": [ + { + "workerIndex": 0, + "parallelIndex": 0, + "status": "passed", + "duration": 5018, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-03-06T10:47:27.602Z", + "annotations": [], + "attachments": [ + { + "name": "drag-within-list-1000", + "contentType": "application/json", + "body": "ewogICJzY2VuYXJpbyI6ICJkcmFnLXdpdGhpbi1saXN0LTEwMDAiLAogICJjcHVUaHJvdHRsZSI6IDQsCiAgIml0ZXJhdGlvbnMiOiA1LAogICJ0b3RhbEJsb2NraW5nVGltZSI6IHsKICAgICJtZWFuIjogMCwKICAgICJtZWRpYW4iOiAwLAogICAgInA5NSI6IDAsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAwLAogICAgIm1heCI6IDAsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsb25nVGFza0NvdW50IjogewogICAgIm1lYW4iOiAwLAogICAgIm1lZGlhbiI6IDAsCiAgICAicDk1IjogMCwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDAsCiAgICAibWF4IjogMCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxheW91dENvdW50IjogewogICAgIm1lYW4iOiA1LAogICAgIm1lZGlhbiI6IDUsCiAgICAicDk1IjogNSwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDUsCiAgICAibWF4IjogNSwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInJlY2FsY1N0eWxlQ291bnQiOiB7CiAgICAibWVhbiI6IDQ3LjYsCiAgICAibWVkaWFuIjogNDgsCiAgICAicDk1IjogNDksCiAgICAic3RkZGV2IjogMS4wMTk4MDM5MDI3MTg1NTY4LAogICAgIm1pbiI6IDQ2LAogICAgIm1heCI6IDQ5LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAiYXZnRnJhbWVUaW1lIjogewogICAgIm1lYW4iOiA3Ljk2ODAwMDAwMDAwMDAwMSwKICAgICJtZWRpYW4iOiA4LjAxLAogICAgInA5NSI6IDguMDcsCiAgICAic3RkZGV2IjogMC4wOTg0NjgyNjkwMDA3Mjk2MSwKICAgICJtaW4iOiA3LjgxLAogICAgIm1heCI6IDguMDcsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJtYXhGcmFtZUdhcCI6IHsKICAgICJtZWFuIjogMTEuMDQwMDAwMDAwMDAwMDAxLAogICAgIm1lZGlhbiI6IDExLAogICAgInA5NSI6IDEyLjIsCiAgICAic3RkZGV2IjogMC42ODg3NjcwMTQzMDg5MDIyLAogICAgIm1pbiI6IDEwLjMsCiAgICAibWF4IjogMTIuMiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImpzSGVhcERlbHRhIjogewogICAgIm1lYW4iOiA0MTMuNzMyMDAwMDAwMDAwMSwKICAgICJtZWRpYW4iOiA0MTMuNjEsCiAgICAicDk1IjogNDE2LjY3LAogICAgInN0ZGRldiI6IDEuODA0ODMxMjk0MDU0OTMxNiwKICAgICJtaW4iOiA0MTAuOTgsCiAgICAibWF4IjogNDE2LjY3LAogICAgInNhbXBsZXMiOiA1CiAgfQp9" + } + ] + } + ], + "status": "expected" + } + ], + "id": "a43c3a9bf74f7ffdb398-14fac9bab569ea34b83a", + "file": "drag-within-list.perf.ts", + "line": 10, + "column": 3 + } + ] + } + ] + }, + { + "title": "dynamic-height.perf.ts", + "file": "dynamic-height.perf.ts", + "column": 0, + "line": 0, + "specs": [], + "suites": [ + { + "title": "Dynamic Height Scroll Performance", + "file": "dynamic-height.perf.ts", + "line": 8, + "column": 6, + "specs": [ + { + "title": "scroll through dynamic height list", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 120000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "", + "projectName": "", + "results": [ + { + "workerIndex": 0, + "parallelIndex": 0, + "status": "passed", + "duration": 14210, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-03-06T10:47:32.628Z", + "annotations": [], + "attachments": [ + { + "name": "dynamic-height-scroll", + "contentType": "application/json", + "body": "ewogICJzY2VuYXJpbyI6ICJkeW5hbWljLWhlaWdodC1zY3JvbGwiLAogICJjcHVUaHJvdHRsZSI6IDQsCiAgIml0ZXJhdGlvbnMiOiA1LAogICJ0b3RhbEJsb2NraW5nVGltZSI6IHsKICAgICJtZWFuIjogMjgsCiAgICAibWVkaWFuIjogMjgsCiAgICAicDk1IjogMjgsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAyOCwKICAgICJtYXgiOiAyOCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxvbmdUYXNrQ291bnQiOiB7CiAgICAibWVhbiI6IDIsCiAgICAibWVkaWFuIjogMiwKICAgICJwOTUiOiAyLAogICAgInN0ZGRldiI6IDAsCiAgICAibWluIjogMiwKICAgICJtYXgiOiAyLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibGF5b3V0Q291bnQiOiB7CiAgICAibWVhbiI6IDEyOC42LAogICAgIm1lZGlhbiI6IDEyOSwKICAgICJwOTUiOiAxMzAsCiAgICAic3RkZGV2IjogMS4zNTY0NjU5OTY2MjUwNTM2LAogICAgIm1pbiI6IDEyNiwKICAgICJtYXgiOiAxMzAsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJyZWNhbGNTdHlsZUNvdW50IjogewogICAgIm1lYW4iOiAyNzMuNiwKICAgICJtZWRpYW4iOiAyNzMsCiAgICAicDk1IjogMjc2LAogICAgInN0ZGRldiI6IDEuMzU2NDY1OTk2NjI1MDUzNiwKICAgICJtaW4iOiAyNzIsCiAgICAibWF4IjogMjc2LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAiYXZnRnJhbWVUaW1lIjogewogICAgIm1lYW4iOiA3Ljg2MTk5OTk5OTk5OTk5OSwKICAgICJtZWRpYW4iOiA3Ljg4LAogICAgInA5NSI6IDcuOSwKICAgICJzdGRkZXYiOiAwLjAzNDg3MTE5MTU0ODMyNTUzNCwKICAgICJtaW4iOiA3LjgsCiAgICAibWF4IjogNy45LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibWF4RnJhbWVHYXAiOiB7CiAgICAibWVhbiI6IDExLjQ4LAogICAgIm1lZGlhbiI6IDExLAogICAgInA5NSI6IDEzLjIsCiAgICAic3RkZGV2IjogMC45NTE2MzAxODAyNjk2MjUzLAogICAgIm1pbiI6IDEwLjYsCiAgICAibWF4IjogMTMuMiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImpzSGVhcERlbHRhIjogewogICAgIm1lYW4iOiAtMjU5LjM4NCwKICAgICJtZWRpYW4iOiAyODIuMDMsCiAgICAicDk1IjogNDc1Ljg1LAogICAgInN0ZGRldiI6IDgzNi42NTkzNjQwNTY4NDI1LAogICAgIm1pbiI6IC0xNTQ2LjI0LAogICAgIm1heCI6IDQ3NS44NSwKICAgICJzYW1wbGVzIjogNQogIH0KfQ==" + } + ] + } + ], + "status": "expected" + } + ], + "id": "53b37ba56613b41fa25c-fd0a2c300ba8b1f9765d", + "file": "dynamic-height.perf.ts", + "line": 9, + "column": 3 + } + ] + } + ] + }, + { + "title": "scroll.perf.ts", + "file": "scroll.perf.ts", + "column": 0, + "line": 0, + "specs": [], + "suites": [ + { + "title": "Scroll Performance", + "file": "scroll.perf.ts", + "line": 10, + "column": 6, + "specs": [ + { + "title": "large list scroll - 2000 items", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 120000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "", + "projectName": "", + "results": [ + { + "workerIndex": 0, + "parallelIndex": 0, + "status": "passed", + "duration": 14274, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-03-06T10:47:46.846Z", + "annotations": [], + "attachments": [ + { + "name": "scroll-2000-items", + "contentType": "application/json", + "body": "ewogICJzY2VuYXJpbyI6ICJzY3JvbGwtMjAwMC1pdGVtcyIsCiAgImNwdVRocm90dGxlIjogNCwKICAiaXRlcmF0aW9ucyI6IDUsCiAgInRvdGFsQmxvY2tpbmdUaW1lIjogewogICAgIm1lYW4iOiAzNywKICAgICJtZWRpYW4iOiAzNywKICAgICJwOTUiOiAzNywKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDM3LAogICAgIm1heCI6IDM3LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibG9uZ1Rhc2tDb3VudCI6IHsKICAgICJtZWFuIjogMiwKICAgICJtZWRpYW4iOiAyLAogICAgInA5NSI6IDIsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAyLAogICAgIm1heCI6IDIsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsYXlvdXRDb3VudCI6IHsKICAgICJtZWFuIjogMTIxLjIsCiAgICAibWVkaWFuIjogMTIxLAogICAgInA5NSI6IDEyMiwKICAgICJzdGRkZXYiOiAwLjc0ODMzMTQ3NzM1NDc4ODIsCiAgICAibWluIjogMTIwLAogICAgIm1heCI6IDEyMiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInJlY2FsY1N0eWxlQ291bnQiOiB7CiAgICAibWVhbiI6IDEyMS4yLAogICAgIm1lZGlhbiI6IDEyMSwKICAgICJwOTUiOiAxMjIsCiAgICAic3RkZGV2IjogMC43NDgzMzE0NzczNTQ3ODgyLAogICAgIm1pbiI6IDEyMCwKICAgICJtYXgiOiAxMjIsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJhdmdGcmFtZVRpbWUiOiB7CiAgICAibWVhbiI6IDcuOTA0MDAwMDAwMDAwMDAxLAogICAgIm1lZGlhbiI6IDcuODksCiAgICAicDk1IjogNy45OSwKICAgICJzdGRkZXYiOiAwLjA1MjAwMDAwMDAwMDAwMDA4LAogICAgIm1pbiI6IDcuODMsCiAgICAibWF4IjogNy45OSwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgIm1heEZyYW1lR2FwIjogewogICAgIm1lYW4iOiAxMy4wMiwKICAgICJtZWRpYW4iOiAxMywKICAgICJwOTUiOiAxMy41LAogICAgInN0ZGRldiI6IDAuNDc5MTY1OTQyMDI4NDM3NzYsCiAgICAibWluIjogMTIuMiwKICAgICJtYXgiOiAxMy41LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAianNIZWFwRGVsdGEiOiB7CiAgICAibWVhbiI6IC05NzcuNTMxOTk5OTk5OTk5OCwKICAgICJtZWRpYW4iOiAtNzY1LjE0LAogICAgInA5NSI6IDQzNTEuMTksCiAgICAic3RkZGV2IjogMzg0NC45OTg5MTE3MjYyNDQsCiAgICAibWluIjogLTU3NTUuODgsCiAgICAibWF4IjogNDM1MS4xOSwKICAgICJzYW1wbGVzIjogNQogIH0KfQ==" + } + ] + } + ], + "status": "expected" + } + ], + "id": "e4444aa54d21dcc13bb1-7b5cf2c5d052bafba4c6", + "file": "scroll.perf.ts", + "line": 11, + "column": 3 + } + ] + } + ] + } + ], + "errors": [], + "stats": { + "startTime": "2026-03-06T10:47:00.475Z", + "duration": 60692.924, + "expected": 4, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + } +} diff --git a/perf/compare.ts b/perf/compare.ts new file mode 100644 index 0000000..4c0d554 --- /dev/null +++ b/perf/compare.ts @@ -0,0 +1,167 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +interface AggregatedMetrics { + mean: number; + median: number; + p95: number; + stddev: number; + min: number; + max: number; + samples: number; +} + +interface ScenarioReport { + scenario: string; + [metric: string]: AggregatedMetrics | string | number; +} + +interface Attachment { + name: string; + body?: string; + contentType: string; +} + +interface TestResult { + attachments?: Attachment[]; +} + +interface TestCase { + results?: TestResult[]; +} + +interface Spec { + tests?: TestCase[]; +} + +interface Suite { + suites?: Suite[]; + specs?: Spec[]; +} + +interface ResultsFile { + suites: Suite[]; +} + +const COMPARISON_METRICS = [ + 'totalBlockingTime', + 'longTaskCount', + 'layoutCount', + 'recalcStyleCount', + 'avgFrameTime', + 'maxFrameGap', + 'jsHeapDelta', +]; + +function extractScenarios(filePath: string): Map { + const data: ResultsFile = JSON.parse(readFileSync(filePath, 'utf-8')); + const scenarios = new Map(); + + function traverseSuite(suite: Suite): void { + for (const child of suite.suites ?? []) { + traverseSuite(child); + } + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) { + for (const result of test.results ?? []) { + for (const attachment of result.attachments ?? []) { + if (attachment.contentType === 'application/json' && attachment.body) { + const report = JSON.parse( + Buffer.from(attachment.body, 'base64').toString('utf-8'), + ) as ScenarioReport; + if (report.scenario) { + scenarios.set(report.scenario, report); + } + } + } + } + } + } + } + + for (const suite of data.suites ?? []) { + traverseSuite(suite); + } + + return scenarios; +} + +function percentChange(baseline: number, current: number): number { + if (baseline === 0) return current === 0 ? 0 : 100; + return ((current - baseline) / Math.abs(baseline)) * 100; +} + +function main(): void { + const args = process.argv.slice(2); + let threshold = 25; + + const thresholdIdx = args.indexOf('--threshold'); + if (thresholdIdx !== -1 && args[thresholdIdx + 1]) { + threshold = parseFloat(args[thresholdIdx + 1]); + } + + const baselinePath = resolve(import.meta.dirname, 'baselines/baseline.json'); + const latestPath = resolve(import.meta.dirname, 'results/latest.json'); + + if (!existsSync(baselinePath)) { + console.log('No baseline found. Run `npm run perf:baseline` to create one.'); + process.exit(0); + } + + if (!existsSync(latestPath)) { + console.error('No latest results found. Run `npm run perf` first.'); + process.exit(1); + } + + const baseline = extractScenarios(baselinePath); + const latest = extractScenarios(latestPath); + + if (baseline.size === 0) { + console.log('Baseline contains no scenario data. Re-run `npm run perf:baseline`.'); + process.exit(0); + } + + console.log(`\n## Performance Comparison (threshold: ${threshold}%)\n`); + console.log('| Scenario | Metric | Baseline p95 | Current p95 | Change |'); + console.log('|----------|--------|-------------|------------|--------|'); + + let hasRegression = false; + + for (const [name, baselineReport] of baseline) { + const currentReport = latest.get(name); + if (!currentReport) { + console.log(`| ${name} | - | - | - | MISSING |`); + continue; + } + + for (const metric of COMPARISON_METRICS) { + const bMetric = baselineReport[metric] as AggregatedMetrics | undefined; + const cMetric = currentReport[metric] as AggregatedMetrics | undefined; + + if (!bMetric || !cMetric) continue; + + const change = percentChange(bMetric.p95, cMetric.p95); + const changeStr = `${change >= 0 ? '+' : ''}${change.toFixed(1)}%`; + const flag = change > threshold ? ' REGRESSION' : ''; + + if (change > threshold) { + hasRegression = true; + } + + console.log( + `| ${name} | ${metric} | ${bMetric.p95.toFixed(1)} | ${cMetric.p95.toFixed(1)} | ${changeStr}${flag} |`, + ); + } + } + + console.log(''); + + if (hasRegression) { + console.error(`Performance regression detected (>${threshold}% on p95 values).`); + process.exit(1); + } else { + console.log('No significant regressions detected.'); + } +} + +main(); diff --git a/perf/fixtures/metrics-collector.ts b/perf/fixtures/metrics-collector.ts new file mode 100644 index 0000000..266f906 --- /dev/null +++ b/perf/fixtures/metrics-collector.ts @@ -0,0 +1,152 @@ +import { CDPSession, Page } from '@playwright/test'; + +export interface PerfSnapshot { + timestamp: number; + jsHeapUsedSize: number; + layoutCount: number; + recalcStyleCount: number; +} + +export interface LongTask { + startTime: number; + duration: number; +} + +export interface ScenarioMetrics { + durationMs: number; + longTaskCount: number; + totalBlockingTime: number; + layoutCount: number; + recalcStyleCount: number; + jsHeapBefore: number; + jsHeapAfter: number; + jsHeapDelta: number; + frameCount: number; + avgFrameTime: number; + maxFrameGap: number; +} + +export class MetricsCollector { + #page: Page; + #cdp: CDPSession | null = null; + + constructor(page: Page) { + this.#page = page; + } + + async init(): Promise { + this.#cdp = await this.#page.context().newCDPSession(this.#page); + await this.#cdp.send('Performance.enable'); + } + + async setCpuThrottling(rate: number): Promise { + await this.#cdp!.send('Emulation.setCPUThrottlingRate', { rate }); + } + + async clearCpuThrottling(): Promise { + await this.#cdp!.send('Emulation.setCPUThrottlingRate', { rate: 1 }); + } + + async getSnapshot(): Promise { + const { metrics } = await this.#cdp!.send('Performance.getMetrics'); + const get = (name: string) => metrics.find((m) => m.name === name)?.value ?? 0; + return { + timestamp: Date.now(), + jsHeapUsedSize: get('JSHeapUsedSize'), + layoutCount: get('LayoutCount'), + recalcStyleCount: get('RecalcStyleCount'), + }; + } + + /** + * Inject a PerformanceObserver for long tasks and an rAF-based frame tracker into the page. + * Must be called before the scenario runs. + */ + async injectObservers(): Promise { + await this.#page.evaluate(() => { + const w = window as Window & Record; + w.__perfLongTasks = []; + w.__perfFrames = []; + w.__perfLastFrameTime = 0; + w.__perfTrackingActive = true; + + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + (w.__perfLongTasks as { startTime: number; duration: number }[]).push({ + startTime: entry.startTime, + duration: entry.duration, + }); + } + }).observe({ type: 'longtask', buffered: true }); + + const trackFrame = () => { + const now = performance.now(); + if ((w.__perfLastFrameTime as number) > 0) { + (w.__perfFrames as number[]).push(now - (w.__perfLastFrameTime as number)); + } + w.__perfLastFrameTime = now; + if (w.__perfTrackingActive) { + requestAnimationFrame(trackFrame); + } + }; + requestAnimationFrame(trackFrame); + }); + } + + async collectObserverResults(): Promise<{ longTasks: LongTask[]; frameTimes: number[] }> { + return this.#page.evaluate(() => { + const w = window as Window & Record; + w.__perfTrackingActive = false; + return { + longTasks: (w.__perfLongTasks as LongTask[]) ?? [], + frameTimes: (w.__perfFrames as number[]) ?? [], + }; + }); + } + + /** + * Measure a scenario: takes snapshots, injects observers, runs the scenario, + * then collects all metrics. + */ + async measureScenario(scenario: () => Promise): Promise { + const before = await this.getSnapshot(); + await this.injectObservers(); + const startTime = Date.now(); + + await scenario(); + + const durationMs = Date.now() - startTime; + const after = await this.getSnapshot(); + const { longTasks, frameTimes } = await this.collectObserverResults(); + + const totalBlockingTime = longTasks.reduce( + (sum, task) => sum + Math.max(0, task.duration - 50), + 0, + ); + + const frameCount = frameTimes.length; + const avgFrameTime = frameCount > 0 ? frameTimes.reduce((a, b) => a + b, 0) / frameCount : 0; + const maxFrameGap = frameCount > 0 ? Math.max(...frameTimes) : 0; + + return { + durationMs, + longTaskCount: longTasks.length, + totalBlockingTime, + layoutCount: after.layoutCount - before.layoutCount, + recalcStyleCount: after.recalcStyleCount - before.recalcStyleCount, + jsHeapBefore: before.jsHeapUsedSize, + jsHeapAfter: after.jsHeapUsedSize, + jsHeapDelta: after.jsHeapUsedSize - before.jsHeapUsedSize, + frameCount, + avgFrameTime, + maxFrameGap, + }; + } + + async dispose(): Promise { + await this.clearCpuThrottling(); + if (this.#cdp) { + await this.#cdp.detach(); + } + } +} diff --git a/perf/fixtures/perf.page.ts b/perf/fixtures/perf.page.ts new file mode 100644 index 0000000..85f7b33 --- /dev/null +++ b/perf/fixtures/perf.page.ts @@ -0,0 +1,139 @@ +import { expect, Page } from '@playwright/test'; + +export class PerfPage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async goto(route = '/'): Promise { + await this.page.goto(route); + await this.page.locator('[data-draggable-id]').first().waitFor({ state: 'visible' }); + } + + /** + * Set the item count on the main demo page and regenerate items. + * Only works on the `/` route. + */ + async setItemCount(count: number): Promise { + const input = this.page.locator('#itemCount'); + await input.fill(String(count)); + await this.page.locator('button', { hasText: 'Regenerate' }).click(); + // Wait for virtual scroll to render with the new item count + await expect(async () => { + const badge = this.page.locator('.list-badge').first(); + const text = await badge.textContent(); + expect(parseInt(text?.trim() ?? '0', 10)).toBe(Math.floor(count / 2)); + }).toPass({ timeout: 5000 }); + } + + /** + * Programmatic smooth scroll using rAF interpolation. + * Scrolls the element matching `selector` from its current position to `targetScrollTop` + * over `durationMs` milliseconds. + */ + async smoothScroll(selector: string, targetScrollTop: number, durationMs: number): Promise { + await this.page.evaluate( + ({ selector, target, duration }) => { + return new Promise((resolve) => { + const el = document.querySelector(selector) as HTMLElement; + if (!el) { + resolve(); + return; + } + const start = el.scrollTop; + const delta = target - start; + const startTime = performance.now(); + const step = () => { + const elapsed = performance.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + // Ease-in-out for more realistic scroll behavior + const eased = + progress < 0.5 ? 2 * progress * progress : 1 - (-2 * progress + 2) ** 2 / 2; + el.scrollTop = start + delta * eased; + if (progress < 1) { + requestAnimationFrame(step); + } else { + resolve(); + } + }; + requestAnimationFrame(step); + }); + }, + { selector, target: targetScrollTop, duration: durationMs }, + ); + } + + /** + * Simulate a full drag operation with stepped mouse moves. + * Mirrors the E2E drag pattern from `demo.page.ts`. + */ + async simulateDrag(opts: { + startX: number; + startY: number; + endX: number; + endY: number; + steps?: number; + holdDurationMs?: number; + }): Promise { + const { startX, startY, endX, endY, steps = 15, holdDurationMs } = opts; + + await this.page.mouse.move(startX, startY); + await this.page.mouse.down(); + + // Small initial move to pass drag threshold + await this.page.mouse.move(startX + 5, startY + 5, { steps: 2 }); + + // Wait for drag preview + const dragPreview = this.page.getByTestId('vdnd-drag-preview'); + await expect(dragPreview).toBeVisible({ timeout: 2000 }); + + // Move to target + await this.page.mouse.move(endX, endY, { steps }); + // Firefox finalization move + await this.page.mouse.move(endX, endY); + + if (holdDurationMs) { + await this.page.waitForTimeout(holdDurationMs); + } + + // Wait one rAF for position update + await this.page.evaluate(() => new Promise((r) => requestAnimationFrame(r))); + await this.page.mouse.up(); + + // Wait for drag to complete + await expect(dragPreview).not.toBeVisible({ timeout: 2000 }); + } + + /** + * Get bounding box of a virtual scroll container. + */ + async getContainerBox(list: 'list1' | 'list2') { + const droppableId = list === 'list1' ? 'list-1' : 'list-2'; + const container = this.page.locator(`[data-droppable-id="${droppableId}"] vdnd-virtual-scroll`); + return container.boundingBox(); + } + + /** + * Get bounding box of a specific draggable item. + */ + async getItemBox(list: 'list1' | 'list2', index: number) { + const droppableId = list === 'list1' ? 'list-1' : 'list-2'; + const items = this.page.locator(`[data-droppable-id="${droppableId}"] [data-draggable-id]`); + return items.nth(index).boundingBox(); + } + + /** Reset scroll position of both lists to the top. */ + async resetScrollPositions(): Promise { + for (const id of ['list-1', 'list-2']) { + await this.page.evaluate((droppableId) => { + const el = document.querySelector( + `[data-droppable-id="${droppableId}"] vdnd-virtual-scroll`, + ) as HTMLElement; + if (el) el.scrollTop = 0; + }, id); + } + await this.page.evaluate(() => new Promise((r) => requestAnimationFrame(r))); + } +} diff --git a/perf/fixtures/statistics.ts b/perf/fixtures/statistics.ts new file mode 100644 index 0000000..20e9c7e --- /dev/null +++ b/perf/fixtures/statistics.ts @@ -0,0 +1,32 @@ +export interface AggregatedMetrics { + mean: number; + median: number; + p95: number; + stddev: number; + min: number; + max: number; + samples: number; +} + +export function aggregate(values: number[]): AggregatedMetrics { + if (values.length === 0) { + return { mean: 0, median: 0, p95: 0, stddev: 0, min: 0, max: 0, samples: 0 }; + } + + const sorted = [...values].sort((a, b) => a - b); + const n = sorted.length; + const mean = sorted.reduce((a, b) => a + b, 0) / n; + const median = n % 2 === 0 ? (sorted[n / 2 - 1] + sorted[n / 2]) / 2 : sorted[Math.floor(n / 2)]; + const p95Index = Math.ceil(n * 0.95) - 1; + const p95 = sorted[Math.min(p95Index, n - 1)]; + const variance = sorted.reduce((sum, v) => sum + (v - mean) ** 2, 0) / n; + const stddev = Math.sqrt(variance); + + return { mean, median, p95, stddev, min: sorted[0], max: sorted[n - 1], samples: n }; +} + +/** Round a number to a fixed number of decimal places for display. */ +export function round(value: number, decimals = 2): number { + const factor = 10 ** decimals; + return Math.round(value * factor) / factor; +} diff --git a/perf/playwright.perf.config.ts b/perf/playwright.perf.config.ts new file mode 100644 index 0000000..8845103 --- /dev/null +++ b/perf/playwright.perf.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './scenarios', + testMatch: '**/*.perf.ts', + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 120_000, + reporter: [['list'], ['json', { outputFile: 'results/latest.json' }]], + use: { + baseURL: 'http://127.0.0.1:4200', + ...devices['Desktop Chrome'], + headless: true, + video: 'off', + screenshot: 'off', + trace: 'off', + }, + webServer: { + command: 'npm start -- --host 127.0.0.1 --port 4200 -c production', + url: 'http://127.0.0.1:4200', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/perf/results/.gitkeep b/perf/results/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/perf/scenarios/drag-between-lists.perf.ts b/perf/scenarios/drag-between-lists.perf.ts new file mode 100644 index 0000000..08d93c5 --- /dev/null +++ b/perf/scenarios/drag-between-lists.perf.ts @@ -0,0 +1,95 @@ +import { test } from '@playwright/test'; +import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector'; +import { PerfPage } from '../fixtures/perf.page'; +import { aggregate, round } from '../fixtures/statistics'; + +const ITERATIONS = 5; +const WARMUP_ITERATIONS = 1; +const ITEM_COUNT = 1000; +const AUTOSCROLL_HOLD_MS = 3000; +const CPU_THROTTLE = 4; + +test.describe('Drag Between Lists Performance', () => { + test('drag from list1 to list2 with autoscroll - 1000 items', async ({ page }, testInfo) => { + const perfPage = new PerfPage(page); + const collector = new MetricsCollector(page); + await collector.init(); + await collector.setCpuThrottling(CPU_THROTTLE); + + await perfPage.goto(); + await perfPage.setItemCount(ITEM_COUNT); + + const results: ScenarioMetrics[] = []; + const totalRuns = WARMUP_ITERATIONS + ITERATIONS; + + for (let i = 0; i < totalRuns; i++) { + // Reload for clean drag state + await perfPage.goto(); + await perfPage.setItemCount(ITEM_COUNT); + await page.waitForTimeout(300); + + const sourceBox = await perfPage.getItemBox('list1', 0); + const list2Box = await perfPage.getContainerBox('list2'); + + if (!sourceBox || !list2Box) { + throw new Error('Could not get bounding boxes'); + } + + const metrics = await collector.measureScenario(async () => { + // Start drag from list1 item 0 + await page.mouse.move( + sourceBox.x + sourceBox.width / 2, + sourceBox.y + sourceBox.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + sourceBox.x + sourceBox.width / 2 + 5, + sourceBox.y + sourceBox.height / 2 + 5, + { steps: 2 }, + ); + + const dragPreview = page.getByTestId('vdnd-drag-preview'); + await dragPreview.waitFor({ state: 'visible', timeout: 2000 }); + + // Move to list2 bottom edge to trigger autoscroll + const nearBottomY = list2Box.y + list2Box.height - 20; + const centerX = list2Box.x + list2Box.width / 2; + await page.mouse.move(centerX, nearBottomY, { steps: 15 }); + await page.mouse.move(centerX, nearBottomY); + + // Hold at edge to accumulate autoscroll + await page.waitForTimeout(AUTOSCROLL_HOLD_MS); + + // Release + await page.evaluate(() => new Promise((r) => requestAnimationFrame(r))); + await page.mouse.up(); + await dragPreview.waitFor({ state: 'hidden', timeout: 2000 }); + }); + + if (i >= WARMUP_ITERATIONS) { + results.push(metrics); + } + } + + const report = { + scenario: 'drag-between-lists-autoscroll-1000', + cpuThrottle: CPU_THROTTLE, + iterations: ITERATIONS, + autoscrollHoldMs: AUTOSCROLL_HOLD_MS, + totalBlockingTime: aggregate(results.map((r) => round(r.totalBlockingTime))), + longTaskCount: aggregate(results.map((r) => r.longTaskCount)), + layoutCount: aggregate(results.map((r) => r.layoutCount)), + recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)), + avgFrameTime: aggregate(results.map((r) => round(r.avgFrameTime))), + maxFrameGap: aggregate(results.map((r) => round(r.maxFrameGap))), + jsHeapDelta: aggregate(results.map((r) => round(r.jsHeapDelta / 1024))), + }; + + testInfo.attach('drag-between-lists-autoscroll-1000', { + body: JSON.stringify(report, null, 2), + contentType: 'application/json', + }); + + await collector.dispose(); + }); +}); diff --git a/perf/scenarios/drag-within-list.perf.ts b/perf/scenarios/drag-within-list.perf.ts new file mode 100644 index 0000000..fb2f229 --- /dev/null +++ b/perf/scenarios/drag-within-list.perf.ts @@ -0,0 +1,73 @@ +import { test } from '@playwright/test'; +import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector'; +import { PerfPage } from '../fixtures/perf.page'; +import { aggregate, round } from '../fixtures/statistics'; + +const ITERATIONS = 5; +const WARMUP_ITERATIONS = 1; +const ITEM_COUNT = 1000; +const CPU_THROTTLE = 4; + +test.describe('Drag Within List Performance', () => { + test('drag item 0 to item 10 - 1000 items', async ({ page }, testInfo) => { + const perfPage = new PerfPage(page); + const collector = new MetricsCollector(page); + await collector.init(); + await collector.setCpuThrottling(CPU_THROTTLE); + + await perfPage.goto(); + await perfPage.setItemCount(ITEM_COUNT); + + const results: ScenarioMetrics[] = []; + const totalRuns = WARMUP_ITERATIONS + ITERATIONS; + + for (let i = 0; i < totalRuns; i++) { + // Reload page to get a clean state for each drag iteration + await perfPage.goto(); + await perfPage.setItemCount(ITEM_COUNT); + await page.waitForTimeout(300); + + const sourceBox = await perfPage.getItemBox('list1', 0); + const targetBox = await perfPage.getItemBox('list1', 7); + + if (!sourceBox || !targetBox) { + throw new Error('Could not get bounding boxes'); + } + + const metrics = await collector.measureScenario(async () => { + await perfPage.simulateDrag({ + startX: sourceBox.x + sourceBox.width / 2, + startY: sourceBox.y + sourceBox.height / 2, + // Target item ~7 visible positions down (items 0-7 in viewport) + endX: targetBox.x + targetBox.width / 2, + endY: targetBox.y + targetBox.height / 2, + steps: 20, + }); + }); + + if (i >= WARMUP_ITERATIONS) { + results.push(metrics); + } + } + + const report = { + scenario: 'drag-within-list-1000', + cpuThrottle: CPU_THROTTLE, + iterations: ITERATIONS, + totalBlockingTime: aggregate(results.map((r) => round(r.totalBlockingTime))), + longTaskCount: aggregate(results.map((r) => r.longTaskCount)), + layoutCount: aggregate(results.map((r) => r.layoutCount)), + recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)), + avgFrameTime: aggregate(results.map((r) => round(r.avgFrameTime))), + maxFrameGap: aggregate(results.map((r) => round(r.maxFrameGap))), + jsHeapDelta: aggregate(results.map((r) => round(r.jsHeapDelta / 1024))), + }; + + testInfo.attach('drag-within-list-1000', { + body: JSON.stringify(report, null, 2), + contentType: 'application/json', + }); + + await collector.dispose(); + }); +}); diff --git a/perf/scenarios/dynamic-height.perf.ts b/perf/scenarios/dynamic-height.perf.ts new file mode 100644 index 0000000..680cdc3 --- /dev/null +++ b/perf/scenarios/dynamic-height.perf.ts @@ -0,0 +1,88 @@ +import { test } from '@playwright/test'; +import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector'; +import { PerfPage } from '../fixtures/perf.page'; +import { aggregate, round } from '../fixtures/statistics'; + +const ITERATIONS = 5; +const WARMUP_ITERATIONS = 1; +const CPU_THROTTLE = 4; + +test.describe('Dynamic Height Scroll Performance', () => { + test('scroll through dynamic height list', async ({ page }, testInfo) => { + const perfPage = new PerfPage(page); + const collector = new MetricsCollector(page); + await collector.init(); + await collector.setCpuThrottling(CPU_THROTTLE); + + // Navigate to the dynamic height demo (150 tasks by default with varying heights) + await perfPage.goto('/dynamic-height'); + + const results: ScenarioMetrics[] = []; + const totalRuns = WARMUP_ITERATIONS + ITERATIONS; + + for (let i = 0; i < totalRuns; i++) { + // Reset scroll to top via Ionic's IonContent scroll container + await page.evaluate(() => { + const scrollable = document.querySelector('[vdndScrollable]') as HTMLElement; + if (scrollable) scrollable.scrollTop = 0; + }); + await page.waitForTimeout(300); + + const metrics = await collector.measureScenario(async () => { + // Scroll to the bottom of the dynamic-height list + // 150 items * ~80px estimated height = ~12000px, but heights vary + await page.evaluate(() => { + return new Promise((resolve) => { + const scrollable = document.querySelector('[vdndScrollable]') as HTMLElement; + if (!scrollable) { + resolve(); + return; + } + const target = scrollable.scrollHeight; + const start = scrollable.scrollTop; + const delta = target - start; + const duration = 2000; + const startTime = performance.now(); + const step = () => { + const elapsed = performance.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = + progress < 0.5 ? 2 * progress * progress : 1 - (-2 * progress + 2) ** 2 / 2; + scrollable.scrollTop = start + delta * eased; + if (progress < 1) { + requestAnimationFrame(step); + } else { + resolve(); + } + }; + requestAnimationFrame(step); + }); + }); + }); + + if (i >= WARMUP_ITERATIONS) { + results.push(metrics); + } + } + + const report = { + scenario: 'dynamic-height-scroll', + cpuThrottle: CPU_THROTTLE, + iterations: ITERATIONS, + totalBlockingTime: aggregate(results.map((r) => round(r.totalBlockingTime))), + longTaskCount: aggregate(results.map((r) => r.longTaskCount)), + layoutCount: aggregate(results.map((r) => r.layoutCount)), + recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)), + avgFrameTime: aggregate(results.map((r) => round(r.avgFrameTime))), + maxFrameGap: aggregate(results.map((r) => round(r.maxFrameGap))), + jsHeapDelta: aggregate(results.map((r) => round(r.jsHeapDelta / 1024))), + }; + + testInfo.attach('dynamic-height-scroll', { + body: JSON.stringify(report, null, 2), + contentType: 'application/json', + }); + + await collector.dispose(); + }); +}); diff --git a/perf/scenarios/scroll.perf.ts b/perf/scenarios/scroll.perf.ts new file mode 100644 index 0000000..8f9311d --- /dev/null +++ b/perf/scenarios/scroll.perf.ts @@ -0,0 +1,65 @@ +import { test } from '@playwright/test'; +import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector'; +import { PerfPage } from '../fixtures/perf.page'; +import { aggregate, round } from '../fixtures/statistics'; + +const ITERATIONS = 5; +const WARMUP_ITERATIONS = 1; +const ITEM_COUNT = 2000; +const SCROLL_DURATION_MS = 2000; +const CPU_THROTTLE = 4; + +test.describe('Scroll Performance', () => { + test('large list scroll - 2000 items', async ({ page }, testInfo) => { + const perfPage = new PerfPage(page); + const collector = new MetricsCollector(page); + await collector.init(); + await collector.setCpuThrottling(CPU_THROTTLE); + + await perfPage.goto(); + await perfPage.setItemCount(ITEM_COUNT); + + const results: ScenarioMetrics[] = []; + const totalRuns = WARMUP_ITERATIONS + ITERATIONS; + + for (let i = 0; i < totalRuns; i++) { + await perfPage.resetScrollPositions(); + // Allow GC and settling between iterations + await page.waitForTimeout(300); + + const maxScroll = (ITEM_COUNT / 2) * 50 - 400; // half items * height - container + const metrics = await collector.measureScenario(async () => { + await perfPage.smoothScroll( + '[data-droppable-id="list-1"] vdnd-virtual-scroll', + maxScroll, + SCROLL_DURATION_MS, + ); + }); + + // Skip warmup iterations + if (i >= WARMUP_ITERATIONS) { + results.push(metrics); + } + } + + const report = { + scenario: 'scroll-2000-items', + cpuThrottle: CPU_THROTTLE, + iterations: ITERATIONS, + totalBlockingTime: aggregate(results.map((r) => round(r.totalBlockingTime))), + longTaskCount: aggregate(results.map((r) => r.longTaskCount)), + layoutCount: aggregate(results.map((r) => r.layoutCount)), + recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)), + avgFrameTime: aggregate(results.map((r) => round(r.avgFrameTime))), + maxFrameGap: aggregate(results.map((r) => round(r.maxFrameGap))), + jsHeapDelta: aggregate(results.map((r) => round(r.jsHeapDelta / 1024))), // KB + }; + + testInfo.attach('scroll-2000-items', { + body: JSON.stringify(report, null, 2), + contentType: 'application/json', + }); + + await collector.dispose(); + }); +}); diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json index 827f63d..90e9c77 100644 --- a/tsconfig.e2e.json +++ b/tsconfig.e2e.json @@ -5,5 +5,5 @@ "outDir": "./out-tsc/e2e", "types": ["node"] }, - "include": ["e2e/**/*.ts"] + "include": ["e2e/**/*.ts", "perf/**/*.ts"] } From 456ac20ebcc666c1cf704dee349b96a87d8fdb8b Mon Sep 17 00:00:00 2001 From: Sergey Gultyayev Date: Fri, 6 Mar 2026 12:41:15 +0100 Subject: [PATCH 2/5] feat(e2e): write perf comparison to GitHub Job Summary Add --output flag to compare.ts so results can be written to a file. The CI workflow now writes to $GITHUB_STEP_SUMMARY, making the comparison table visible directly on the Actions run page. --- .github/workflows/perf.yml | 2 +- perf/compare.ts | 46 +++++++++++++++++++++++++------------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index 45ab7b2..fc1afbb 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -43,5 +43,5 @@ jobs: path: perf/results/ - name: Compare against baseline - run: npm run perf:compare -- --threshold 25 + run: npm run perf:compare -- --threshold 25 --output "$GITHUB_STEP_SUMMARY" continue-on-error: true diff --git a/perf/compare.ts b/perf/compare.ts index 4c0d554..8fc04c7 100644 --- a/perf/compare.ts +++ b/perf/compare.ts @@ -1,4 +1,4 @@ -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; interface AggregatedMetrics { @@ -91,14 +91,15 @@ function percentChange(baseline: number, current: number): number { return ((current - baseline) / Math.abs(baseline)) * 100; } +function parseArg(args: string[], flag: string): string | undefined { + const idx = args.indexOf(flag); + return idx !== -1 ? args[idx + 1] : undefined; +} + function main(): void { const args = process.argv.slice(2); - let threshold = 25; - - const thresholdIdx = args.indexOf('--threshold'); - if (thresholdIdx !== -1 && args[thresholdIdx + 1]) { - threshold = parseFloat(args[thresholdIdx + 1]); - } + const threshold = parseFloat(parseArg(args, '--threshold') ?? '25'); + const outputPath = parseArg(args, '--output'); const baselinePath = resolve(import.meta.dirname, 'baselines/baseline.json'); const latestPath = resolve(import.meta.dirname, 'results/latest.json'); @@ -121,16 +122,22 @@ function main(): void { process.exit(0); } - console.log(`\n## Performance Comparison (threshold: ${threshold}%)\n`); - console.log('| Scenario | Metric | Baseline p95 | Current p95 | Change |'); - console.log('|----------|--------|-------------|------------|--------|'); + const lines: string[] = []; + const emit = (line: string) => { + console.log(line); + lines.push(line); + }; + + emit(`\n## Performance Comparison (threshold: ${threshold}%)\n`); + emit('| Scenario | Metric | Baseline p95 | Current p95 | Change |'); + emit('|----------|--------|-------------|------------|--------|'); let hasRegression = false; for (const [name, baselineReport] of baseline) { const currentReport = latest.get(name); if (!currentReport) { - console.log(`| ${name} | - | - | - | MISSING |`); + emit(`| ${name} | - | - | - | MISSING |`); continue; } @@ -148,19 +155,26 @@ function main(): void { hasRegression = true; } - console.log( + emit( `| ${name} | ${metric} | ${bMetric.p95.toFixed(1)} | ${cMetric.p95.toFixed(1)} | ${changeStr}${flag} |`, ); } } - console.log(''); + emit(''); if (hasRegression) { - console.error(`Performance regression detected (>${threshold}% on p95 values).`); - process.exit(1); + emit(`**Performance regression detected** (>${threshold}% on p95 values).`); } else { - console.log('No significant regressions detected.'); + emit('No significant regressions detected.'); + } + + if (outputPath) { + writeFileSync(outputPath, lines.join('\n') + '\n'); + } + + if (hasRegression) { + process.exit(1); } } From 5d2f65afcaddd9029c0cc7034ed4da0d6de0f53c Mon Sep 17 00:00:00 2001 From: Sergey Gultyayev Date: Fri, 6 Mar 2026 14:23:10 +0100 Subject: [PATCH 3/5] feat(e2e): post benchmark results as PR comment and job summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add perf:report script that formats raw benchmark metrics as a readable markdown table. The CI workflow now posts results directly to the PR as a comment (updated on re-runs) and writes to the GitHub Job Summary. Removed baseline comparison from CI — the report shows current state. --- .github/workflows/perf.yml | 47 ++++++++++- package.json | 1 + perf/compare.ts | 8 +- perf/report.ts | 155 +++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 perf/report.ts diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index fc1afbb..6a97aea 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + pull-requests: write concurrency: group: perf-${{ github.event.pull_request.number }} @@ -36,12 +37,50 @@ jobs: - name: Run performance benchmarks run: npm run perf + - name: Generate report + run: npm run perf:report -- --output "$GITHUB_STEP_SUMMARY" + + - name: Comment on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const { readFileSync } = require('fs'); + const { execSync } = require('child_process'); + + const report = execSync('npm run --silent perf:report', { encoding: 'utf-8' }); + + // Find and update existing comment, or create a new one + const marker = ''; + const body = `${marker}\n${report}`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => c.body?.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + - name: Upload results + if: always() uses: actions/upload-artifact@v4 with: name: perf-results path: perf/results/ - - - name: Compare against baseline - run: npm run perf:compare -- --threshold 25 --output "$GITHUB_STEP_SUMMARY" - continue-on-error: true diff --git a/package.json b/package.json index 72bf29d..451b6ba 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "e2e:ui": "playwright test --ui", "e2e:headed": "playwright test --headed", "perf": "playwright test --config perf/playwright.perf.config.ts", + "perf:report": "node --experimental-strip-types perf/report.ts", "perf:compare": "node --experimental-strip-types perf/compare.ts", "perf:baseline": "npm run perf && cp perf/results/latest.json perf/baselines/baseline.json", "storybook": "ng run dnd:storybook", diff --git a/perf/compare.ts b/perf/compare.ts index 8fc04c7..7953996 100644 --- a/perf/compare.ts +++ b/perf/compare.ts @@ -101,8 +101,12 @@ function main(): void { const threshold = parseFloat(parseArg(args, '--threshold') ?? '25'); const outputPath = parseArg(args, '--output'); - const baselinePath = resolve(import.meta.dirname, 'baselines/baseline.json'); - const latestPath = resolve(import.meta.dirname, 'results/latest.json'); + const baselinePath = resolve( + parseArg(args, '--baseline') ?? resolve(import.meta.dirname, 'baselines/baseline.json'), + ); + const latestPath = resolve( + parseArg(args, '--current') ?? resolve(import.meta.dirname, 'results/latest.json'), + ); if (!existsSync(baselinePath)) { console.log('No baseline found. Run `npm run perf:baseline` to create one.'); diff --git a/perf/report.ts b/perf/report.ts new file mode 100644 index 0000000..d3313ae --- /dev/null +++ b/perf/report.ts @@ -0,0 +1,155 @@ +import { readFileSync, appendFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +interface AggregatedMetrics { + mean: number; + median: number; + p95: number; + stddev: number; + min: number; + max: number; + samples: number; +} + +interface ScenarioReport { + scenario: string; + cpuThrottle?: number; + iterations?: number; + [metric: string]: AggregatedMetrics | string | number | undefined; +} + +interface Attachment { + name: string; + body?: string; + contentType: string; +} + +interface TestResult { + attachments?: Attachment[]; +} + +interface TestCase { + results?: TestResult[]; +} + +interface Spec { + tests?: TestCase[]; +} + +interface Suite { + suites?: Suite[]; + specs?: Spec[]; +} + +interface ResultsFile { + suites: Suite[]; +} + +const METRIC_LABELS: Record = { + totalBlockingTime: { label: 'Total Blocking Time', unit: 'ms' }, + longTaskCount: { label: 'Long Tasks (>50ms)', unit: '' }, + layoutCount: { label: 'Layouts', unit: '' }, + recalcStyleCount: { label: 'Style Recalcs', unit: '' }, + avgFrameTime: { label: 'Avg Frame Time', unit: 'ms' }, + maxFrameGap: { label: 'Max Frame Gap', unit: 'ms' }, + jsHeapDelta: { label: 'Heap Delta', unit: 'KB' }, +}; + +function extractScenarios(filePath: string): ScenarioReport[] { + const data: ResultsFile = JSON.parse(readFileSync(filePath, 'utf-8')); + const scenarios: ScenarioReport[] = []; + + function traverseSuite(suite: Suite): void { + for (const child of suite.suites ?? []) { + traverseSuite(child); + } + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) { + for (const result of test.results ?? []) { + for (const attachment of result.attachments ?? []) { + if (attachment.contentType === 'application/json' && attachment.body) { + const report = JSON.parse( + Buffer.from(attachment.body, 'base64').toString('utf-8'), + ) as ScenarioReport; + if (report.scenario) { + scenarios.push(report); + } + } + } + } + } + } + } + + for (const suite of data.suites ?? []) { + traverseSuite(suite); + } + + return scenarios; +} + +function formatValue(value: number, unit: string): string { + const rounded = Math.round(value * 10) / 10; + return unit ? `${rounded} ${unit}` : `${rounded}`; +} + +function generateReport(scenarios: ScenarioReport[]): string { + const lines: string[] = []; + + lines.push('## Performance Benchmark Results'); + lines.push(''); + lines.push(`> CPU throttling: **4x** · Iterations: **5** (1 warmup discarded)`); + lines.push(''); + + for (const scenario of scenarios) { + lines.push(`### ${scenario.scenario}`); + lines.push(''); + lines.push('| Metric | p95 | Mean | Stddev |'); + lines.push('|--------|-----|------|--------|'); + + for (const [key, meta] of Object.entries(METRIC_LABELS)) { + const m = scenario[key] as AggregatedMetrics | undefined; + if (!m) continue; + + lines.push( + `| ${meta.label} | **${formatValue(m.p95, meta.unit)}** | ${formatValue(m.mean, meta.unit)} | ±${formatValue(m.stddev, meta.unit)} |`, + ); + } + + lines.push(''); + } + + return lines.join('\n'); +} + +function main(): void { + const args = process.argv.slice(2); + const inputIdx = args.indexOf('--input'); + const inputPath = + inputIdx !== -1 && args[inputIdx + 1] + ? resolve(args[inputIdx + 1]) + : resolve(import.meta.dirname, 'results/latest.json'); + + const outputIdx = args.indexOf('--output'); + const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : undefined; + + if (!existsSync(inputPath)) { + console.error(`Results file not found: ${inputPath}`); + process.exit(1); + } + + const scenarios = extractScenarios(inputPath); + if (scenarios.length === 0) { + console.log('No benchmark data found in results.'); + process.exit(0); + } + + const report = generateReport(scenarios); + console.log(report); + + if (outputPath) { + appendFileSync(outputPath, report + '\n'); + } +} + +main(); From 56d582a1bad7b72182addb1634f78cd09107bf66 Mon Sep 17 00:00:00 2001 From: Sergey Gultyayev Date: Fri, 6 Mar 2026 21:26:56 +0100 Subject: [PATCH 4/5] fix(e2e): address perf benchmark accuracy and quality issues - Use Bessel's correction (n-1) for sample stddev in statistics - Remove pre-rounding before aggregation in all scenario files - Fix test name to match actual drag target (item 7, not item 10) - Extract shared extractScenarios into reusable module - Add forced GC via CDP before each measurement iteration - Add droppedFrames (>16.7ms) and p99FrameTime metrics - Rename report column from p95 to Max (honest with 5 samples) - Fix misleading warmup wording in report header - Add waitForLoadState('networkidle') after page navigation - Add artifact retention-days and TODO for perf:compare in CI --- .github/workflows/perf.yml | 2 + perf/compare.ts | 87 +++------------------- perf/fixtures/extract-scenarios.ts | 66 ++++++++++++++++ perf/fixtures/metrics-collector.ts | 18 +++++ perf/fixtures/perf.page.ts | 1 + perf/fixtures/statistics.ts | 2 +- perf/playwright.perf.config.ts | 3 + perf/report.ts | 91 +++-------------------- perf/scenarios/drag-between-lists.perf.ts | 12 +-- perf/scenarios/drag-within-list.perf.ts | 14 ++-- perf/scenarios/dynamic-height.perf.ts | 12 +-- perf/scenarios/scroll.perf.ts | 12 +-- 12 files changed, 138 insertions(+), 182 deletions(-) create mode 100644 perf/fixtures/extract-scenarios.ts diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index 6a97aea..00c6220 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -78,9 +78,11 @@ jobs: }); } + # TODO: Wire up `npm run perf:compare` once a baseline artifact strategy is in place - name: Upload results if: always() uses: actions/upload-artifact@v4 with: name: perf-results path: perf/results/ + retention-days: 30 diff --git a/perf/compare.ts b/perf/compare.ts index 7953996..2cc8bfb 100644 --- a/perf/compare.ts +++ b/perf/compare.ts @@ -1,47 +1,7 @@ -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { writeFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; - -interface AggregatedMetrics { - mean: number; - median: number; - p95: number; - stddev: number; - min: number; - max: number; - samples: number; -} - -interface ScenarioReport { - scenario: string; - [metric: string]: AggregatedMetrics | string | number; -} - -interface Attachment { - name: string; - body?: string; - contentType: string; -} - -interface TestResult { - attachments?: Attachment[]; -} - -interface TestCase { - results?: TestResult[]; -} - -interface Spec { - tests?: TestCase[]; -} - -interface Suite { - suites?: Suite[]; - specs?: Spec[]; -} - -interface ResultsFile { - suites: Suite[]; -} +import type { AggregatedMetrics } from './fixtures/statistics.ts'; +import { extractScenarios } from './fixtures/extract-scenarios.ts'; const COMPARISON_METRICS = [ 'totalBlockingTime', @@ -50,42 +10,11 @@ const COMPARISON_METRICS = [ 'recalcStyleCount', 'avgFrameTime', 'maxFrameGap', + 'droppedFrames', + 'p99FrameTime', 'jsHeapDelta', ]; -function extractScenarios(filePath: string): Map { - const data: ResultsFile = JSON.parse(readFileSync(filePath, 'utf-8')); - const scenarios = new Map(); - - function traverseSuite(suite: Suite): void { - for (const child of suite.suites ?? []) { - traverseSuite(child); - } - for (const spec of suite.specs ?? []) { - for (const test of spec.tests ?? []) { - for (const result of test.results ?? []) { - for (const attachment of result.attachments ?? []) { - if (attachment.contentType === 'application/json' && attachment.body) { - const report = JSON.parse( - Buffer.from(attachment.body, 'base64').toString('utf-8'), - ) as ScenarioReport; - if (report.scenario) { - scenarios.set(report.scenario, report); - } - } - } - } - } - } - } - - for (const suite of data.suites ?? []) { - traverseSuite(suite); - } - - return scenarios; -} - function percentChange(baseline: number, current: number): number { if (baseline === 0) return current === 0 ? 0 : 100; return ((current - baseline) / Math.abs(baseline)) * 100; @@ -118,8 +47,10 @@ function main(): void { process.exit(1); } - const baseline = extractScenarios(baselinePath); - const latest = extractScenarios(latestPath); + const baselineArr = extractScenarios(baselinePath); + const latestArr = extractScenarios(latestPath); + const baseline = new Map(baselineArr.map((s) => [s.scenario, s])); + const latest = new Map(latestArr.map((s) => [s.scenario, s])); if (baseline.size === 0) { console.log('Baseline contains no scenario data. Re-run `npm run perf:baseline`.'); diff --git a/perf/fixtures/extract-scenarios.ts b/perf/fixtures/extract-scenarios.ts new file mode 100644 index 0000000..ae0b1cb --- /dev/null +++ b/perf/fixtures/extract-scenarios.ts @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs'; + +interface Attachment { + name: string; + body?: string; + contentType: string; +} + +interface TestResult { + attachments?: Attachment[]; +} + +interface TestCase { + results?: TestResult[]; +} + +interface Spec { + tests?: TestCase[]; +} + +interface Suite { + suites?: Suite[]; + specs?: Spec[]; +} + +interface ResultsFile { + suites: Suite[]; +} + +export interface ScenarioReport { + scenario: string; + [metric: string]: unknown; +} + +export function extractScenarios(filePath: string): ScenarioReport[] { + const data: ResultsFile = JSON.parse(readFileSync(filePath, 'utf-8')); + const scenarios: ScenarioReport[] = []; + + function traverseSuite(suite: Suite): void { + for (const child of suite.suites ?? []) { + traverseSuite(child); + } + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) { + for (const result of test.results ?? []) { + for (const attachment of result.attachments ?? []) { + if (attachment.contentType === 'application/json' && attachment.body) { + const report = JSON.parse( + Buffer.from(attachment.body, 'base64').toString('utf-8'), + ) as ScenarioReport; + if (report.scenario) { + scenarios.push(report); + } + } + } + } + } + } + } + + for (const suite of data.suites ?? []) { + traverseSuite(suite); + } + + return scenarios; +} diff --git a/perf/fixtures/metrics-collector.ts b/perf/fixtures/metrics-collector.ts index 266f906..517dd19 100644 --- a/perf/fixtures/metrics-collector.ts +++ b/perf/fixtures/metrics-collector.ts @@ -24,6 +24,8 @@ export interface ScenarioMetrics { frameCount: number; avgFrameTime: number; maxFrameGap: number; + droppedFrames: number; + p99FrameTime: number; } export class MetricsCollector { @@ -47,6 +49,14 @@ export class MetricsCollector { await this.#cdp!.send('Emulation.setCPUThrottlingRate', { rate: 1 }); } + /** Force garbage collection via CDP (requires --js-flags=--expose-gc). */ + async forceGC(): Promise { + await this.#cdp!.send('Runtime.evaluate', { + expression: 'typeof gc === "function" && gc()', + awaitPromise: false, + }); + } + async getSnapshot(): Promise { const { metrics } = await this.#cdp!.send('Performance.getMetrics'); const get = (name: string) => metrics.find((m) => m.name === name)?.value ?? 0; @@ -109,6 +119,7 @@ export class MetricsCollector { * then collects all metrics. */ async measureScenario(scenario: () => Promise): Promise { + await this.forceGC(); const before = await this.getSnapshot(); await this.injectObservers(); const startTime = Date.now(); @@ -127,6 +138,11 @@ export class MetricsCollector { const frameCount = frameTimes.length; const avgFrameTime = frameCount > 0 ? frameTimes.reduce((a, b) => a + b, 0) / frameCount : 0; const maxFrameGap = frameCount > 0 ? Math.max(...frameTimes) : 0; + const droppedFrames = frameTimes.filter((t) => t > 16.7).length; + const sortedFrames = [...frameTimes].sort((a, b) => a - b); + const p99Index = sortedFrames.length > 0 ? Math.ceil(sortedFrames.length * 0.99) - 1 : 0; + const p99FrameTime = + sortedFrames.length > 0 ? sortedFrames[Math.min(p99Index, sortedFrames.length - 1)] : 0; return { durationMs, @@ -140,6 +156,8 @@ export class MetricsCollector { frameCount, avgFrameTime, maxFrameGap, + droppedFrames, + p99FrameTime, }; } diff --git a/perf/fixtures/perf.page.ts b/perf/fixtures/perf.page.ts index 85f7b33..d42bcbd 100644 --- a/perf/fixtures/perf.page.ts +++ b/perf/fixtures/perf.page.ts @@ -9,6 +9,7 @@ export class PerfPage { async goto(route = '/'): Promise { await this.page.goto(route); + await this.page.waitForLoadState('networkidle'); await this.page.locator('[data-draggable-id]').first().waitFor({ state: 'visible' }); } diff --git a/perf/fixtures/statistics.ts b/perf/fixtures/statistics.ts index 20e9c7e..8949d71 100644 --- a/perf/fixtures/statistics.ts +++ b/perf/fixtures/statistics.ts @@ -19,7 +19,7 @@ export function aggregate(values: number[]): AggregatedMetrics { const median = n % 2 === 0 ? (sorted[n / 2 - 1] + sorted[n / 2]) / 2 : sorted[Math.floor(n / 2)]; const p95Index = Math.ceil(n * 0.95) - 1; const p95 = sorted[Math.min(p95Index, n - 1)]; - const variance = sorted.reduce((sum, v) => sum + (v - mean) ** 2, 0) / n; + const variance = sorted.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (n > 1 ? n - 1 : n); const stddev = Math.sqrt(variance); return { mean, median, p95, stddev, min: sorted[0], max: sorted[n - 1], samples: n }; diff --git a/perf/playwright.perf.config.ts b/perf/playwright.perf.config.ts index 8845103..cb5332d 100644 --- a/perf/playwright.perf.config.ts +++ b/perf/playwright.perf.config.ts @@ -15,6 +15,9 @@ export default defineConfig({ video: 'off', screenshot: 'off', trace: 'off', + launchOptions: { + args: ['--js-flags=--expose-gc'], + }, }, webServer: { command: 'npm start -- --host 127.0.0.1 --port 4200 -c production', diff --git a/perf/report.ts b/perf/report.ts index d3313ae..36bf7e1 100644 --- a/perf/report.ts +++ b/perf/report.ts @@ -1,49 +1,7 @@ -import { readFileSync, appendFileSync, existsSync } from 'node:fs'; +import { appendFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; - -interface AggregatedMetrics { - mean: number; - median: number; - p95: number; - stddev: number; - min: number; - max: number; - samples: number; -} - -interface ScenarioReport { - scenario: string; - cpuThrottle?: number; - iterations?: number; - [metric: string]: AggregatedMetrics | string | number | undefined; -} - -interface Attachment { - name: string; - body?: string; - contentType: string; -} - -interface TestResult { - attachments?: Attachment[]; -} - -interface TestCase { - results?: TestResult[]; -} - -interface Spec { - tests?: TestCase[]; -} - -interface Suite { - suites?: Suite[]; - specs?: Spec[]; -} - -interface ResultsFile { - suites: Suite[]; -} +import type { AggregatedMetrics } from './fixtures/statistics.ts'; +import { extractScenarios } from './fixtures/extract-scenarios.ts'; const METRIC_LABELS: Record = { totalBlockingTime: { label: 'Total Blocking Time', unit: 'ms' }, @@ -52,59 +10,28 @@ const METRIC_LABELS: Record = { recalcStyleCount: { label: 'Style Recalcs', unit: '' }, avgFrameTime: { label: 'Avg Frame Time', unit: 'ms' }, maxFrameGap: { label: 'Max Frame Gap', unit: 'ms' }, + droppedFrames: { label: 'Dropped Frames (>16.7ms)', unit: '' }, + p99FrameTime: { label: 'p99 Frame Time', unit: 'ms' }, jsHeapDelta: { label: 'Heap Delta', unit: 'KB' }, }; -function extractScenarios(filePath: string): ScenarioReport[] { - const data: ResultsFile = JSON.parse(readFileSync(filePath, 'utf-8')); - const scenarios: ScenarioReport[] = []; - - function traverseSuite(suite: Suite): void { - for (const child of suite.suites ?? []) { - traverseSuite(child); - } - for (const spec of suite.specs ?? []) { - for (const test of spec.tests ?? []) { - for (const result of test.results ?? []) { - for (const attachment of result.attachments ?? []) { - if (attachment.contentType === 'application/json' && attachment.body) { - const report = JSON.parse( - Buffer.from(attachment.body, 'base64').toString('utf-8'), - ) as ScenarioReport; - if (report.scenario) { - scenarios.push(report); - } - } - } - } - } - } - } - - for (const suite of data.suites ?? []) { - traverseSuite(suite); - } - - return scenarios; -} - function formatValue(value: number, unit: string): string { const rounded = Math.round(value * 10) / 10; return unit ? `${rounded} ${unit}` : `${rounded}`; } -function generateReport(scenarios: ScenarioReport[]): string { +function generateReport(scenarios: { scenario: string; [k: string]: unknown }[]): string { const lines: string[] = []; lines.push('## Performance Benchmark Results'); lines.push(''); - lines.push(`> CPU throttling: **4x** · Iterations: **5** (1 warmup discarded)`); + lines.push(`> CPU throttling: **4x** · Samples: **5** (1 warmup iteration excluded)`); lines.push(''); for (const scenario of scenarios) { lines.push(`### ${scenario.scenario}`); lines.push(''); - lines.push('| Metric | p95 | Mean | Stddev |'); + lines.push('| Metric | Max | Mean | Stddev |'); lines.push('|--------|-----|------|--------|'); for (const [key, meta] of Object.entries(METRIC_LABELS)) { @@ -112,7 +39,7 @@ function generateReport(scenarios: ScenarioReport[]): string { if (!m) continue; lines.push( - `| ${meta.label} | **${formatValue(m.p95, meta.unit)}** | ${formatValue(m.mean, meta.unit)} | ±${formatValue(m.stddev, meta.unit)} |`, + `| ${meta.label} | **${formatValue(m.max, meta.unit)}** | ${formatValue(m.mean, meta.unit)} | ±${formatValue(m.stddev, meta.unit)} |`, ); } diff --git a/perf/scenarios/drag-between-lists.perf.ts b/perf/scenarios/drag-between-lists.perf.ts index 08d93c5..865c196 100644 --- a/perf/scenarios/drag-between-lists.perf.ts +++ b/perf/scenarios/drag-between-lists.perf.ts @@ -1,7 +1,7 @@ import { test } from '@playwright/test'; import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector'; import { PerfPage } from '../fixtures/perf.page'; -import { aggregate, round } from '../fixtures/statistics'; +import { aggregate } from '../fixtures/statistics'; const ITERATIONS = 5; const WARMUP_ITERATIONS = 1; @@ -76,13 +76,15 @@ test.describe('Drag Between Lists Performance', () => { cpuThrottle: CPU_THROTTLE, iterations: ITERATIONS, autoscrollHoldMs: AUTOSCROLL_HOLD_MS, - totalBlockingTime: aggregate(results.map((r) => round(r.totalBlockingTime))), + totalBlockingTime: aggregate(results.map((r) => r.totalBlockingTime)), longTaskCount: aggregate(results.map((r) => r.longTaskCount)), layoutCount: aggregate(results.map((r) => r.layoutCount)), recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)), - avgFrameTime: aggregate(results.map((r) => round(r.avgFrameTime))), - maxFrameGap: aggregate(results.map((r) => round(r.maxFrameGap))), - jsHeapDelta: aggregate(results.map((r) => round(r.jsHeapDelta / 1024))), + avgFrameTime: aggregate(results.map((r) => r.avgFrameTime)), + maxFrameGap: aggregate(results.map((r) => r.maxFrameGap)), + droppedFrames: aggregate(results.map((r) => r.droppedFrames)), + p99FrameTime: aggregate(results.map((r) => r.p99FrameTime)), + jsHeapDelta: aggregate(results.map((r) => r.jsHeapDelta / 1024)), }; testInfo.attach('drag-between-lists-autoscroll-1000', { diff --git a/perf/scenarios/drag-within-list.perf.ts b/perf/scenarios/drag-within-list.perf.ts index fb2f229..e5f524b 100644 --- a/perf/scenarios/drag-within-list.perf.ts +++ b/perf/scenarios/drag-within-list.perf.ts @@ -1,7 +1,7 @@ import { test } from '@playwright/test'; import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector'; import { PerfPage } from '../fixtures/perf.page'; -import { aggregate, round } from '../fixtures/statistics'; +import { aggregate } from '../fixtures/statistics'; const ITERATIONS = 5; const WARMUP_ITERATIONS = 1; @@ -9,7 +9,7 @@ const ITEM_COUNT = 1000; const CPU_THROTTLE = 4; test.describe('Drag Within List Performance', () => { - test('drag item 0 to item 10 - 1000 items', async ({ page }, testInfo) => { + test('drag item 0 to item 7 - 1000 items', async ({ page }, testInfo) => { const perfPage = new PerfPage(page); const collector = new MetricsCollector(page); await collector.init(); @@ -54,13 +54,15 @@ test.describe('Drag Within List Performance', () => { scenario: 'drag-within-list-1000', cpuThrottle: CPU_THROTTLE, iterations: ITERATIONS, - totalBlockingTime: aggregate(results.map((r) => round(r.totalBlockingTime))), + totalBlockingTime: aggregate(results.map((r) => r.totalBlockingTime)), longTaskCount: aggregate(results.map((r) => r.longTaskCount)), layoutCount: aggregate(results.map((r) => r.layoutCount)), recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)), - avgFrameTime: aggregate(results.map((r) => round(r.avgFrameTime))), - maxFrameGap: aggregate(results.map((r) => round(r.maxFrameGap))), - jsHeapDelta: aggregate(results.map((r) => round(r.jsHeapDelta / 1024))), + avgFrameTime: aggregate(results.map((r) => r.avgFrameTime)), + maxFrameGap: aggregate(results.map((r) => r.maxFrameGap)), + droppedFrames: aggregate(results.map((r) => r.droppedFrames)), + p99FrameTime: aggregate(results.map((r) => r.p99FrameTime)), + jsHeapDelta: aggregate(results.map((r) => r.jsHeapDelta / 1024)), }; testInfo.attach('drag-within-list-1000', { diff --git a/perf/scenarios/dynamic-height.perf.ts b/perf/scenarios/dynamic-height.perf.ts index 680cdc3..3cd7a74 100644 --- a/perf/scenarios/dynamic-height.perf.ts +++ b/perf/scenarios/dynamic-height.perf.ts @@ -1,7 +1,7 @@ import { test } from '@playwright/test'; import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector'; import { PerfPage } from '../fixtures/perf.page'; -import { aggregate, round } from '../fixtures/statistics'; +import { aggregate } from '../fixtures/statistics'; const ITERATIONS = 5; const WARMUP_ITERATIONS = 1; @@ -69,13 +69,15 @@ test.describe('Dynamic Height Scroll Performance', () => { scenario: 'dynamic-height-scroll', cpuThrottle: CPU_THROTTLE, iterations: ITERATIONS, - totalBlockingTime: aggregate(results.map((r) => round(r.totalBlockingTime))), + totalBlockingTime: aggregate(results.map((r) => r.totalBlockingTime)), longTaskCount: aggregate(results.map((r) => r.longTaskCount)), layoutCount: aggregate(results.map((r) => r.layoutCount)), recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)), - avgFrameTime: aggregate(results.map((r) => round(r.avgFrameTime))), - maxFrameGap: aggregate(results.map((r) => round(r.maxFrameGap))), - jsHeapDelta: aggregate(results.map((r) => round(r.jsHeapDelta / 1024))), + avgFrameTime: aggregate(results.map((r) => r.avgFrameTime)), + maxFrameGap: aggregate(results.map((r) => r.maxFrameGap)), + droppedFrames: aggregate(results.map((r) => r.droppedFrames)), + p99FrameTime: aggregate(results.map((r) => r.p99FrameTime)), + jsHeapDelta: aggregate(results.map((r) => r.jsHeapDelta / 1024)), }; testInfo.attach('dynamic-height-scroll', { diff --git a/perf/scenarios/scroll.perf.ts b/perf/scenarios/scroll.perf.ts index 8f9311d..d7ea951 100644 --- a/perf/scenarios/scroll.perf.ts +++ b/perf/scenarios/scroll.perf.ts @@ -1,7 +1,7 @@ import { test } from '@playwright/test'; import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector'; import { PerfPage } from '../fixtures/perf.page'; -import { aggregate, round } from '../fixtures/statistics'; +import { aggregate } from '../fixtures/statistics'; const ITERATIONS = 5; const WARMUP_ITERATIONS = 1; @@ -46,13 +46,15 @@ test.describe('Scroll Performance', () => { scenario: 'scroll-2000-items', cpuThrottle: CPU_THROTTLE, iterations: ITERATIONS, - totalBlockingTime: aggregate(results.map((r) => round(r.totalBlockingTime))), + totalBlockingTime: aggregate(results.map((r) => r.totalBlockingTime)), longTaskCount: aggregate(results.map((r) => r.longTaskCount)), layoutCount: aggregate(results.map((r) => r.layoutCount)), recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)), - avgFrameTime: aggregate(results.map((r) => round(r.avgFrameTime))), - maxFrameGap: aggregate(results.map((r) => round(r.maxFrameGap))), - jsHeapDelta: aggregate(results.map((r) => round(r.jsHeapDelta / 1024))), // KB + avgFrameTime: aggregate(results.map((r) => r.avgFrameTime)), + maxFrameGap: aggregate(results.map((r) => r.maxFrameGap)), + droppedFrames: aggregate(results.map((r) => r.droppedFrames)), + p99FrameTime: aggregate(results.map((r) => r.p99FrameTime)), + jsHeapDelta: aggregate(results.map((r) => r.jsHeapDelta / 1024)), }; testInfo.attach('scroll-2000-items', { From e67ec7ecf2930d4623f3bb5f0c25ee1b75f4a8b6 Mon Sep 17 00:00:00 2001 From: Sergey Gultyayev Date: Fri, 6 Mar 2026 21:39:17 +0100 Subject: [PATCH 5/5] feat(e2e): wire perf comparison into PR comment and job summary Add baseline comparison step to CI workflow. The PR comment now includes both raw benchmark results and the diff table against the committed baseline. Steps use `if: always()` so the comment posts even when a regression is detected. --- .github/workflows/perf.yml | 24 +++++++-- perf/baselines/baseline.json | 94 ++++++++++++++++++++++++++++-------- 2 files changed, 93 insertions(+), 25 deletions(-) diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index 00c6220..4093d27 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -40,19 +40,36 @@ jobs: - name: Generate report run: npm run perf:report -- --output "$GITHUB_STEP_SUMMARY" + - name: Compare against baseline + id: compare + if: always() + run: | + if npm run --silent perf:compare 2>&1 | tee perf/results/comparison.txt; then + echo "regression=false" >> "$GITHUB_OUTPUT" + else + echo "regression=true" >> "$GITHUB_OUTPUT" + fi + cat perf/results/comparison.txt >> "$GITHUB_STEP_SUMMARY" + - name: Comment on PR - if: github.event_name == 'pull_request' + if: always() && github.event_name == 'pull_request' uses: actions/github-script@v7 with: script: | - const { readFileSync } = require('fs'); + const { readFileSync, existsSync } = require('fs'); const { execSync } = require('child_process'); const report = execSync('npm run --silent perf:report', { encoding: 'utf-8' }); + let comparison = ''; + const compPath = 'perf/results/comparison.txt'; + if (existsSync(compPath)) { + comparison = '\n---\n' + readFileSync(compPath, 'utf-8'); + } + // Find and update existing comment, or create a new one const marker = ''; - const body = `${marker}\n${report}`; + const body = `${marker}\n${report}${comparison}`; const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, @@ -78,7 +95,6 @@ jobs: }); } - # TODO: Wire up `npm run perf:compare` once a baseline artifact strategy is in place - name: Upload results if: always() uses: actions/upload-artifact@v4 diff --git a/perf/baselines/baseline.json b/perf/baselines/baseline.json index bf1c5a0..83f9b71 100644 --- a/perf/baselines/baseline.json +++ b/perf/baselines/baseline.json @@ -1,7 +1,7 @@ { "config": { - "configFile": "/Users/crush/Projects/dnd/perf/playwright.perf.config.ts", - "rootDir": "/Users/crush/Projects/dnd/perf/scenarios", + "configFile": "/home/runner/work/angular-vdnd/angular-vdnd/perf/playwright.perf.config.ts", + "rootDir": "/home/runner/work/angular-vdnd/angular-vdnd/perf/scenarios", "forbidOnly": false, "fullyParallel": false, "globalSetup": null, @@ -11,6 +11,32 @@ "grepInvert": null, "maxFailures": 0, "metadata": { + "ci": { + "commitHref": "https://github.com/gultyayev/angular-vdnd/commit/5aaa584a6ef72f8e368b87a0f69552038e5b2b98", + "commitHash": "5aaa584a6ef72f8e368b87a0f69552038e5b2b98", + "prHref": "https://github.com/gultyayev/angular-vdnd/pull/17", + "prTitle": "feat(e2e): add automated performance benchmarks with Playwright", + "prBaseHash": "d5dc0daf91e780ec7f8ff86a4dc50a36efd5f27b", + "buildHref": "https://github.com/gultyayev/angular-vdnd/actions/runs/22780714112" + }, + "gitCommit": { + "shortHash": "5aaa584", + "hash": "5aaa584a6ef72f8e368b87a0f69552038e5b2b98", + "subject": "Merge 56d582a1bad7b72182addb1634f78cd09107bf66 into d5dc0daf91e780ec7f8ff86a4dc50a36efd5f27b", + "body": "Merge 56d582a1bad7b72182addb1634f78cd09107bf66 into d5dc0daf91e780ec7f8ff86a4dc50a36efd5f27b\n", + "author": { + "name": "Sergey Gultyayev (Serhii Hultiaiev)", + "email": "gultyayev.sergey@gmail.com", + "time": 1772828872000 + }, + "committer": { + "name": "GitHub", + "email": "noreply@github.com", + "time": 1772828872000 + }, + "branch": "HEAD" + }, + "gitDiff": "diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml\nnew file mode 100644\nindex 0000000..00c6220\n--- /dev/null\n+++ b/.github/workflows/perf.yml\n@@ -0,0 +1,88 @@\n+name: Performance Benchmarks\n+\n+on:\n+ pull_request:\n+ branches: [master]\n+\n+permissions:\n+ contents: read\n+ pull-requests: write\n+\n+concurrency:\n+ group: perf-${{ github.event.pull_request.number }}\n+ cancel-in-progress: true\n+\n+jobs:\n+ benchmark:\n+ runs-on: ubuntu-latest\n+ steps:\n+ - name: Checkout\n+ uses: actions/checkout@v4\n+\n+ - name: Setup Node.js\n+ uses: actions/setup-node@v4\n+ with:\n+ node-version: '24'\n+ cache: 'npm'\n+\n+ - name: Install dependencies\n+ run: npm ci\n+\n+ - name: Build Angular Library\n+ run: npm run build:lib\n+\n+ - name: Install Playwright browsers\n+ run: npx playwright install --with-deps chromium\n+\n+ - name: Run performance benchmarks\n+ run: npm run perf\n+\n+ - name: Generate report\n+ run: npm run perf:report -- --output \"$GITHUB_STEP_SUMMARY\"\n+\n+ - name: Comment on PR\n+ if: github.event_name == 'pull_request'\n+ uses: actions/github-script@v7\n+ with:\n+ script: |\n+ const { readFileSync } = require('fs');\n+ const { execSync } = require('child_process');\n+\n+ const report = execSync('npm run --silent perf:report', { encoding: 'utf-8' });\n+\n+ // Find and update existing comment, or create a new one\n+ const marker = '';\n+ const body = `${marker}\\n${report}`;\n+\n+ const { data: comments } = await github.rest.issues.listComments({\n+ owner: context.repo.owner,\n+ repo: context.repo.repo,\n+ issue_number: context.issue.number,\n+ });\n+\n+ const existing = comments.find(c => c.body?.includes(marker));\n+\n+ if (existing) {\n+ await github.rest.issues.updateComment({\n+ owner: context.repo.owner,\n+ repo: context.repo.repo,\n+ comment_id: existing.id,\n+ body,\n+ });\n+ } else {\n+ await github.rest.issues.createComment({\n+ owner: context.repo.owner,\n+ repo: context.repo.repo,\n+ issue_number: context.issue.number,\n+ body,\n+ });\n+ }\n+\n+ # TODO: Wire up `npm run perf:compare` once a baseline artifact strategy is in place\n+ - name: Upload results\n+ if: always()\n+ uses: actions/upload-artifact@v4\n+ with:\n+ name: perf-results\n+ path: perf/results/\n+ retention-days: 30\ndiff --git a/.gitignore b/.gitignore\nindex 8e9f591..a31ed88 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -53,5 +53,6 @@ storybook-static\n /reference\n /playwright-report\n /test-results\n+/perf/results/*.json\n \n .codemie\ndiff --git a/package.json b/package.json\nindex 931d071..451b6ba 100644\n--- a/package.json\n+++ b/package.json\n@@ -13,6 +13,10 @@\n \"e2e\": \"playwright test\",\n \"e2e:ui\": \"playwright test --ui\",\n \"e2e:headed\": \"playwright test --headed\",\n+ \"perf\": \"playwright test --config perf/playwright.perf.config.ts\",\n+ \"perf:report\": \"node --experimental-strip-types perf/report.ts\",\n+ \"perf:compare\": \"node --experimental-strip-types perf/compare.ts\",\n+ \"perf:baseline\": \"npm run perf && cp perf/results/latest.json perf/baselines/baseline.json\",\n \"storybook\": \"ng run dnd:storybook\",\n \"build-storybook\": \"ng run dnd:build-storybook\",\n \"release\": \"node scripts/release.js\",\ndiff --git a/perf/baselines/baseline.json b/perf/baselines/baseline.json\nnew file mode 100644\nindex 0000000..bf1c5a0\n--- /dev/null\n+++ b/perf/baselines/baseline.json\n@@ -0,0 +1,299 @@\n+{\n+ \"config\": {\n+ \"configFile\": \"/Users/crush/Projects/dnd/perf/playwright.perf.config.ts\",\n+ \"rootDir\": \"/Users/crush/Projects/dnd/perf/scenarios\",\n+ \"forbidOnly\": false,\n+ \"fullyParallel\": false,\n+ \"globalSetup\": null,\n+ \"globalTeardown\": null,\n+ \"globalTimeout\": 0,\n+ \"grep\": {},\n+ \"grepInvert\": null,\n+ \"maxFailures\": 0,\n+ \"metadata\": {\n+ \"actualWorkers\": 1\n+ },\n+ \"preserveOutput\": \"always\",\n+ \"reporter\": [\n+ [\"list\", null],\n+ [\n+ \"json\",\n+ {\n+ \"outputFile\": \"results/latest.json\"\n+ }\n+ ]\n+ ],\n+ \"reportSlowTests\": {\n+ \"max\": 5,\n+ \"threshold\": 300000\n+ },\n+ \"quiet\": false,\n+ \"projects\": [\n+ {\n+ \"outputDir\": \"/Users/crush/Projects/dnd/test-results\",\n+ \"repeatEach\": 1,\n+ \"retries\": 0,\n+ \"metadata\": {\n+ \"actualWorkers\": 1\n+ },\n+ \"id\": \"\",\n+ \"name\": \"\",\n+ \"testDir\": \"/Users/crush/Projects/dnd/perf/scenarios\",\n+ \"testIgnore\": [],\n+ \"testMatch\": [\"**/*.perf.ts\"],\n+ \"timeout\": 120000\n+ }\n+ ],\n+ \"shard\": null,\n+ \"tags\": [],\n+ \"updateSnapshots\": \"missing\",\n+ \"updateSourceMethod\": \"patch\",\n+ \"version\": \"1.57.0\",\n+ \"workers\": 1,\n+ \"webServer\": {\n+ \"command\": \"npm start -- --host 127.0.0.1 --port 4200 -c production\",\n+ \"url\": \"http://127.0.0.1:4200\",\n+ \"reuseExistingServer\": true,\n+ \"timeout\": 120000\n+ }\n+ },\n+ \"suites\": [\n+ {\n+ \"title\": \"drag-between-lists.perf.ts\",\n+ \"file\": \"drag-between-lists.perf.ts\",\n+ \"column\": 0,\n+ \"line\": 0,\n+ \"specs\": [],\n+ \"suites\": [\n+ {\n+ \"title\": \"Drag Between Lists Performance\",\n+ \"file\": \"drag-between-lists.perf.ts\",\n+ \"line\": 10,\n+ \"column\": 6,\n+ \"specs\": [\n+ {\n+ \"title\": \"drag from list1 to list2 with autoscroll - 1000 items\",\n+ \"ok\": true,\n+ \"tags\": [],\n+ \"tests\": [\n+ {\n+ \"timeout\": 120000,\n+ \"annotations\": [],\n+ \"expectedStatus\": \"passed\",\n+ \"projectId\": \"\",\n+ \"projectName\": \"\",\n+ \"results\": [\n+ {\n+ \"workerIndex\": 0,\n+ \"parallelIndex\": 0,\n+ \"status\": \"passed\",\n+ \"duration\": 22486,\n+ \"errors\": [],\n+ \"stdout\": [],\n+ \"stderr\": [],\n+ \"retry\": 0,\n+ \"startTime\": \"2026-03-06T10:47:04.873Z\",\n+ \"annotations\": [],\n+ \"attachments\": [\n+ {\n+ \"name\": \"drag-between-lists-autoscroll-1000\",\n+ \"contentType\": \"application/json\",\n+ \"body\": \"ewogICJzY2VuYXJpbyI6ICJkcmFnLWJldHdlZW4tbGlzdHMtYXV0b3Njcm9sbC0xMDAwIiwKICAiY3B1VGhyb3R0bGUiOiA0LAogICJpdGVyYXRpb25zIjogNSwKICAiYXV0b3Njcm9sbEhvbGRNcyI6IDMwMDAsCiAgInRvdGFsQmxvY2tpbmdUaW1lIjogewogICAgIm1lYW4iOiAwLAogICAgIm1lZGlhbiI6IDAsCiAgICAicDk1IjogMCwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDAsCiAgICAibWF4IjogMCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxvbmdUYXNrQ291bnQiOiB7CiAgICAibWVhbiI6IDAsCiAgICAibWVkaWFuIjogMCwKICAgICJwOTUiOiAwLAogICAgInN0ZGRldiI6IDAsCiAgICAibWluIjogMCwKICAgICJtYXgiOiAwLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibGF5b3V0Q291bnQiOiB7CiAgICAibWVhbiI6IDcyLAogICAgIm1lZGlhbiI6IDcxLAogICAgInA5NSI6IDc0LAogICAgInN0ZGRldiI6IDEuMjY0OTExMDY0MDY3MzUxOCwKICAgICJtaW4iOiA3MSwKICAgICJtYXgiOiA3NCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInJlY2FsY1N0eWxlQ291bnQiOiB7CiAgICAibWVhbiI6IDEwNy42LAogICAgIm1lZGlhbiI6IDEwNywKICAgICJwOTUiOiAxMTAsCiAgICAic3RkZGV2IjogMS4zNTY0NjU5OTY2MjUwNTM2LAogICAgIm1pbiI6IDEwNiwKICAgICJtYXgiOiAxMTAsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJhdmdGcmFtZVRpbWUiOiB7CiAgICAibWVhbiI6IDguMTkyLAogICAgIm1lZGlhbiI6IDguMzIsCiAgICAicDk1IjogOC4zMywKICAgICJzdGRkZXYiOiAwLjE3MTc0Mzk5NTUyODIyOCwKICAgICJtaW4iOiA3LjksCiAgICAibWF4IjogOC4zMywKICAgICJzYW1wbGVzIjogNQogIH0sCiAgIm1heEZyYW1lR2FwIjogewogICAgIm1lYW4iOiAxMS45LAogICAgIm1lZGlhbiI6IDExLjksCiAgICAicDk1IjogMTMuMSwKICAgICJzdGRkZXYiOiAwLjcwNDI3MjY3NDQ2NjM2MDIsCiAgICAibWluIjogMTEuMSwKICAgICJtYXgiOiAxMy4xLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAianNIZWFwRGVsdGEiOiB7CiAgICAibWVhbiI6IC02MzcuODA1OTk5OTk5OTk5OSwKICAgICJtZWRpYW4iOiAtMTQ3Mi41NCwKICAgICJwOTUiOiAyMjMxLjA1LAogICAgInN0ZGRldiI6IDE2OTcuNzU1MjEzMjkzMTI5NiwKICAgICJtaW4iOiAtMjYwNy40MywKICAgICJtYXgiOiAyMjMxLjA1LAogICAgInNhbXBsZXMiOiA1CiAgfQp9\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"status\": \"expected\"\n+ }\n+ ],\n+ \"id\": \"627006642c7ff841ce75-d042198824336d6b100d\",\n+ \"file\": \"drag-between-lists.perf.ts\",\n+ \"line\": 11,\n+ \"column\": 3\n+ }\n+ ]\n+ }\n+ ]\n+ },\n+ {\n+ \"title\": \"drag-within-list.perf.ts\",\n+ \"file\": \"drag-within-list.perf.ts\",\n+ \"column\": 0,\n+ \"line\": 0,\n+ \"specs\": [],\n+ \"suites\": [\n+ {\n+ \"title\": \"Drag Within List Performance\",\n+ \"file\": \"drag-within-list.perf.ts\",\n+ \"line\": 9,\n+ \"column\": 6,\n+ \"specs\": [\n+ {\n+ \"title\": \"drag item 0 to item 10 - 1000 items\",\n+ \"ok\": true,\n+ \"tags\": [],\n+ \"tests\": [\n+ {\n+ \"timeout\": 120000,\n+ \"annotations\": [],\n+ \"expectedStatus\": \"passed\",\n+ \"projectId\": \"\",\n+ \"projectName\": \"\",\n+ \"results\": [\n+ {\n+ \"workerIndex\": 0,\n+ \"parallelIndex\": 0,\n+ \"status\": \"passed\",\n+ \"duration\": 5018,\n+ \"errors\": [],\n+ \"stdout\": [],\n+ \"stderr\": [],\n+ \"retry\": 0,\n+ \"startTime\": \"2026-03-06T10:47:27.602Z\",\n+ \"annotations\": [],\n+ \"attachments\": [\n+ {\n+ \"name\": \"drag-within-list-1000\",\n+ \"contentType\": \"application/json\",\n+ \"body\": \"ewogICJzY2VuYXJpbyI6ICJkcmFnLXdpdGhpbi1saXN0LTEwMDAiLAogICJjcHVUaHJvdHRsZSI6IDQsCiAgIml0ZXJhdGlvbnMiOiA1LAogICJ0b3RhbEJsb2NraW5nVGltZSI6IHsKICAgICJtZWFuIjogMCwKICAgICJtZWRpYW4iOiAwLAogICAgInA5NSI6IDAsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAwLAogICAgIm1heCI6IDAsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsb25nVGFza0NvdW50IjogewogICAgIm1lYW4iOiAwLAogICAgIm1lZGlhbiI6IDAsCiAgICAicDk1IjogMCwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDAsCiAgICAibWF4IjogMCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxheW91dENvdW50IjogewogICAgIm1lYW4iOiA1LAogICAgIm1lZGlhbiI6IDUsCiAgICAicDk1IjogNSwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDUsCiAgICAibWF4IjogNSwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInJlY2FsY1N0eWxlQ291bnQiOiB7CiAgICAibWVhbiI6IDQ3LjYsCiAgICAibWVkaWFuIjogNDgsCiAgICAicDk1IjogNDksCiAgICAic3RkZGV2IjogMS4wMTk4MDM5MDI3MTg1NTY4LAogICAgIm1pbiI6IDQ2LAogICAgIm1heCI6IDQ5LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAiYXZnRnJhbWVUaW1lIjogewogICAgIm1lYW4iOiA3Ljk2ODAwMDAwMDAwMDAwMSwKICAgICJtZWRpYW4iOiA4LjAxLAogICAgInA5NSI6IDguMDcsCiAgICAic3RkZGV2IjogMC4wOTg0NjgyNjkwMDA3Mjk2MSwKICAgICJtaW4iOiA3LjgxLAogICAgIm1heCI6IDguMDcsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJtYXhGcmFtZUdhcCI6IHsKICAgICJtZWFuIjogMTEuMDQwMDAwMDAwMDAwMDAxLAogICAgIm1lZGlhbiI6IDExLAogICAgInA5NSI6IDEyLjIsCiAgICAic3RkZGV2IjogMC42ODg3NjcwMTQzMDg5MDIyLAogICAgIm1pbiI6IDEwLjMsCiAgICAibWF4IjogMTIuMiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImpzSGVhcERlbHRhIjogewogICAgIm1lYW4iOiA0MTMuNzMyMDAwMDAwMDAwMSwKICAgICJtZWRpYW4iOiA0MTMuNjEsCiAgICAicDk1IjogNDE2LjY3LAogICAgInN0ZGRldiI6IDEuODA0ODMxMjk0MDU0OTMxNiwKICAgICJtaW4iOiA0MTAuOTgsCiAgICAibWF4IjogNDE2LjY3LAogICAgInNhbXBsZXMiOiA1CiAgfQp9\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"status\": \"expected\"\n+ }\n+ ],\n+ \"id\": \"a43c3a9bf74f7ffdb398-14fac9bab569ea34b83a\",\n+ \"file\": \"drag-within-list.perf.ts\",\n+ \"line\": 10,\n+ \"column\": 3\n+ }\n+ ]\n+ }\n+ ]\n+ },\n+ {\n+ \"title\": \"dynamic-height.perf.ts\",\n+ \"file\": \"dynamic-height.perf.ts\",\n+ \"column\": 0,\n+ \"line\": 0,\n+ \"specs\": [],\n+ \"suites\": [\n+ {\n+ \"title\": \"Dynamic Height Scroll Performance\",\n+ \"file\": \"dynamic-height.perf.ts\",\n+ \"line\": 8,\n+ \"column\": 6,\n+ \"specs\": [\n+ {\n+ \"title\": \"scroll through dynamic height list\",\n+ \"ok\": true,\n+ \"tags\": [],\n+ \"tests\": [\n+ {\n+ \"timeout\": 120000,\n+ \"annotations\": [],\n+ \"expectedStatus\": \"passed\",\n+ \"projectId\": \"\",\n+ \"projectName\": \"\",\n+ \"results\": [\n+ {\n+ \"workerIndex\": 0,\n+ \"parallelIndex\": 0,\n+ \"status\": \"passed\",\n+ \"duration\": 14210,\n+ \"errors\": [],\n+ \"stdout\": [],\n+ \"stderr\": [],\n+ \"retry\": 0,\n+ \"startTime\": \"2026-03-06T10:47:32.628Z\",\n+ \"annotations\": [],\n+ \"attachments\": [\n+ {\n+ \"name\": \"dynamic-height-scroll\",\n+ \"contentType\": \"application/json\",\n+ \"body\": \"ewogICJzY2VuYXJpbyI6ICJkeW5hbWljLWhlaWdodC1zY3JvbGwiLAogICJjcHVUaHJvdHRsZSI6IDQsCiAgIml0ZXJhdGlvbnMiOiA1LAogICJ0b3RhbEJsb2NraW5nVGltZSI6IHsKICAgICJtZWFuIjogMjgsCiAgICAibWVkaWFuIjogMjgsCiAgICAicDk1IjogMjgsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAyOCwKICAgICJtYXgiOiAyOCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxvbmdUYXNrQ291bnQiOiB7CiAgICAibWVhbiI6IDIsCiAgICAibWVkaWFuIjogMiwKICAgICJwOTUiOiAyLAogICAgInN0ZGRldiI6IDAsCiAgICAibWluIjogMiwKICAgICJtYXgiOiAyLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibGF5b3V0Q291bnQiOiB7CiAgICAibWVhbiI6IDEyOC42LAogICAgIm1lZGlhbiI6IDEyOSwKICAgICJwOTUiOiAxMzAsCiAgICAic3RkZGV2IjogMS4zNTY0NjU5OTY2MjUwNTM2LAogICAgIm1pbiI6IDEyNiwKICAgICJtYXgiOiAxMzAsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJyZWNhbGNTdHlsZUNvdW50IjogewogICAgIm1lYW4iOiAyNzMuNiwKICAgICJtZWRpYW4iOiAyNzMsCiAgICAicDk1IjogMjc2LAogICAgInN0ZGRldiI6IDEuMzU2NDY1OTk2NjI1MDUzNiwKICAgICJtaW4iOiAyNzIsCiAgICAibWF4IjogMjc2LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAiYXZnRnJhbWVUaW1lIjogewogICAgIm1lYW4iOiA3Ljg2MTk5OTk5OTk5OTk5OSwKICAgICJtZWRpYW4iOiA3Ljg4LAogICAgInA5NSI6IDcuOSwKICAgICJzdGRkZXYiOiAwLjAzNDg3MTE5MTU0ODMyNTUzNCwKICAgICJtaW4iOiA3LjgsCiAgICAibWF4IjogNy45LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibWF4RnJhbWVHYXAiOiB7CiAgICAibWVhbiI6IDExLjQ4LAogICAgIm1lZGlhbiI6IDExLAogICAgInA5NSI6IDEzLjIsCiAgICAic3RkZGV2IjogMC45NTE2MzAxODAyNjk2MjUzLAogICAgIm1pbiI6IDEwLjYsCiAgICAibWF4IjogMTMuMiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImpzSGVhcERlbHRhIjogewogICAgIm1lYW4iOiAtMjU5LjM4NCwKICAgICJtZWRpYW4iOiAyODIuMDMsCiAgICAicDk1IjogNDc1Ljg1LAogICAgInN0ZGRldiI6IDgzNi42NTkzNjQwNTY4NDI1LAogICAgIm1pbiI6IC0xNTQ2LjI0LAogICAgIm1heCI6IDQ3NS44NSwKICAgICJzYW1wbGVzIjogNQogIH0KfQ==\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"status\": \"expected\"\n+ }\n+ ],\n+ \"id\": \"53b37ba56613b41fa25c-fd0a2c300ba8b1f9765d\",\n+ \"file\": \"dynamic-height.perf.ts\",\n+ \"line\": 9,\n+ \"column\": 3\n+ }\n+ ]\n+ }\n+ ]\n+ },\n+ {\n+ \"title\": \"scroll.perf.ts\",\n+ \"file\": \"scroll.perf.ts\",\n+ \"column\": 0,\n+ \"line\": 0,\n+ \"specs\": [],\n+ \"suites\": [\n+ {\n+ \"title\": \"Scroll Performance\",\n+ \"file\": \"scroll.perf.ts\",\n+ \"line\": 10,\n+ \"column\": 6,\n+ \"specs\": [\n+ {\n+ \"title\": \"large list scroll - 2000 items\",\n+ \"ok\": true,\n+ \"tags\": [],\n+ \"tests\": [\n+ {\n+ \"timeout\": 120000,\n+ \"annotations\": [],\n+ \"expectedStatus\": \"passed\",\n+ \"projectId\": \"\",\n+ \"projectName\": \"\",\n+ \"results\": [\n+ {\n+ \"workerIndex\": 0,\n+ \"parallelIndex\": 0,\n+ \"status\": \"passed\",\n+ \"duration\": 14274,\n+ \"errors\": [],\n+ \"stdout\": [],\n+ \"stderr\": [],\n+ \"retry\": 0,\n+ \"startTime\": \"2026-03-06T10:47:46.846Z\",\n+ \"annotations\": [],\n+ \"attachments\": [\n+ {\n+ \"name\": \"scroll-2000-items\",\n+ \"contentType\": \"application/json\",\n+ \"body\": \"ewogICJzY2VuYXJpbyI6ICJzY3JvbGwtMjAwMC1pdGVtcyIsCiAgImNwdVRocm90dGxlIjogNCwKICAiaXRlcmF0aW9ucyI6IDUsCiAgInRvdGFsQmxvY2tpbmdUaW1lIjogewogICAgIm1lYW4iOiAzNywKICAgICJtZWRpYW4iOiAzNywKICAgICJwOTUiOiAzNywKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDM3LAogICAgIm1heCI6IDM3LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibG9uZ1Rhc2tDb3VudCI6IHsKICAgICJtZWFuIjogMiwKICAgICJtZWRpYW4iOiAyLAogICAgInA5NSI6IDIsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAyLAogICAgIm1heCI6IDIsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsYXlvdXRDb3VudCI6IHsKICAgICJtZWFuIjogMTIxLjIsCiAgICAibWVkaWFuIjogMTIxLAogICAgInA5NSI6IDEyMiwKICAgICJzdGRkZXYiOiAwLjc0ODMzMTQ3NzM1NDc4ODIsCiAgICAibWluIjogMTIwLAogICAgIm1heCI6IDEyMiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInJlY2FsY1N0eWxlQ291bnQiOiB7CiAgICAibWVhbiI6IDEyMS4yLAogICAgIm1lZGlhbiI6IDEyMSwKICAgICJwOTUiOiAxMjIsCiAgICAic3RkZGV2IjogMC43NDgzMzE0NzczNTQ3ODgyLAogICAgIm1pbiI6IDEyMCwKICAgICJtYXgiOiAxMjIsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJhdmdGcmFtZVRpbWUiOiB7CiAgICAibWVhbiI6IDcuOTA0MDAwMDAwMDAwMDAxLAogICAgIm1lZGlhbiI6IDcuODksCiAgICAicDk1IjogNy45OSwKICAgICJzdGRkZXYiOiAwLjA1MjAwMDAwMDAwMDAwMDA4LAogICAgIm1pbiI6IDcuODMsCiAgICAibWF4IjogNy45OSwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgIm1heEZyYW1lR2FwIjogewogICAgIm1lYW4iOiAxMy4wMiwKICAgICJtZWRpYW4iOiAxMywKICAgICJwOTUiOiAxMy41LAogICAgInN0ZGRldiI6IDAuNDc5MTY1OTQyMDI4NDM3NzYsCiAgICAibWluIjogMTIuMiwKICAgICJtYXgiOiAxMy41LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAianNIZWFwRGVsdGEiOiB7CiAgICAibWVhbiI6IC05NzcuNTMxOTk5OTk5OTk5OCwKICAgICJtZWRpYW4iOiAtNzY1LjE0LAogICAgInA5NSI6IDQzNTEuMTksCiAgICAic3RkZGV2IjogMzg0NC45OTg5MTE3MjYyNDQsCiAgICAibWluIjogLTU3NTUuODgsCiAgICAibWF4IjogNDM1MS4xOSwKICAgICJzYW1wbGVzIjogNQogIH0KfQ==\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"status\": \"expected\"\n+ }\n+ ],\n+ \"id\": \"e4444aa54d21dcc13bb1-7b5cf2c5d052bafba4c6\",\n+ \"file\": \"scroll.perf.ts\",\n+ \"line\": 11,\n+ \"column\": 3\n+ }\n+ ]\n+ }\n+ ]\n+ }\n+ ],\n+ \"errors\": [],\n+ \"stats\": {\n+ \"startTime\": \"2026-03-06T10:47:00.475Z\",\n+ \"duration\": 60692.924,\n+ \"expected\": 4,\n+ \"skipped\": 0,\n+ \"unexpected\": 0,\n+ \"flaky\": 0\n+ }\n+}\ndiff --git a/perf/compare.ts b/perf/compare.ts\nnew file mode 100644\nindex 0000000..2cc8bfb\n--- /dev/null\n+++ b/perf/compare.ts\n@@ -0,0 +1,116 @@\n+import { writeFileSync, existsSync } from 'node:fs';\n+import { resolve } from 'node:path';\n+import type { AggregatedMetrics } from './fixtures/statistics.ts';\n+import { extractScenarios } from './fixtures/extract-scenarios.ts';\n+\n+const COMPARISON_METRICS = [\n+ 'totalBlockingTime',\n+ 'longTaskCount',\n+ 'layoutCount',\n+ 'recalcStyleCount',\n+ 'avgFrameTime',\n+ 'maxFrameGap',\n+ 'droppedFrames',\n+ 'p99FrameTime',\n+ 'jsHeapDelta',\n+];\n+\n+function percentChange(baseline: number, current: number): number {\n+ if (baseline === 0) return current === 0 ? 0 : 100;\n+ return ((current - baseline) / Math.abs(baseline)) * 100;\n+}\n+\n+function parseArg(args: string[], flag: string): string | undefined {\n+ const idx = args.indexOf(flag);\n+ return idx !== -1 ? args[idx + 1] : undefined;\n+}\n+\n+function main(): void {\n+ const args = process.argv.slice(2);\n+ const threshold = parseFloat(parseArg(args, '--threshold') ?? '25');\n+ const outputPath = parseArg(args, '--output');\n+\n+ const baselinePath = resolve(\n+ parseArg(args, '--baseline') ?? resolve(import.meta.dirname, 'baselines/baseline.json'),\n+ );\n+ const latestPath = resolve(\n+ parseArg(args, '--current') ?? resolve(import.meta.dirname, 'results/latest.json'),\n+ );\n+\n+ if (!existsSync(baselinePath)) {\n+ console.log('No baseline found. Run `npm run perf:baseline` to create one.');\n+ process.exit(0);\n+ }\n+\n+ if (!existsSync(latestPath)) {\n+ console.error('No latest results found. Run `npm run perf` first.');\n+ process.exit(1);\n+ }\n+\n+ const baselineArr = extractScenarios(baselinePath);\n+ const latestArr = extractScenarios(latestPath);\n+ const baseline = new Map(baselineArr.map((s) => [s.scenario, s]));\n+ const latest = new Map(latestArr.map((s) => [s.scenario, s]));\n+\n+ if (baseline.size === 0) {\n+ console.log('Baseline contains no scenario data. Re-run `npm run perf:baseline`.');\n+ process.exit(0);\n+ }\n+\n+ const lines: string[] = [];\n+ const emit = (line: string) => {\n+ console.log(line);\n+ lines.push(line);\n+ };\n+\n+ emit(`\\n## Performance Comparison (threshold: ${threshold}%)\\n`);\n+ emit('| Scenario | Metric | Baseline p95 | Current p95 | Change |');\n+ emit('|----------|--------|-------------|------------|--------|');\n+\n+ let hasRegression = false;\n+\n+ for (const [name, baselineReport] of baseline) {\n+ const currentReport = latest.get(name);\n+ if (!currentReport) {\n+ emit(`| ${name} | - | - | - | MISSING |`);\n+ continue;\n+ }\n+\n+ for (const metric of COMPARISON_METRICS) {\n+ const bMetric = baselineReport[metric] as AggregatedMetrics | undefined;\n+ const cMetric = currentReport[metric] as AggregatedMetrics | undefined;\n+\n+ if (!bMetric || !cMetric) continue;\n+\n+ const change = percentChange(bMetric.p95, cMetric.p95);\n+ const changeStr = `${change >= 0 ? '+' : ''}${change.toFixed(1)}%`;\n+ const flag = change > threshold ? ' REGRESSION' : '';\n+\n+ if (change > threshold) {\n+ hasRegression = true;\n+ }\n+\n+ emit(\n+ `| ${name} | ${metric} | ${bMetric.p95.toFixed(1)} | ${cMetric.p95.toFixed(1)} | ${changeStr}${flag} |`,\n+ );\n+ }\n+ }\n+\n+ emit('');\n+\n+ if (hasRegression) {\n+ emit(`**Performance regression detected** (>${threshold}% on p95 values).`);\n+ } else {\n+ emit('No significant regressions detected.');\n+ }\n+\n+ if (outputPath) {\n+ writeFileSync(outputPath, lines.join('\\n') + '\\n');\n+ }\n+\n+ if (hasRegression) {\n+ process.exit(1);\n+ }\n+}\n+\n+main();\ndiff --git a/perf/fixtures/extract-scenarios.ts b/perf/fixtures/extract-scenarios.ts\nnew file mode 100644\nindex 0000000..ae0b1cb\n--- /dev/null\n+++ b/perf/fixtures/extract-scenarios.ts\n@@ -0,0 +1,66 @@\n+import { readFileSync } from 'node:fs';\n+\n+interface Attachment {\n+ name: string;\n+ body?: string;\n+ contentType: string;\n+}\n+\n+interface TestResult {\n+ attachments?: Attachment[];\n+}\n+\n+interface TestCase {\n+ results?: TestResult[];\n+}\n+\n+interface Spec {\n+ tests?: TestCase[];\n+}\n+\n+interface Suite {\n+ suites?: Suite[];\n+ specs?: Spec[];\n+}\n+\n+interface ResultsFile {\n+ suites: Suite[];\n+}\n+\n+export interface ScenarioReport {\n+ scenario: string;\n+ [metric: string]: unknown;\n+}\n+\n+export function extractScenarios(filePath: string): ScenarioReport[] {\n+ const data: ResultsFile = JSON.parse(readFileSync(filePath, 'utf-8'));\n+ const scenarios: ScenarioReport[] = [];\n+\n+ function traverseSuite(suite: Suite): void {\n+ for (const child of suite.suites ?? []) {\n+ traverseSuite(child);\n+ }\n+ for (const spec of suite.specs ?? []) {\n+ for (const test of spec.tests ?? []) {\n+ for (const result of test.results ?? []) {\n+ for (const attachment of result.attachments ?? []) {\n+ if (attachment.contentType === 'application/json' && attachment.body) {\n+ const report = JSON.parse(\n+ Buffer.from(attachment.body, 'base64').toString('utf-8'),\n+ ) as ScenarioReport;\n+ if (report.scenario) {\n+ scenarios.push(report);\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+\n+ for (const suite of data.suites ?? []) {\n+ traverseSuite(suite);\n+ }\n+\n+ return scenarios;\n+}\ndiff --git a/perf/fixtures/metrics-collector.ts b/perf/fixtures/metrics-collector.ts\nnew file mode 100644\nindex 0000000..517dd19\n--- /dev/null\n+++ b/perf/fixtures/metrics-collector.ts\n@@ -0,0 +1,170 @@\n+import { CDPSession, Page } from '@playwright/test';\n+\n+export interface PerfSnapshot {\n+ timestamp: number;\n+ jsHeapUsedSize: number;\n+ layoutCount: number;\n+ recalcStyleCount: number;\n+}\n+\n+export interface LongTask {\n+ startTime: number;\n+ duration: number;\n+}\n+\n+export interface ScenarioMetrics {\n+ durationMs: number;\n+ longTaskCount: number;\n+ totalBlockingTime: number;\n+ layoutCount: number;\n+ recalcStyleCount: number;\n+ jsHeapBefore: number;\n+ jsHeapAfter: number;\n+ jsHeapDelta: number;\n+ frameCount: number;\n+ avgFrameTime: number;\n+ maxFrameGap: number;\n+ droppedFrames: number;\n+ p99FrameTime: number;\n+}\n+\n+export class MetricsCollector {\n+ #page: Page;\n+ #cdp: CDPSession | null = null;\n+\n+ constructor(page: Page) {\n+ this.#page = page;\n+ }\n+\n+ async init(): Promise {\n+ this.#cdp = await this.#page.context().newCDPSession(this.#page);\n+ await this.#cdp.send('Performance.enable');\n+ }\n+\n+ async setCpuThrottling(rate: number): Promise {\n+ await this.#cdp!.send('Emulation.setCPUThrottlingRate', { rate });\n+ }\n+\n+ async clearCpuThrottling(): Promise {\n+ await this.#cdp!.send('Emulation.setCPUThrottlingRate', { rate: 1 });\n+ }\n+\n+ /** Force garbage collection via CDP (requires --js-flags=--expose-gc). */\n+ async forceGC(): Promise {\n+ await this.#cdp!.send('Runtime.evaluate', {\n+ expression: 'typeof gc === \"function\" && gc()',\n+ awaitPromise: false,\n+ });\n+ }\n+\n+ async getSnapshot(): Promise {\n+ const { metrics } = await this.#cdp!.send('Performance.getMetrics');\n+ const get = (name: string) => metrics.find((m) => m.name === name)?.value ?? 0;\n+ return {\n+ timestamp: Date.now(),\n+ jsHeapUsedSize: get('JSHeapUsedSize'),\n+ layoutCount: get('LayoutCount'),\n+ recalcStyleCount: get('RecalcStyleCount'),\n+ };\n+ }\n+\n+ /**\n+ * Inject a PerformanceObserver for long tasks and an rAF-based frame tracker into the page.\n+ * Must be called before the scenario runs.\n+ */\n+ async injectObservers(): Promise {\n+ await this.#page.evaluate(() => {\n+ const w = window as Window & Record;\n+ w.__perfLongTasks = [];\n+ w.__perfFrames = [];\n+ w.__perfLastFrameTime = 0;\n+ w.__perfTrackingActive = true;\n+\n+ new PerformanceObserver((list) => {\n+ for (const entry of list.getEntries()) {\n+ (w.__perfLongTasks as { startTime: number; duration: number }[]).push({\n+ startTime: entry.startTime,\n+ duration: entry.duration,\n+ });\n+ }\n+ }).observe({ type: 'longtask', buffered: true });\n+\n+ const trackFrame = () => {\n+ const now = performance.now();\n+ if ((w.__perfLastFrameTime as number) > 0) {\n+ (w.__perfFrames as number[]).push(now - (w.__perfLastFrameTime as number));\n+ }\n+ w.__perfLastFrameTime = now;\n+ if (w.__perfTrackingActive) {\n+ requestAnimationFrame(trackFrame);\n+ }\n+ };\n+ requestAnimationFrame(trackFrame);\n+ });\n+ }\n+\n+ async collectObserverResults(): Promise<{ longTasks: LongTask[]; frameTimes: number[] }> {\n+ return this.#page.evaluate(() => {\n+ const w = window as Window & Record;\n+ w.__perfTrackingActive = false;\n+ return {\n+ longTasks: (w.__perfLongTasks as LongTask[]) ?? [],\n+ frameTimes: (w.__perfFrames as number[]) ?? [],\n+ };\n+ });\n+ }\n+\n+ /**\n+ * Measure a scenario: takes snapshots, injects observers, runs the scenario,\n+ * then collects all metrics.\n+ */\n+ async measureScenario(scenario: () => Promise): Promise {\n+ await this.forceGC();\n+ const before = await this.getSnapshot();\n+ await this.injectObservers();\n+ const startTime = Date.now();\n+\n+ await scenario();\n+\n+ const durationMs = Date.now() - startTime;\n+ const after = await this.getSnapshot();\n+ const { longTasks, frameTimes } = await this.collectObserverResults();\n+\n+ const totalBlockingTime = longTasks.reduce(\n+ (sum, task) => sum + Math.max(0, task.duration - 50),\n+ 0,\n+ );\n+\n+ const frameCount = frameTimes.length;\n+ const avgFrameTime = frameCount > 0 ? frameTimes.reduce((a, b) => a + b, 0) / frameCount : 0;\n+ const maxFrameGap = frameCount > 0 ? Math.max(...frameTimes) : 0;\n+ const droppedFrames = frameTimes.filter((t) => t > 16.7).length;\n+ const sortedFrames = [...frameTimes].sort((a, b) => a - b);\n+ const p99Index = sortedFrames.length > 0 ? Math.ceil(sortedFrames.length * 0.99) - 1 : 0;\n+ const p99FrameTime =\n+ sortedFrames.length > 0 ? sortedFrames[Math.min(p99Index, sortedFrames.length - 1)] : 0;\n+\n+ return {\n+ durationMs,\n+ longTaskCount: longTasks.length,\n+ totalBlockingTime,\n+ layoutCount: after.layoutCount - before.layoutCount,\n+ recalcStyleCount: after.recalcStyleCount - before.recalcStyleCount,\n+ jsHeapBefore: before.jsHeapUsedSize,\n+ jsHeapAfter: after.jsHeapUsedSize,\n+ jsHeapDelta: after.jsHeapUsedSize - before.jsHeapUsedSize,\n+ frameCount,\n+ avgFrameTime,\n+ maxFrameGap,\n+ droppedFrames,\n+ p99FrameTime,\n+ };\n+ }\n+\n+ async dispose(): Promise {\n+ await this.clearCpuThrottling();\n+ if (this.#cdp) {\n+ await this.#cdp.detach();\n+ }\n+ }\n+}\ndiff --git a/perf/fixtures/perf.page.ts b/perf/fixtures/perf.page.ts\nnew file mode 100644\nindex 0000000..d42bcbd\n--- /dev/null\n+++ b/perf/fixtures/perf.page.ts\n@@ -0,0 +1,140 @@\n+import { expect, Page } from '@playwright/test';\n+\n+export class PerfPage {\n+ readonly page: Page;\n+\n+ constructor(page: Page) {\n+ this.page = page;\n+ }\n+\n+ async goto(route = '/'): Promise {\n+ await this.page.goto(route);\n+ await this.page.waitForLoadState('networkidle');\n+ await this.page.locator('[data-draggable-id]').first().waitFor({ state: 'visible' });\n+ }\n+\n+ /**\n+ * Set the item count on the main demo page and regenerate items.\n+ * Only works on the `/` route.\n+ */\n+ async setItemCount(count: number): Promise {\n+ const input = this.page.locator('#itemCount');\n+ await input.fill(String(count));\n+ await this.page.locator('button', { hasText: 'Regenerate' }).click();\n+ // Wait for virtual scroll to render with the new item count\n+ await expect(async () => {\n+ const badge = this.page.locator('.list-badge').first();\n+ const text = await badge.textContent();\n+ expect(parseInt(text?.trim() ?? '0', 10)).toBe(Math.floor(count / 2));\n+ }).toPass({ timeout: 5000 });\n+ }\n+\n+ /**\n+ * Programmatic smooth scroll using rAF interpolation.\n+ * Scrolls the element matching `selector` from its current position to `targetScrollTop`\n+ * over `durationMs` milliseconds.\n+ */\n+ async smoothScroll(selector: string, targetScrollTop: number, durationMs: number): Promise {\n+ await this.page.evaluate(\n+ ({ selector, target, duration }) => {\n+ return new Promise((resolve) => {\n+ const el = document.querySelector(selector) as HTMLElement;\n+ if (!el) {\n+ resolve();\n+ return;\n+ }\n+ const start = el.scrollTop;\n+ const delta = target - start;\n+ const startTime = performance.now();\n+ const step = () => {\n+ const elapsed = performance.now() - startTime;\n+ const progress = Math.min(elapsed / duration, 1);\n+ // Ease-in-out for more realistic scroll behavior\n+ const eased =\n+ progress < 0.5 ? 2 * progress * progress : 1 - (-2 * progress + 2) ** 2 / 2;\n+ el.scrollTop = start + delta * eased;\n+ if (progress < 1) {\n+ requestAnimationFrame(step);\n+ } else {\n+ resolve();\n+ }\n+ };\n+ requestAnimationFrame(step);\n+ });\n+ },\n+ { selector, target: targetScrollTop, duration: durationMs },\n+ );\n+ }\n+\n+ /**\n+ * Simulate a full drag operation with stepped mouse moves.\n+ * Mirrors the E2E drag pattern from `demo.page.ts`.\n+ */\n+ async simulateDrag(opts: {\n+ startX: number;\n+ startY: number;\n+ endX: number;\n+ endY: number;\n+ steps?: number;\n+ holdDurationMs?: number;\n+ }): Promise {\n+ const { startX, startY, endX, endY, steps = 15, holdDurationMs } = opts;\n+\n+ await this.page.mouse.move(startX, startY);\n+ await this.page.mouse.down();\n+\n+ // Small initial move to pass drag threshold\n+ await this.page.mouse.move(startX + 5, startY + 5, { steps: 2 });\n+\n+ // Wait for drag preview\n+ const dragPreview = this.page.getByTestId('vdnd-drag-preview');\n+ await expect(dragPreview).toBeVisible({ timeout: 2000 });\n+\n+ // Move to target\n+ await this.page.mouse.move(endX, endY, { steps });\n+ // Firefox finalization move\n+ await this.page.mouse.move(endX, endY);\n+\n+ if (holdDurationMs) {\n+ await this.page.waitForTimeout(holdDurationMs);\n+ }\n+\n+ // Wait one rAF for position update\n+ await this.page.evaluate(() => new Promise((r) => requestAnimationFrame(r)));\n+ await this.page.mouse.up();\n+\n+ // Wait for drag to complete\n+ await expect(dragPreview).not.toBeVisible({ timeout: 2000 });\n+ }\n+\n+ /**\n+ * Get bounding box of a virtual scroll container.\n+ */\n+ async getContainerBox(list: 'list1' | 'list2') {\n+ const droppableId = list === 'list1' ? 'list-1' : 'list-2';\n+ const container = this.page.locator(`[data-droppable-id=\"${droppableId}\"] vdnd-virtual-scroll`);\n+ return container.boundingBox();\n+ }\n+\n+ /**\n+ * Get bounding box of a specific draggable item.\n+ */\n+ async getItemBox(list: 'list1' | 'list2', index: number) {\n+ const droppableId = list === 'list1' ? 'list-1' : 'list-2';\n+ const items = this.page.locator(`[data-droppable-id=\"${droppableId}\"] [data-draggable-id]`);\n+ return items.nth(index).boundingBox();\n+ }\n+\n+ /** Reset scroll position of both lists to the top. */\n+ async resetScrollPositions(): Promise {\n+ for (const id of ['list-1', 'list-2']) {\n+ await this.page.evaluate((droppableId) => {\n+ const el = document.querySelector(\n+ `[data-droppable-id=\"${droppableId}\"] vdnd-virtual-scroll`,\n+ ) as HTMLElement;\n+ if (el) el.scrollTop = 0;\n+ }, id);\n+ }\n+ await this.page.evaluate(() => new Promise((r) => requestAnimationFrame(r)));\n+ }\n+}\ndiff --git a/perf/fixtures/statistics.ts b/perf/fixtures/statistics.ts\nnew file mode 100644\nindex 0000000..8949d71\n--- /dev/null\n+++ b/perf/fixtures/statistics.ts\n@@ -0,0 +1,32 @@\n+export interface AggregatedMetrics {\n+ mean: number;\n+ median: number;\n+ p95: number;\n+ stddev: number;\n+ min: number;\n+ max: number;\n+ samples: number;\n+}\n+\n+export function aggregate(values: number[]): AggregatedMetrics {\n+ if (values.length === 0) {\n+ return { mean: 0, median: 0, p95: 0, stddev: 0, min: 0, max: 0, samples: 0 };\n+ }\n+\n+ const sorted = [...values].sort((a, b) => a - b);\n+ const n = sorted.length;\n+ const mean = sorted.reduce((a, b) => a + b, 0) / n;\n+ const median = n % 2 === 0 ? (sorted[n / 2 - 1] + sorted[n / 2]) / 2 : sorted[Math.floor(n / 2)];\n+ const p95Index = Math.ceil(n * 0.95) - 1;\n+ const p95 = sorted[Math.min(p95Index, n - 1)];\n+ const variance = sorted.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (n > 1 ? n - 1 : n);\n+ const stddev = Math.sqrt(variance);\n+\n+ return { mean, median, p95, stddev, min: sorted[0], max: sorted[n - 1], samples: n };\n+}\n+\n+/** Round a number to a fixed number of decimal places for display. */\n+export function round(value: number, decimals = 2): number {\n+ const factor = 10 ** decimals;\n+ return Math.round(value * factor) / factor;\n+}\ndiff --git a/perf/playwright.perf.config.ts b/perf/playwright.perf.config.ts\nnew file mode 100644\nindex 0000000..cb5332d\n--- /dev/null\n+++ b/perf/playwright.perf.config.ts\n@@ -0,0 +1,28 @@\n+import { defineConfig, devices } from '@playwright/test';\n+\n+export default defineConfig({\n+ testDir: './scenarios',\n+ testMatch: '**/*.perf.ts',\n+ fullyParallel: false,\n+ workers: 1,\n+ retries: 0,\n+ timeout: 120_000,\n+ reporter: [['list'], ['json', { outputFile: 'results/latest.json' }]],\n+ use: {\n+ baseURL: 'http://127.0.0.1:4200',\n+ ...devices['Desktop Chrome'],\n+ headless: true,\n+ video: 'off',\n+ screenshot: 'off',\n+ trace: 'off',\n+ launchOptions: {\n+ args: ['--js-flags=--expose-gc'],\n+ },\n+ },\n+ webServer: {\n+ command: 'npm start -- --host 127.0.0.1 --port 4200 -c production',\n+ url: 'http://127.0.0.1:4200',\n+ reuseExistingServer: !process.env.CI,\n+ timeout: 120_000,\n+ },\n+});\ndiff --git a/perf/report.ts b/perf/report.ts\nnew file mode 100644\nindex 0000000..36bf7e1\n--- /dev/null\n+++ b/perf/report.ts\n@@ -0,0 +1,82 @@\n+import { appendFileSync, existsSync } from 'node:fs';\n+import { resolve } from 'node:path';\n+import type { AggregatedMetrics } from './fixtures/statistics.ts';\n+import { extractScenarios } from './fixtures/extract-scenarios.ts';\n+\n+const METRIC_LABELS: Record = {\n+ totalBlockingTime: { label: 'Total Blocking Time', unit: 'ms' },\n+ longTaskCount: { label: 'Long Tasks (>50ms)', unit: '' },\n+ layoutCount: { label: 'Layouts', unit: '' },\n+ recalcStyleCount: { label: 'Style Recalcs', unit: '' },\n+ avgFrameTime: { label: 'Avg Frame Time', unit: 'ms' },\n+ maxFrameGap: { label: 'Max Frame Gap', unit: 'ms' },\n+ droppedFrames: { label: 'Dropped Frames (>16.7ms)', unit: '' },\n+ p99FrameTime: { label: 'p99 Frame Time', unit: 'ms' },\n+ jsHeapDelta: { label: 'Heap Delta', unit: 'KB' },\n+};\n+\n+function formatValue(value: number, unit: string): string {\n+ const rounded = Math.round(value * 10) / 10;\n+ return unit ? `${rounded} ${unit}` : `${rounded}`;\n+}\n+\n+function generateReport(scenarios: { scenario: string; [k: string]: unknown }[]): string {\n+ const lines: string[] = [];\n+\n+ lines.push('## Performance Benchmark Results');\n+ lines.push('');\n+ lines.push(`> CPU throttling: **4x** · Samples: **5** (1 warmup iteration excluded)`);\n+ lines.push('');\n+\n+ for (const scenario of scenarios) {\n+ lines.push(`### ${scenario.scenario}`);\n+ lines.push('');\n+ lines.push('| Metric | Max | Mean | Stddev |');\n+ lines.push('|--------|-----|------|--------|');\n+\n+ for (const [key, meta] of Object.entries(METRIC_LABELS)) {\n+ const m = scenario[key] as AggregatedMetrics | undefined;\n+ if (!m) continue;\n+\n+ lines.push(\n+ `| ${meta.label} | **${formatValue(m.max, meta.unit)}** | ${formatValue(m.mean, meta.unit)} | ±${formatValue(m.stddev, meta.unit)} |`,\n+ );\n+ }\n+\n+ lines.push('');\n+ }\n+\n+ return lines.join('\\n');\n+}\n+\n+function main(): void {\n+ const args = process.argv.slice(2);\n+ const inputIdx = args.indexOf('--input');\n+ const inputPath =\n+ inputIdx !== -1 && args[inputIdx + 1]\n+ ? resolve(args[inputIdx + 1])\n+ : resolve(import.meta.dirname, 'results/latest.json');\n+\n+ const outputIdx = args.indexOf('--output');\n+ const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : undefined;\n+\n+ if (!existsSync(inputPath)) {\n+ console.error(`Results file not found: ${inputPath}`);\n+ process.exit(1);\n+ }\n+\n+ const scenarios = extractScenarios(inputPath);\n+ if (scenarios.length === 0) {\n+ console.log('No benchmark data found in results.');\n+ process.exit(0);\n+ }\n+\n+ const report = generateReport(scenarios);\n+ console.log(report);\n+\n+ if (outputPath) {\n+ appendFileSync(outputPath, report + '\\n');\n+ }\n+}\n+\n+main();\ndiff --git a/perf/results/.gitkeep b/perf/results/.gitkeep\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/perf/scenarios/drag-between-lists.perf.ts b/perf/scenarios/drag-between-lists.perf.ts\nnew file mode 100644\nindex 0000000..865c196\n--- /dev/null\n+++ b/perf/scenarios/drag-between-lists.perf.ts\n@@ -0,0 +1,97 @@\n+import { test } from '@playwright/test';\n+import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector';\n+import { PerfPage } from '../fixtures/perf.page';\n+import { aggregate } from '../fixtures/statistics';\n+\n+const ITERATIONS = 5;\n+const WARMUP_ITERATIONS = 1;\n+const ITEM_COUNT = 1000;\n+const AUTOSCROLL_HOLD_MS = 3000;\n+const CPU_THROTTLE = 4;\n+\n+test.describe('Drag Between Lists Performance', () => {\n+ test('drag from list1 to list2 with autoscroll - 1000 items', async ({ page }, testInfo) => {\n+ const perfPage = new PerfPage(page);\n+ const collector = new MetricsCollector(page);\n+ await collector.init();\n+ await collector.setCpuThrottling(CPU_THROTTLE);\n+\n+ await perfPage.goto();\n+ await perfPage.setItemCount(ITEM_COUNT);\n+\n+ const results: ScenarioMetrics[] = [];\n+ const totalRuns = WARMUP_ITERATIONS + ITERATIONS;\n+\n+ for (let i = 0; i < totalRuns; i++) {\n+ // Reload for clean drag state\n+ await perfPage.goto();\n+ await perfPage.setItemCount(ITEM_COUNT);\n+ await page.waitForTimeout(300);\n+\n+ const sourceBox = await perfPage.getItemBox('list1', 0);\n+ const list2Box = await perfPage.getContainerBox('list2');\n+\n+ if (!sourceBox || !list2Box) {\n+ throw new Error('Could not get bounding boxes');\n+ }\n+\n+ const metrics = await collector.measureScenario(async () => {\n+ // Start drag from list1 item 0\n+ await page.mouse.move(\n+ sourceBox.x + sourceBox.width / 2,\n+ sourceBox.y + sourceBox.height / 2,\n+ );\n+ await page.mouse.down();\n+ await page.mouse.move(\n+ sourceBox.x + sourceBox.width / 2 + 5,\n+ sourceBox.y + sourceBox.height / 2 + 5,\n+ { steps: 2 },\n+ );\n+\n+ const dragPreview = page.getByTestId('vdnd-drag-preview');\n+ await dragPreview.waitFor({ state: 'visible', timeout: 2000 });\n+\n+ // Move to list2 bottom edge to trigger autoscroll\n+ const nearBottomY = list2Box.y + list2Box.height - 20;\n+ const centerX = list2Box.x + list2Box.width / 2;\n+ await page.mouse.move(centerX, nearBottomY, { steps: 15 });\n+ await page.mouse.move(centerX, nearBottomY);\n+\n+ // Hold at edge to accumulate autoscroll\n+ await page.waitForTimeout(AUTOSCROLL_HOLD_MS);\n+\n+ // Release\n+ await page.evaluate(() => new Promise((r) => requestAnimationFrame(r)));\n+ await page.mouse.up();\n+ await dragPreview.waitFor({ state: 'hidden', timeout: 2000 });\n+ });\n+\n+ if (i >= WARMUP_ITERATIONS) {\n+ results.push(metrics);\n+ }\n+ }\n+\n+ const report = {\n+ scenario: 'drag-between-lists-autoscroll-1000',\n+ cpuThrottle: CPU_THROTTLE,\n+ iterations: ITERATIONS,\n+ autoscrollHoldMs: AUTOSCROLL_HOLD_MS,\n+ totalBlockingTime: aggregate(results.map((r) => r.totalBlockingTime)),\n+ longTaskCount: aggregate(results.map((r) => r.longTaskCount)),\n+ layoutCount: aggregate(results.map((r) => r.layoutCount)),\n+ recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)),\n+ avgFrameTime: aggregate(results.map((r) => r.avgFrameTime)),\n+ maxFrameGap: aggregate(results.map((r) => r.maxFrameGap)),\n+ droppedFrames: aggregate(results.map((r) => r.droppedFrames)),\n+ p99FrameTime: aggregate(results.map((r) => r.p99FrameTime)),\n+ jsHeapDelta: aggregate(results.map((r) => r.jsHeapDelta / 1024)),\n+ };\n+\n+ testInfo.attach('drag-between-lists-autoscroll-1000', {\n+ body: JSON.stringify(report, null, 2),\n+ contentType: 'application/json',\n+ });\n+\n+ await collector.dispose();\n+ });\n+});\ndiff --git a/perf/scenarios/drag-within-list.perf.ts b/perf/scenarios/drag-within-list.perf.ts\nnew file mode 100644\nindex 0000000..e5f524b\n--- /dev/null\n+++ b/perf/scenarios/drag-within-list.perf.ts\n@@ -0,0 +1,75 @@\n+import { test } from '@playwright/test';\n+import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector';\n+import { PerfPage } from '../fixtures/perf.page';\n+import { aggregate } from '../fixtures/statistics';\n+\n+const ITERATIONS = 5;\n+const WARMUP_ITERATIONS = 1;\n+const ITEM_COUNT = 1000;\n+const CPU_THROTTLE = 4;\n+\n+test.describe('Drag Within List Performance', () => {\n+ test('drag item 0 to item 7 - 1000 items', async ({ page }, testInfo) => {\n+ const perfPage = new PerfPage(page);\n+ const collector = new MetricsCollector(page);\n+ await collector.init();\n+ await collector.setCpuThrottling(CPU_THROTTLE);\n+\n+ await perfPage.goto();\n+ await perfPage.setItemCount(ITEM_COUNT);\n+\n+ const results: ScenarioMetrics[] = [];\n+ const totalRuns = WARMUP_ITERATIONS + ITERATIONS;\n+\n+ for (let i = 0; i < totalRuns; i++) {\n+ // Reload page to get a clean state for each drag iteration\n+ await perfPage.goto();\n+ await perfPage.setItemCount(ITEM_COUNT);\n+ await page.waitForTimeout(300);\n+\n+ const sourceBox = await perfPage.getItemBox('list1', 0);\n+ const targetBox = await perfPage.getItemBox('list1', 7);\n+\n+ if (!sourceBox || !targetBox) {\n+ throw new Error('Could not get bounding boxes');\n+ }\n+\n+ const metrics = await collector.measureScenario(async () => {\n+ await perfPage.simulateDrag({\n+ startX: sourceBox.x + sourceBox.width / 2,\n+ startY: sourceBox.y + sourceBox.height / 2,\n+ // Target item ~7 visible positions down (items 0-7 in viewport)\n+ endX: targetBox.x + targetBox.width / 2,\n+ endY: targetBox.y + targetBox.height / 2,\n+ steps: 20,\n+ });\n+ });\n+\n+ if (i >= WARMUP_ITERATIONS) {\n+ results.push(metrics);\n+ }\n+ }\n+\n+ const report = {\n+ scenario: 'drag-within-list-1000',\n+ cpuThrottle: CPU_THROTTLE,\n+ iterations: ITERATIONS,\n+ totalBlockingTime: aggregate(results.map((r) => r.totalBlockingTime)),\n+ longTaskCount: aggregate(results.map((r) => r.longTaskCount)),\n+ layoutCount: aggregate(results.map((r) => r.layoutCount)),\n+ recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)),\n+ avgFrameTime: aggregate(results.map((r) => r.avgFrameTime)),\n+ maxFrameGap: aggregate(results.map((r) => r.maxFrameGap)),\n+ droppedFrames: aggregate(results.map((r) => r.droppedFrames)),\n+ p99FrameTime: aggregate(results.map((r) => r.p99FrameTime)),\n+ jsHeapDelta: aggregate(results.map((r) => r.jsHeapDelta / 1024)),\n+ };\n+\n+ testInfo.attach('drag-within-list-1000', {\n+ body: JSON.stringify(report, null, 2),\n+ contentType: 'application/json',\n+ });\n+\n+ await collector.dispose();\n+ });\n+});\ndiff --git a/perf/scenarios/dynamic-height.perf.ts b/perf/scenarios/dynamic-height.perf.ts\nnew file mode 100644\nindex 0000000..3cd7a74\n--- /dev/null\n+++ b/perf/scenarios/dynamic-height.perf.ts\n@@ -0,0 +1,90 @@\n+import { test } from '@playwright/test';\n+import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector';\n+import { PerfPage } from '../fixtures/perf.page';\n+import { aggregate } from '../fixtures/statistics';\n+\n+const ITERATIONS = 5;\n+const WARMUP_ITERATIONS = 1;\n+const CPU_THROTTLE = 4;\n+\n+test.describe('Dynamic Height Scroll Performance', () => {\n+ test('scroll through dynamic height list', async ({ page }, testInfo) => {\n+ const perfPage = new PerfPage(page);\n+ const collector = new MetricsCollector(page);\n+ await collector.init();\n+ await collector.setCpuThrottling(CPU_THROTTLE);\n+\n+ // Navigate to the dynamic height demo (150 tasks by default with varying heights)\n+ await perfPage.goto('/dynamic-height');\n+\n+ const results: ScenarioMetrics[] = [];\n+ const totalRuns = WARMUP_ITERATIONS + ITERATIONS;\n+\n+ for (let i = 0; i < totalRuns; i++) {\n+ // Reset scroll to top via Ionic's IonContent scroll container\n+ await page.evaluate(() => {\n+ const scrollable = document.querySelector('[vdndScrollable]') as HTMLElement;\n+ if (scrollable) scrollable.scrollTop = 0;\n+ });\n+ await page.waitForTimeout(300);\n+\n+ const metrics = await collector.measureScenario(async () => {\n+ // Scroll to the bottom of the dynamic-height list\n+ // 150 items * ~80px estimated height = ~12000px, but heights vary\n+ await page.evaluate(() => {\n+ return new Promise((resolve) => {\n+ const scrollable = document.querySelector('[vdndScrollable]') as HTMLElement;\n+ if (!scrollable) {\n+ resolve();\n+ return;\n+ }\n+ const target = scrollable.scrollHeight;\n+ const start = scrollable.scrollTop;\n+ const delta = target - start;\n+ const duration = 2000;\n+ const startTime = performance.now();\n+ const step = () => {\n+ const elapsed = performance.now() - startTime;\n+ const progress = Math.min(elapsed / duration, 1);\n+ const eased =\n+ progress < 0.5 ? 2 * progress * progress : 1 - (-2 * progress + 2) ** 2 / 2;\n+ scrollable.scrollTop = start + delta * eased;\n+ if (progress < 1) {\n+ requestAnimationFrame(step);\n+ } else {\n+ resolve();\n+ }\n+ };\n+ requestAnimationFrame(step);\n+ });\n+ });\n+ });\n+\n+ if (i >= WARMUP_ITERATIONS) {\n+ results.push(metrics);\n+ }\n+ }\n+\n+ const report = {\n+ scenario: 'dynamic-height-scroll',\n+ cpuThrottle: CPU_THROTTLE,\n+ iterations: ITERATIONS,\n+ totalBlockingTime: aggregate(results.map((r) => r.totalBlockingTime)),\n+ longTaskCount: aggregate(results.map((r) => r.longTaskCount)),\n+ layoutCount: aggregate(results.map((r) => r.layoutCount)),\n+ recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)),\n+ avgFrameTime: aggregate(results.map((r) => r.avgFrameTime)),\n+ maxFrameGap: aggregate(results.map((r) => r.maxFrameGap)),\n+ droppedFrames: aggregate(results.map((r) => r.droppedFrames)),\n+ p99FrameTime: aggregate(results.map((r) => r.p99FrameTime)),\n+ jsHeapDelta: aggregate(results.map((r) => r.jsHeapDelta / 1024)),\n+ };\n+\n+ testInfo.attach('dynamic-height-scroll', {\n+ body: JSON.stringify(report, null, 2),\n+ contentType: 'application/json',\n+ });\n+\n+ await collector.dispose();\n+ });\n+});\ndiff --git a/perf/scenarios/scroll.perf.ts b/perf/scenarios/scroll.perf.ts\nnew file mode 100644\nindex 0000000..d7ea951\n--- /dev/null\n+++ b/perf/scenarios/scroll.perf.ts\n@@ -0,0 +1,67 @@\n+import { test } from '@playwright/test';\n+import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector';\n+import { PerfPage } from '../fixtures/perf.page';\n+import { aggregate } from '../fixtures/statistics';\n+\n+const ITERATIONS = 5;\n+const WARMUP_ITERATIONS = 1;\n+const ITEM_COUNT = 2000;\n+const SCROLL_DURATION_MS = 2000;\n+const CPU_THROTTLE = 4;\n+\n+test.describe('Scroll Performance', () => {\n+ test('large list scroll - 2000 items', async ({ page }, testInfo) => {\n+ const perfPage = new PerfPage(page);\n+ const collector = new MetricsCollector(page);\n+ await collector.init();\n+ await collector.setCpuThrottling(CPU_THROTTLE);\n+\n+ await perfPage.goto();\n+ await perfPage.setItemCount(ITEM_COUNT);\n+\n+ const results: ScenarioMetrics[] = [];\n+ const totalRuns = WARMUP_ITERATIONS + ITERATIONS;\n+\n+ for (let i = 0; i < totalRuns; i++) {\n+ await perfPage.resetScrollPositions();\n+ // Allow GC and settling between iterations\n+ await page.waitForTimeout(300);\n+\n+ const maxScroll = (ITEM_COUNT / 2) * 50 - 400; // half items * height - container\n+ const metrics = await collector.measureScenario(async () => {\n+ await perfPage.smoothScroll(\n+ '[data-droppable-id=\"list-1\"] vdnd-virtual-scroll',\n+ maxScroll,\n+ SCROLL_DURATION_MS,\n+ );\n+ });\n+\n+ // Skip warmup iterations\n+ if (i >= WARMUP_ITERATIONS) {\n+ results.push(metrics);\n+ }\n+ }\n+\n+ const report = {\n+ scenario: 'scroll-2000-items',\n+ cpuThrottle: CPU_THROTTLE,\n+ iterations: ITERATIONS,\n+ totalBlockingTime: aggregate(results.map((r) => r.totalBlockingTime)),\n+ longTaskCount: aggregate(results.map((r) => r.longTaskCount)),\n+ layoutCount: aggregate(results.map((r) => r.layoutCount)),\n+ recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)),\n+ avgFrameTime: aggregate(results.map((r) => r.avgFrameTime)),\n+ maxFrameGap: aggregate(results.map((r) => r.maxFrameGap)),\n+ droppedFrames: aggregate(results.map((r) => r.droppedFrames)),\n+ p99FrameTime: aggregate(results.map((r) => r.p99FrameTime)),\n+ jsHeapDelta: aggregate(results.map((r) => r.jsHeapDelta / 1024)),\n+ };\n+\n+ testInfo.attach('scroll-2000-items', {\n+ body: JSON.stringify(report, null, 2),\n+ contentType: 'application/json',\n+ });\n+\n+ await collector.dispose();\n+ });\n+});\ndiff --git a/tsconfig.e2e.json b/tsconfig.e2e.json\nindex 827f63d..90e9c77 100644\n--- a/tsconfig.e2e.json\n+++ b/tsconfig.e2e.json\n@@ -5,5 +5,5 @@\n \"outDir\": \"./out-tsc/e2e\",\n \"types\": [\"node\"]\n },\n- \"include\": [\"e2e/**/*.ts\"]\n+ \"include\": [\"e2e/**/*.ts\", \"perf/**/*.ts\"]\n }", "actualWorkers": 1 }, "preserveOutput": "always", @@ -30,15 +56,41 @@ "quiet": false, "projects": [ { - "outputDir": "/Users/crush/Projects/dnd/test-results", + "outputDir": "/home/runner/work/angular-vdnd/angular-vdnd/test-results", "repeatEach": 1, "retries": 0, "metadata": { + "ci": { + "commitHref": "https://github.com/gultyayev/angular-vdnd/commit/5aaa584a6ef72f8e368b87a0f69552038e5b2b98", + "commitHash": "5aaa584a6ef72f8e368b87a0f69552038e5b2b98", + "prHref": "https://github.com/gultyayev/angular-vdnd/pull/17", + "prTitle": "feat(e2e): add automated performance benchmarks with Playwright", + "prBaseHash": "d5dc0daf91e780ec7f8ff86a4dc50a36efd5f27b", + "buildHref": "https://github.com/gultyayev/angular-vdnd/actions/runs/22780714112" + }, + "gitCommit": { + "shortHash": "5aaa584", + "hash": "5aaa584a6ef72f8e368b87a0f69552038e5b2b98", + "subject": "Merge 56d582a1bad7b72182addb1634f78cd09107bf66 into d5dc0daf91e780ec7f8ff86a4dc50a36efd5f27b", + "body": "Merge 56d582a1bad7b72182addb1634f78cd09107bf66 into d5dc0daf91e780ec7f8ff86a4dc50a36efd5f27b\n", + "author": { + "name": "Sergey Gultyayev (Serhii Hultiaiev)", + "email": "gultyayev.sergey@gmail.com", + "time": 1772828872000 + }, + "committer": { + "name": "GitHub", + "email": "noreply@github.com", + "time": 1772828872000 + }, + "branch": "HEAD" + }, + "gitDiff": "diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml\nnew file mode 100644\nindex 0000000..00c6220\n--- /dev/null\n+++ b/.github/workflows/perf.yml\n@@ -0,0 +1,88 @@\n+name: Performance Benchmarks\n+\n+on:\n+ pull_request:\n+ branches: [master]\n+\n+permissions:\n+ contents: read\n+ pull-requests: write\n+\n+concurrency:\n+ group: perf-${{ github.event.pull_request.number }}\n+ cancel-in-progress: true\n+\n+jobs:\n+ benchmark:\n+ runs-on: ubuntu-latest\n+ steps:\n+ - name: Checkout\n+ uses: actions/checkout@v4\n+\n+ - name: Setup Node.js\n+ uses: actions/setup-node@v4\n+ with:\n+ node-version: '24'\n+ cache: 'npm'\n+\n+ - name: Install dependencies\n+ run: npm ci\n+\n+ - name: Build Angular Library\n+ run: npm run build:lib\n+\n+ - name: Install Playwright browsers\n+ run: npx playwright install --with-deps chromium\n+\n+ - name: Run performance benchmarks\n+ run: npm run perf\n+\n+ - name: Generate report\n+ run: npm run perf:report -- --output \"$GITHUB_STEP_SUMMARY\"\n+\n+ - name: Comment on PR\n+ if: github.event_name == 'pull_request'\n+ uses: actions/github-script@v7\n+ with:\n+ script: |\n+ const { readFileSync } = require('fs');\n+ const { execSync } = require('child_process');\n+\n+ const report = execSync('npm run --silent perf:report', { encoding: 'utf-8' });\n+\n+ // Find and update existing comment, or create a new one\n+ const marker = '';\n+ const body = `${marker}\\n${report}`;\n+\n+ const { data: comments } = await github.rest.issues.listComments({\n+ owner: context.repo.owner,\n+ repo: context.repo.repo,\n+ issue_number: context.issue.number,\n+ });\n+\n+ const existing = comments.find(c => c.body?.includes(marker));\n+\n+ if (existing) {\n+ await github.rest.issues.updateComment({\n+ owner: context.repo.owner,\n+ repo: context.repo.repo,\n+ comment_id: existing.id,\n+ body,\n+ });\n+ } else {\n+ await github.rest.issues.createComment({\n+ owner: context.repo.owner,\n+ repo: context.repo.repo,\n+ issue_number: context.issue.number,\n+ body,\n+ });\n+ }\n+\n+ # TODO: Wire up `npm run perf:compare` once a baseline artifact strategy is in place\n+ - name: Upload results\n+ if: always()\n+ uses: actions/upload-artifact@v4\n+ with:\n+ name: perf-results\n+ path: perf/results/\n+ retention-days: 30\ndiff --git a/.gitignore b/.gitignore\nindex 8e9f591..a31ed88 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -53,5 +53,6 @@ storybook-static\n /reference\n /playwright-report\n /test-results\n+/perf/results/*.json\n \n .codemie\ndiff --git a/package.json b/package.json\nindex 931d071..451b6ba 100644\n--- a/package.json\n+++ b/package.json\n@@ -13,6 +13,10 @@\n \"e2e\": \"playwright test\",\n \"e2e:ui\": \"playwright test --ui\",\n \"e2e:headed\": \"playwright test --headed\",\n+ \"perf\": \"playwright test --config perf/playwright.perf.config.ts\",\n+ \"perf:report\": \"node --experimental-strip-types perf/report.ts\",\n+ \"perf:compare\": \"node --experimental-strip-types perf/compare.ts\",\n+ \"perf:baseline\": \"npm run perf && cp perf/results/latest.json perf/baselines/baseline.json\",\n \"storybook\": \"ng run dnd:storybook\",\n \"build-storybook\": \"ng run dnd:build-storybook\",\n \"release\": \"node scripts/release.js\",\ndiff --git a/perf/baselines/baseline.json b/perf/baselines/baseline.json\nnew file mode 100644\nindex 0000000..bf1c5a0\n--- /dev/null\n+++ b/perf/baselines/baseline.json\n@@ -0,0 +1,299 @@\n+{\n+ \"config\": {\n+ \"configFile\": \"/Users/crush/Projects/dnd/perf/playwright.perf.config.ts\",\n+ \"rootDir\": \"/Users/crush/Projects/dnd/perf/scenarios\",\n+ \"forbidOnly\": false,\n+ \"fullyParallel\": false,\n+ \"globalSetup\": null,\n+ \"globalTeardown\": null,\n+ \"globalTimeout\": 0,\n+ \"grep\": {},\n+ \"grepInvert\": null,\n+ \"maxFailures\": 0,\n+ \"metadata\": {\n+ \"actualWorkers\": 1\n+ },\n+ \"preserveOutput\": \"always\",\n+ \"reporter\": [\n+ [\"list\", null],\n+ [\n+ \"json\",\n+ {\n+ \"outputFile\": \"results/latest.json\"\n+ }\n+ ]\n+ ],\n+ \"reportSlowTests\": {\n+ \"max\": 5,\n+ \"threshold\": 300000\n+ },\n+ \"quiet\": false,\n+ \"projects\": [\n+ {\n+ \"outputDir\": \"/Users/crush/Projects/dnd/test-results\",\n+ \"repeatEach\": 1,\n+ \"retries\": 0,\n+ \"metadata\": {\n+ \"actualWorkers\": 1\n+ },\n+ \"id\": \"\",\n+ \"name\": \"\",\n+ \"testDir\": \"/Users/crush/Projects/dnd/perf/scenarios\",\n+ \"testIgnore\": [],\n+ \"testMatch\": [\"**/*.perf.ts\"],\n+ \"timeout\": 120000\n+ }\n+ ],\n+ \"shard\": null,\n+ \"tags\": [],\n+ \"updateSnapshots\": \"missing\",\n+ \"updateSourceMethod\": \"patch\",\n+ \"version\": \"1.57.0\",\n+ \"workers\": 1,\n+ \"webServer\": {\n+ \"command\": \"npm start -- --host 127.0.0.1 --port 4200 -c production\",\n+ \"url\": \"http://127.0.0.1:4200\",\n+ \"reuseExistingServer\": true,\n+ \"timeout\": 120000\n+ }\n+ },\n+ \"suites\": [\n+ {\n+ \"title\": \"drag-between-lists.perf.ts\",\n+ \"file\": \"drag-between-lists.perf.ts\",\n+ \"column\": 0,\n+ \"line\": 0,\n+ \"specs\": [],\n+ \"suites\": [\n+ {\n+ \"title\": \"Drag Between Lists Performance\",\n+ \"file\": \"drag-between-lists.perf.ts\",\n+ \"line\": 10,\n+ \"column\": 6,\n+ \"specs\": [\n+ {\n+ \"title\": \"drag from list1 to list2 with autoscroll - 1000 items\",\n+ \"ok\": true,\n+ \"tags\": [],\n+ \"tests\": [\n+ {\n+ \"timeout\": 120000,\n+ \"annotations\": [],\n+ \"expectedStatus\": \"passed\",\n+ \"projectId\": \"\",\n+ \"projectName\": \"\",\n+ \"results\": [\n+ {\n+ \"workerIndex\": 0,\n+ \"parallelIndex\": 0,\n+ \"status\": \"passed\",\n+ \"duration\": 22486,\n+ \"errors\": [],\n+ \"stdout\": [],\n+ \"stderr\": [],\n+ \"retry\": 0,\n+ \"startTime\": \"2026-03-06T10:47:04.873Z\",\n+ \"annotations\": [],\n+ \"attachments\": [\n+ {\n+ \"name\": \"drag-between-lists-autoscroll-1000\",\n+ \"contentType\": \"application/json\",\n+ \"body\": \"ewogICJzY2VuYXJpbyI6ICJkcmFnLWJldHdlZW4tbGlzdHMtYXV0b3Njcm9sbC0xMDAwIiwKICAiY3B1VGhyb3R0bGUiOiA0LAogICJpdGVyYXRpb25zIjogNSwKICAiYXV0b3Njcm9sbEhvbGRNcyI6IDMwMDAsCiAgInRvdGFsQmxvY2tpbmdUaW1lIjogewogICAgIm1lYW4iOiAwLAogICAgIm1lZGlhbiI6IDAsCiAgICAicDk1IjogMCwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDAsCiAgICAibWF4IjogMCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxvbmdUYXNrQ291bnQiOiB7CiAgICAibWVhbiI6IDAsCiAgICAibWVkaWFuIjogMCwKICAgICJwOTUiOiAwLAogICAgInN0ZGRldiI6IDAsCiAgICAibWluIjogMCwKICAgICJtYXgiOiAwLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibGF5b3V0Q291bnQiOiB7CiAgICAibWVhbiI6IDcyLAogICAgIm1lZGlhbiI6IDcxLAogICAgInA5NSI6IDc0LAogICAgInN0ZGRldiI6IDEuMjY0OTExMDY0MDY3MzUxOCwKICAgICJtaW4iOiA3MSwKICAgICJtYXgiOiA3NCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInJlY2FsY1N0eWxlQ291bnQiOiB7CiAgICAibWVhbiI6IDEwNy42LAogICAgIm1lZGlhbiI6IDEwNywKICAgICJwOTUiOiAxMTAsCiAgICAic3RkZGV2IjogMS4zNTY0NjU5OTY2MjUwNTM2LAogICAgIm1pbiI6IDEwNiwKICAgICJtYXgiOiAxMTAsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJhdmdGcmFtZVRpbWUiOiB7CiAgICAibWVhbiI6IDguMTkyLAogICAgIm1lZGlhbiI6IDguMzIsCiAgICAicDk1IjogOC4zMywKICAgICJzdGRkZXYiOiAwLjE3MTc0Mzk5NTUyODIyOCwKICAgICJtaW4iOiA3LjksCiAgICAibWF4IjogOC4zMywKICAgICJzYW1wbGVzIjogNQogIH0sCiAgIm1heEZyYW1lR2FwIjogewogICAgIm1lYW4iOiAxMS45LAogICAgIm1lZGlhbiI6IDExLjksCiAgICAicDk1IjogMTMuMSwKICAgICJzdGRkZXYiOiAwLjcwNDI3MjY3NDQ2NjM2MDIsCiAgICAibWluIjogMTEuMSwKICAgICJtYXgiOiAxMy4xLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAianNIZWFwRGVsdGEiOiB7CiAgICAibWVhbiI6IC02MzcuODA1OTk5OTk5OTk5OSwKICAgICJtZWRpYW4iOiAtMTQ3Mi41NCwKICAgICJwOTUiOiAyMjMxLjA1LAogICAgInN0ZGRldiI6IDE2OTcuNzU1MjEzMjkzMTI5NiwKICAgICJtaW4iOiAtMjYwNy40MywKICAgICJtYXgiOiAyMjMxLjA1LAogICAgInNhbXBsZXMiOiA1CiAgfQp9\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"status\": \"expected\"\n+ }\n+ ],\n+ \"id\": \"627006642c7ff841ce75-d042198824336d6b100d\",\n+ \"file\": \"drag-between-lists.perf.ts\",\n+ \"line\": 11,\n+ \"column\": 3\n+ }\n+ ]\n+ }\n+ ]\n+ },\n+ {\n+ \"title\": \"drag-within-list.perf.ts\",\n+ \"file\": \"drag-within-list.perf.ts\",\n+ \"column\": 0,\n+ \"line\": 0,\n+ \"specs\": [],\n+ \"suites\": [\n+ {\n+ \"title\": \"Drag Within List Performance\",\n+ \"file\": \"drag-within-list.perf.ts\",\n+ \"line\": 9,\n+ \"column\": 6,\n+ \"specs\": [\n+ {\n+ \"title\": \"drag item 0 to item 10 - 1000 items\",\n+ \"ok\": true,\n+ \"tags\": [],\n+ \"tests\": [\n+ {\n+ \"timeout\": 120000,\n+ \"annotations\": [],\n+ \"expectedStatus\": \"passed\",\n+ \"projectId\": \"\",\n+ \"projectName\": \"\",\n+ \"results\": [\n+ {\n+ \"workerIndex\": 0,\n+ \"parallelIndex\": 0,\n+ \"status\": \"passed\",\n+ \"duration\": 5018,\n+ \"errors\": [],\n+ \"stdout\": [],\n+ \"stderr\": [],\n+ \"retry\": 0,\n+ \"startTime\": \"2026-03-06T10:47:27.602Z\",\n+ \"annotations\": [],\n+ \"attachments\": [\n+ {\n+ \"name\": \"drag-within-list-1000\",\n+ \"contentType\": \"application/json\",\n+ \"body\": \"ewogICJzY2VuYXJpbyI6ICJkcmFnLXdpdGhpbi1saXN0LTEwMDAiLAogICJjcHVUaHJvdHRsZSI6IDQsCiAgIml0ZXJhdGlvbnMiOiA1LAogICJ0b3RhbEJsb2NraW5nVGltZSI6IHsKICAgICJtZWFuIjogMCwKICAgICJtZWRpYW4iOiAwLAogICAgInA5NSI6IDAsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAwLAogICAgIm1heCI6IDAsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsb25nVGFza0NvdW50IjogewogICAgIm1lYW4iOiAwLAogICAgIm1lZGlhbiI6IDAsCiAgICAicDk1IjogMCwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDAsCiAgICAibWF4IjogMCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxheW91dENvdW50IjogewogICAgIm1lYW4iOiA1LAogICAgIm1lZGlhbiI6IDUsCiAgICAicDk1IjogNSwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDUsCiAgICAibWF4IjogNSwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInJlY2FsY1N0eWxlQ291bnQiOiB7CiAgICAibWVhbiI6IDQ3LjYsCiAgICAibWVkaWFuIjogNDgsCiAgICAicDk1IjogNDksCiAgICAic3RkZGV2IjogMS4wMTk4MDM5MDI3MTg1NTY4LAogICAgIm1pbiI6IDQ2LAogICAgIm1heCI6IDQ5LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAiYXZnRnJhbWVUaW1lIjogewogICAgIm1lYW4iOiA3Ljk2ODAwMDAwMDAwMDAwMSwKICAgICJtZWRpYW4iOiA4LjAxLAogICAgInA5NSI6IDguMDcsCiAgICAic3RkZGV2IjogMC4wOTg0NjgyNjkwMDA3Mjk2MSwKICAgICJtaW4iOiA3LjgxLAogICAgIm1heCI6IDguMDcsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJtYXhGcmFtZUdhcCI6IHsKICAgICJtZWFuIjogMTEuMDQwMDAwMDAwMDAwMDAxLAogICAgIm1lZGlhbiI6IDExLAogICAgInA5NSI6IDEyLjIsCiAgICAic3RkZGV2IjogMC42ODg3NjcwMTQzMDg5MDIyLAogICAgIm1pbiI6IDEwLjMsCiAgICAibWF4IjogMTIuMiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImpzSGVhcERlbHRhIjogewogICAgIm1lYW4iOiA0MTMuNzMyMDAwMDAwMDAwMSwKICAgICJtZWRpYW4iOiA0MTMuNjEsCiAgICAicDk1IjogNDE2LjY3LAogICAgInN0ZGRldiI6IDEuODA0ODMxMjk0MDU0OTMxNiwKICAgICJtaW4iOiA0MTAuOTgsCiAgICAibWF4IjogNDE2LjY3LAogICAgInNhbXBsZXMiOiA1CiAgfQp9\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"status\": \"expected\"\n+ }\n+ ],\n+ \"id\": \"a43c3a9bf74f7ffdb398-14fac9bab569ea34b83a\",\n+ \"file\": \"drag-within-list.perf.ts\",\n+ \"line\": 10,\n+ \"column\": 3\n+ }\n+ ]\n+ }\n+ ]\n+ },\n+ {\n+ \"title\": \"dynamic-height.perf.ts\",\n+ \"file\": \"dynamic-height.perf.ts\",\n+ \"column\": 0,\n+ \"line\": 0,\n+ \"specs\": [],\n+ \"suites\": [\n+ {\n+ \"title\": \"Dynamic Height Scroll Performance\",\n+ \"file\": \"dynamic-height.perf.ts\",\n+ \"line\": 8,\n+ \"column\": 6,\n+ \"specs\": [\n+ {\n+ \"title\": \"scroll through dynamic height list\",\n+ \"ok\": true,\n+ \"tags\": [],\n+ \"tests\": [\n+ {\n+ \"timeout\": 120000,\n+ \"annotations\": [],\n+ \"expectedStatus\": \"passed\",\n+ \"projectId\": \"\",\n+ \"projectName\": \"\",\n+ \"results\": [\n+ {\n+ \"workerIndex\": 0,\n+ \"parallelIndex\": 0,\n+ \"status\": \"passed\",\n+ \"duration\": 14210,\n+ \"errors\": [],\n+ \"stdout\": [],\n+ \"stderr\": [],\n+ \"retry\": 0,\n+ \"startTime\": \"2026-03-06T10:47:32.628Z\",\n+ \"annotations\": [],\n+ \"attachments\": [\n+ {\n+ \"name\": \"dynamic-height-scroll\",\n+ \"contentType\": \"application/json\",\n+ \"body\": \"ewogICJzY2VuYXJpbyI6ICJkeW5hbWljLWhlaWdodC1zY3JvbGwiLAogICJjcHVUaHJvdHRsZSI6IDQsCiAgIml0ZXJhdGlvbnMiOiA1LAogICJ0b3RhbEJsb2NraW5nVGltZSI6IHsKICAgICJtZWFuIjogMjgsCiAgICAibWVkaWFuIjogMjgsCiAgICAicDk1IjogMjgsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAyOCwKICAgICJtYXgiOiAyOCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxvbmdUYXNrQ291bnQiOiB7CiAgICAibWVhbiI6IDIsCiAgICAibWVkaWFuIjogMiwKICAgICJwOTUiOiAyLAogICAgInN0ZGRldiI6IDAsCiAgICAibWluIjogMiwKICAgICJtYXgiOiAyLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibGF5b3V0Q291bnQiOiB7CiAgICAibWVhbiI6IDEyOC42LAogICAgIm1lZGlhbiI6IDEyOSwKICAgICJwOTUiOiAxMzAsCiAgICAic3RkZGV2IjogMS4zNTY0NjU5OTY2MjUwNTM2LAogICAgIm1pbiI6IDEyNiwKICAgICJtYXgiOiAxMzAsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJyZWNhbGNTdHlsZUNvdW50IjogewogICAgIm1lYW4iOiAyNzMuNiwKICAgICJtZWRpYW4iOiAyNzMsCiAgICAicDk1IjogMjc2LAogICAgInN0ZGRldiI6IDEuMzU2NDY1OTk2NjI1MDUzNiwKICAgICJtaW4iOiAyNzIsCiAgICAibWF4IjogMjc2LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAiYXZnRnJhbWVUaW1lIjogewogICAgIm1lYW4iOiA3Ljg2MTk5OTk5OTk5OTk5OSwKICAgICJtZWRpYW4iOiA3Ljg4LAogICAgInA5NSI6IDcuOSwKICAgICJzdGRkZXYiOiAwLjAzNDg3MTE5MTU0ODMyNTUzNCwKICAgICJtaW4iOiA3LjgsCiAgICAibWF4IjogNy45LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibWF4RnJhbWVHYXAiOiB7CiAgICAibWVhbiI6IDExLjQ4LAogICAgIm1lZGlhbiI6IDExLAogICAgInA5NSI6IDEzLjIsCiAgICAic3RkZGV2IjogMC45NTE2MzAxODAyNjk2MjUzLAogICAgIm1pbiI6IDEwLjYsCiAgICAibWF4IjogMTMuMiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImpzSGVhcERlbHRhIjogewogICAgIm1lYW4iOiAtMjU5LjM4NCwKICAgICJtZWRpYW4iOiAyODIuMDMsCiAgICAicDk1IjogNDc1Ljg1LAogICAgInN0ZGRldiI6IDgzNi42NTkzNjQwNTY4NDI1LAogICAgIm1pbiI6IC0xNTQ2LjI0LAogICAgIm1heCI6IDQ3NS44NSwKICAgICJzYW1wbGVzIjogNQogIH0KfQ==\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"status\": \"expected\"\n+ }\n+ ],\n+ \"id\": \"53b37ba56613b41fa25c-fd0a2c300ba8b1f9765d\",\n+ \"file\": \"dynamic-height.perf.ts\",\n+ \"line\": 9,\n+ \"column\": 3\n+ }\n+ ]\n+ }\n+ ]\n+ },\n+ {\n+ \"title\": \"scroll.perf.ts\",\n+ \"file\": \"scroll.perf.ts\",\n+ \"column\": 0,\n+ \"line\": 0,\n+ \"specs\": [],\n+ \"suites\": [\n+ {\n+ \"title\": \"Scroll Performance\",\n+ \"file\": \"scroll.perf.ts\",\n+ \"line\": 10,\n+ \"column\": 6,\n+ \"specs\": [\n+ {\n+ \"title\": \"large list scroll - 2000 items\",\n+ \"ok\": true,\n+ \"tags\": [],\n+ \"tests\": [\n+ {\n+ \"timeout\": 120000,\n+ \"annotations\": [],\n+ \"expectedStatus\": \"passed\",\n+ \"projectId\": \"\",\n+ \"projectName\": \"\",\n+ \"results\": [\n+ {\n+ \"workerIndex\": 0,\n+ \"parallelIndex\": 0,\n+ \"status\": \"passed\",\n+ \"duration\": 14274,\n+ \"errors\": [],\n+ \"stdout\": [],\n+ \"stderr\": [],\n+ \"retry\": 0,\n+ \"startTime\": \"2026-03-06T10:47:46.846Z\",\n+ \"annotations\": [],\n+ \"attachments\": [\n+ {\n+ \"name\": \"scroll-2000-items\",\n+ \"contentType\": \"application/json\",\n+ \"body\": \"ewogICJzY2VuYXJpbyI6ICJzY3JvbGwtMjAwMC1pdGVtcyIsCiAgImNwdVRocm90dGxlIjogNCwKICAiaXRlcmF0aW9ucyI6IDUsCiAgInRvdGFsQmxvY2tpbmdUaW1lIjogewogICAgIm1lYW4iOiAzNywKICAgICJtZWRpYW4iOiAzNywKICAgICJwOTUiOiAzNywKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDM3LAogICAgIm1heCI6IDM3LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibG9uZ1Rhc2tDb3VudCI6IHsKICAgICJtZWFuIjogMiwKICAgICJtZWRpYW4iOiAyLAogICAgInA5NSI6IDIsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAyLAogICAgIm1heCI6IDIsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsYXlvdXRDb3VudCI6IHsKICAgICJtZWFuIjogMTIxLjIsCiAgICAibWVkaWFuIjogMTIxLAogICAgInA5NSI6IDEyMiwKICAgICJzdGRkZXYiOiAwLjc0ODMzMTQ3NzM1NDc4ODIsCiAgICAibWluIjogMTIwLAogICAgIm1heCI6IDEyMiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInJlY2FsY1N0eWxlQ291bnQiOiB7CiAgICAibWVhbiI6IDEyMS4yLAogICAgIm1lZGlhbiI6IDEyMSwKICAgICJwOTUiOiAxMjIsCiAgICAic3RkZGV2IjogMC43NDgzMzE0NzczNTQ3ODgyLAogICAgIm1pbiI6IDEyMCwKICAgICJtYXgiOiAxMjIsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJhdmdGcmFtZVRpbWUiOiB7CiAgICAibWVhbiI6IDcuOTA0MDAwMDAwMDAwMDAxLAogICAgIm1lZGlhbiI6IDcuODksCiAgICAicDk1IjogNy45OSwKICAgICJzdGRkZXYiOiAwLjA1MjAwMDAwMDAwMDAwMDA4LAogICAgIm1pbiI6IDcuODMsCiAgICAibWF4IjogNy45OSwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgIm1heEZyYW1lR2FwIjogewogICAgIm1lYW4iOiAxMy4wMiwKICAgICJtZWRpYW4iOiAxMywKICAgICJwOTUiOiAxMy41LAogICAgInN0ZGRldiI6IDAuNDc5MTY1OTQyMDI4NDM3NzYsCiAgICAibWluIjogMTIuMiwKICAgICJtYXgiOiAxMy41LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAianNIZWFwRGVsdGEiOiB7CiAgICAibWVhbiI6IC05NzcuNTMxOTk5OTk5OTk5OCwKICAgICJtZWRpYW4iOiAtNzY1LjE0LAogICAgInA5NSI6IDQzNTEuMTksCiAgICAic3RkZGV2IjogMzg0NC45OTg5MTE3MjYyNDQsCiAgICAibWluIjogLTU3NTUuODgsCiAgICAibWF4IjogNDM1MS4xOSwKICAgICJzYW1wbGVzIjogNQogIH0KfQ==\"\n+ }\n+ ]\n+ }\n+ ],\n+ \"status\": \"expected\"\n+ }\n+ ],\n+ \"id\": \"e4444aa54d21dcc13bb1-7b5cf2c5d052bafba4c6\",\n+ \"file\": \"scroll.perf.ts\",\n+ \"line\": 11,\n+ \"column\": 3\n+ }\n+ ]\n+ }\n+ ]\n+ }\n+ ],\n+ \"errors\": [],\n+ \"stats\": {\n+ \"startTime\": \"2026-03-06T10:47:00.475Z\",\n+ \"duration\": 60692.924,\n+ \"expected\": 4,\n+ \"skipped\": 0,\n+ \"unexpected\": 0,\n+ \"flaky\": 0\n+ }\n+}\ndiff --git a/perf/compare.ts b/perf/compare.ts\nnew file mode 100644\nindex 0000000..2cc8bfb\n--- /dev/null\n+++ b/perf/compare.ts\n@@ -0,0 +1,116 @@\n+import { writeFileSync, existsSync } from 'node:fs';\n+import { resolve } from 'node:path';\n+import type { AggregatedMetrics } from './fixtures/statistics.ts';\n+import { extractScenarios } from './fixtures/extract-scenarios.ts';\n+\n+const COMPARISON_METRICS = [\n+ 'totalBlockingTime',\n+ 'longTaskCount',\n+ 'layoutCount',\n+ 'recalcStyleCount',\n+ 'avgFrameTime',\n+ 'maxFrameGap',\n+ 'droppedFrames',\n+ 'p99FrameTime',\n+ 'jsHeapDelta',\n+];\n+\n+function percentChange(baseline: number, current: number): number {\n+ if (baseline === 0) return current === 0 ? 0 : 100;\n+ return ((current - baseline) / Math.abs(baseline)) * 100;\n+}\n+\n+function parseArg(args: string[], flag: string): string | undefined {\n+ const idx = args.indexOf(flag);\n+ return idx !== -1 ? args[idx + 1] : undefined;\n+}\n+\n+function main(): void {\n+ const args = process.argv.slice(2);\n+ const threshold = parseFloat(parseArg(args, '--threshold') ?? '25');\n+ const outputPath = parseArg(args, '--output');\n+\n+ const baselinePath = resolve(\n+ parseArg(args, '--baseline') ?? resolve(import.meta.dirname, 'baselines/baseline.json'),\n+ );\n+ const latestPath = resolve(\n+ parseArg(args, '--current') ?? resolve(import.meta.dirname, 'results/latest.json'),\n+ );\n+\n+ if (!existsSync(baselinePath)) {\n+ console.log('No baseline found. Run `npm run perf:baseline` to create one.');\n+ process.exit(0);\n+ }\n+\n+ if (!existsSync(latestPath)) {\n+ console.error('No latest results found. Run `npm run perf` first.');\n+ process.exit(1);\n+ }\n+\n+ const baselineArr = extractScenarios(baselinePath);\n+ const latestArr = extractScenarios(latestPath);\n+ const baseline = new Map(baselineArr.map((s) => [s.scenario, s]));\n+ const latest = new Map(latestArr.map((s) => [s.scenario, s]));\n+\n+ if (baseline.size === 0) {\n+ console.log('Baseline contains no scenario data. Re-run `npm run perf:baseline`.');\n+ process.exit(0);\n+ }\n+\n+ const lines: string[] = [];\n+ const emit = (line: string) => {\n+ console.log(line);\n+ lines.push(line);\n+ };\n+\n+ emit(`\\n## Performance Comparison (threshold: ${threshold}%)\\n`);\n+ emit('| Scenario | Metric | Baseline p95 | Current p95 | Change |');\n+ emit('|----------|--------|-------------|------------|--------|');\n+\n+ let hasRegression = false;\n+\n+ for (const [name, baselineReport] of baseline) {\n+ const currentReport = latest.get(name);\n+ if (!currentReport) {\n+ emit(`| ${name} | - | - | - | MISSING |`);\n+ continue;\n+ }\n+\n+ for (const metric of COMPARISON_METRICS) {\n+ const bMetric = baselineReport[metric] as AggregatedMetrics | undefined;\n+ const cMetric = currentReport[metric] as AggregatedMetrics | undefined;\n+\n+ if (!bMetric || !cMetric) continue;\n+\n+ const change = percentChange(bMetric.p95, cMetric.p95);\n+ const changeStr = `${change >= 0 ? '+' : ''}${change.toFixed(1)}%`;\n+ const flag = change > threshold ? ' REGRESSION' : '';\n+\n+ if (change > threshold) {\n+ hasRegression = true;\n+ }\n+\n+ emit(\n+ `| ${name} | ${metric} | ${bMetric.p95.toFixed(1)} | ${cMetric.p95.toFixed(1)} | ${changeStr}${flag} |`,\n+ );\n+ }\n+ }\n+\n+ emit('');\n+\n+ if (hasRegression) {\n+ emit(`**Performance regression detected** (>${threshold}% on p95 values).`);\n+ } else {\n+ emit('No significant regressions detected.');\n+ }\n+\n+ if (outputPath) {\n+ writeFileSync(outputPath, lines.join('\\n') + '\\n');\n+ }\n+\n+ if (hasRegression) {\n+ process.exit(1);\n+ }\n+}\n+\n+main();\ndiff --git a/perf/fixtures/extract-scenarios.ts b/perf/fixtures/extract-scenarios.ts\nnew file mode 100644\nindex 0000000..ae0b1cb\n--- /dev/null\n+++ b/perf/fixtures/extract-scenarios.ts\n@@ -0,0 +1,66 @@\n+import { readFileSync } from 'node:fs';\n+\n+interface Attachment {\n+ name: string;\n+ body?: string;\n+ contentType: string;\n+}\n+\n+interface TestResult {\n+ attachments?: Attachment[];\n+}\n+\n+interface TestCase {\n+ results?: TestResult[];\n+}\n+\n+interface Spec {\n+ tests?: TestCase[];\n+}\n+\n+interface Suite {\n+ suites?: Suite[];\n+ specs?: Spec[];\n+}\n+\n+interface ResultsFile {\n+ suites: Suite[];\n+}\n+\n+export interface ScenarioReport {\n+ scenario: string;\n+ [metric: string]: unknown;\n+}\n+\n+export function extractScenarios(filePath: string): ScenarioReport[] {\n+ const data: ResultsFile = JSON.parse(readFileSync(filePath, 'utf-8'));\n+ const scenarios: ScenarioReport[] = [];\n+\n+ function traverseSuite(suite: Suite): void {\n+ for (const child of suite.suites ?? []) {\n+ traverseSuite(child);\n+ }\n+ for (const spec of suite.specs ?? []) {\n+ for (const test of spec.tests ?? []) {\n+ for (const result of test.results ?? []) {\n+ for (const attachment of result.attachments ?? []) {\n+ if (attachment.contentType === 'application/json' && attachment.body) {\n+ const report = JSON.parse(\n+ Buffer.from(attachment.body, 'base64').toString('utf-8'),\n+ ) as ScenarioReport;\n+ if (report.scenario) {\n+ scenarios.push(report);\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+\n+ for (const suite of data.suites ?? []) {\n+ traverseSuite(suite);\n+ }\n+\n+ return scenarios;\n+}\ndiff --git a/perf/fixtures/metrics-collector.ts b/perf/fixtures/metrics-collector.ts\nnew file mode 100644\nindex 0000000..517dd19\n--- /dev/null\n+++ b/perf/fixtures/metrics-collector.ts\n@@ -0,0 +1,170 @@\n+import { CDPSession, Page } from '@playwright/test';\n+\n+export interface PerfSnapshot {\n+ timestamp: number;\n+ jsHeapUsedSize: number;\n+ layoutCount: number;\n+ recalcStyleCount: number;\n+}\n+\n+export interface LongTask {\n+ startTime: number;\n+ duration: number;\n+}\n+\n+export interface ScenarioMetrics {\n+ durationMs: number;\n+ longTaskCount: number;\n+ totalBlockingTime: number;\n+ layoutCount: number;\n+ recalcStyleCount: number;\n+ jsHeapBefore: number;\n+ jsHeapAfter: number;\n+ jsHeapDelta: number;\n+ frameCount: number;\n+ avgFrameTime: number;\n+ maxFrameGap: number;\n+ droppedFrames: number;\n+ p99FrameTime: number;\n+}\n+\n+export class MetricsCollector {\n+ #page: Page;\n+ #cdp: CDPSession | null = null;\n+\n+ constructor(page: Page) {\n+ this.#page = page;\n+ }\n+\n+ async init(): Promise {\n+ this.#cdp = await this.#page.context().newCDPSession(this.#page);\n+ await this.#cdp.send('Performance.enable');\n+ }\n+\n+ async setCpuThrottling(rate: number): Promise {\n+ await this.#cdp!.send('Emulation.setCPUThrottlingRate', { rate });\n+ }\n+\n+ async clearCpuThrottling(): Promise {\n+ await this.#cdp!.send('Emulation.setCPUThrottlingRate', { rate: 1 });\n+ }\n+\n+ /** Force garbage collection via CDP (requires --js-flags=--expose-gc). */\n+ async forceGC(): Promise {\n+ await this.#cdp!.send('Runtime.evaluate', {\n+ expression: 'typeof gc === \"function\" && gc()',\n+ awaitPromise: false,\n+ });\n+ }\n+\n+ async getSnapshot(): Promise {\n+ const { metrics } = await this.#cdp!.send('Performance.getMetrics');\n+ const get = (name: string) => metrics.find((m) => m.name === name)?.value ?? 0;\n+ return {\n+ timestamp: Date.now(),\n+ jsHeapUsedSize: get('JSHeapUsedSize'),\n+ layoutCount: get('LayoutCount'),\n+ recalcStyleCount: get('RecalcStyleCount'),\n+ };\n+ }\n+\n+ /**\n+ * Inject a PerformanceObserver for long tasks and an rAF-based frame tracker into the page.\n+ * Must be called before the scenario runs.\n+ */\n+ async injectObservers(): Promise {\n+ await this.#page.evaluate(() => {\n+ const w = window as Window & Record;\n+ w.__perfLongTasks = [];\n+ w.__perfFrames = [];\n+ w.__perfLastFrameTime = 0;\n+ w.__perfTrackingActive = true;\n+\n+ new PerformanceObserver((list) => {\n+ for (const entry of list.getEntries()) {\n+ (w.__perfLongTasks as { startTime: number; duration: number }[]).push({\n+ startTime: entry.startTime,\n+ duration: entry.duration,\n+ });\n+ }\n+ }).observe({ type: 'longtask', buffered: true });\n+\n+ const trackFrame = () => {\n+ const now = performance.now();\n+ if ((w.__perfLastFrameTime as number) > 0) {\n+ (w.__perfFrames as number[]).push(now - (w.__perfLastFrameTime as number));\n+ }\n+ w.__perfLastFrameTime = now;\n+ if (w.__perfTrackingActive) {\n+ requestAnimationFrame(trackFrame);\n+ }\n+ };\n+ requestAnimationFrame(trackFrame);\n+ });\n+ }\n+\n+ async collectObserverResults(): Promise<{ longTasks: LongTask[]; frameTimes: number[] }> {\n+ return this.#page.evaluate(() => {\n+ const w = window as Window & Record;\n+ w.__perfTrackingActive = false;\n+ return {\n+ longTasks: (w.__perfLongTasks as LongTask[]) ?? [],\n+ frameTimes: (w.__perfFrames as number[]) ?? [],\n+ };\n+ });\n+ }\n+\n+ /**\n+ * Measure a scenario: takes snapshots, injects observers, runs the scenario,\n+ * then collects all metrics.\n+ */\n+ async measureScenario(scenario: () => Promise): Promise {\n+ await this.forceGC();\n+ const before = await this.getSnapshot();\n+ await this.injectObservers();\n+ const startTime = Date.now();\n+\n+ await scenario();\n+\n+ const durationMs = Date.now() - startTime;\n+ const after = await this.getSnapshot();\n+ const { longTasks, frameTimes } = await this.collectObserverResults();\n+\n+ const totalBlockingTime = longTasks.reduce(\n+ (sum, task) => sum + Math.max(0, task.duration - 50),\n+ 0,\n+ );\n+\n+ const frameCount = frameTimes.length;\n+ const avgFrameTime = frameCount > 0 ? frameTimes.reduce((a, b) => a + b, 0) / frameCount : 0;\n+ const maxFrameGap = frameCount > 0 ? Math.max(...frameTimes) : 0;\n+ const droppedFrames = frameTimes.filter((t) => t > 16.7).length;\n+ const sortedFrames = [...frameTimes].sort((a, b) => a - b);\n+ const p99Index = sortedFrames.length > 0 ? Math.ceil(sortedFrames.length * 0.99) - 1 : 0;\n+ const p99FrameTime =\n+ sortedFrames.length > 0 ? sortedFrames[Math.min(p99Index, sortedFrames.length - 1)] : 0;\n+\n+ return {\n+ durationMs,\n+ longTaskCount: longTasks.length,\n+ totalBlockingTime,\n+ layoutCount: after.layoutCount - before.layoutCount,\n+ recalcStyleCount: after.recalcStyleCount - before.recalcStyleCount,\n+ jsHeapBefore: before.jsHeapUsedSize,\n+ jsHeapAfter: after.jsHeapUsedSize,\n+ jsHeapDelta: after.jsHeapUsedSize - before.jsHeapUsedSize,\n+ frameCount,\n+ avgFrameTime,\n+ maxFrameGap,\n+ droppedFrames,\n+ p99FrameTime,\n+ };\n+ }\n+\n+ async dispose(): Promise {\n+ await this.clearCpuThrottling();\n+ if (this.#cdp) {\n+ await this.#cdp.detach();\n+ }\n+ }\n+}\ndiff --git a/perf/fixtures/perf.page.ts b/perf/fixtures/perf.page.ts\nnew file mode 100644\nindex 0000000..d42bcbd\n--- /dev/null\n+++ b/perf/fixtures/perf.page.ts\n@@ -0,0 +1,140 @@\n+import { expect, Page } from '@playwright/test';\n+\n+export class PerfPage {\n+ readonly page: Page;\n+\n+ constructor(page: Page) {\n+ this.page = page;\n+ }\n+\n+ async goto(route = '/'): Promise {\n+ await this.page.goto(route);\n+ await this.page.waitForLoadState('networkidle');\n+ await this.page.locator('[data-draggable-id]').first().waitFor({ state: 'visible' });\n+ }\n+\n+ /**\n+ * Set the item count on the main demo page and regenerate items.\n+ * Only works on the `/` route.\n+ */\n+ async setItemCount(count: number): Promise {\n+ const input = this.page.locator('#itemCount');\n+ await input.fill(String(count));\n+ await this.page.locator('button', { hasText: 'Regenerate' }).click();\n+ // Wait for virtual scroll to render with the new item count\n+ await expect(async () => {\n+ const badge = this.page.locator('.list-badge').first();\n+ const text = await badge.textContent();\n+ expect(parseInt(text?.trim() ?? '0', 10)).toBe(Math.floor(count / 2));\n+ }).toPass({ timeout: 5000 });\n+ }\n+\n+ /**\n+ * Programmatic smooth scroll using rAF interpolation.\n+ * Scrolls the element matching `selector` from its current position to `targetScrollTop`\n+ * over `durationMs` milliseconds.\n+ */\n+ async smoothScroll(selector: string, targetScrollTop: number, durationMs: number): Promise {\n+ await this.page.evaluate(\n+ ({ selector, target, duration }) => {\n+ return new Promise((resolve) => {\n+ const el = document.querySelector(selector) as HTMLElement;\n+ if (!el) {\n+ resolve();\n+ return;\n+ }\n+ const start = el.scrollTop;\n+ const delta = target - start;\n+ const startTime = performance.now();\n+ const step = () => {\n+ const elapsed = performance.now() - startTime;\n+ const progress = Math.min(elapsed / duration, 1);\n+ // Ease-in-out for more realistic scroll behavior\n+ const eased =\n+ progress < 0.5 ? 2 * progress * progress : 1 - (-2 * progress + 2) ** 2 / 2;\n+ el.scrollTop = start + delta * eased;\n+ if (progress < 1) {\n+ requestAnimationFrame(step);\n+ } else {\n+ resolve();\n+ }\n+ };\n+ requestAnimationFrame(step);\n+ });\n+ },\n+ { selector, target: targetScrollTop, duration: durationMs },\n+ );\n+ }\n+\n+ /**\n+ * Simulate a full drag operation with stepped mouse moves.\n+ * Mirrors the E2E drag pattern from `demo.page.ts`.\n+ */\n+ async simulateDrag(opts: {\n+ startX: number;\n+ startY: number;\n+ endX: number;\n+ endY: number;\n+ steps?: number;\n+ holdDurationMs?: number;\n+ }): Promise {\n+ const { startX, startY, endX, endY, steps = 15, holdDurationMs } = opts;\n+\n+ await this.page.mouse.move(startX, startY);\n+ await this.page.mouse.down();\n+\n+ // Small initial move to pass drag threshold\n+ await this.page.mouse.move(startX + 5, startY + 5, { steps: 2 });\n+\n+ // Wait for drag preview\n+ const dragPreview = this.page.getByTestId('vdnd-drag-preview');\n+ await expect(dragPreview).toBeVisible({ timeout: 2000 });\n+\n+ // Move to target\n+ await this.page.mouse.move(endX, endY, { steps });\n+ // Firefox finalization move\n+ await this.page.mouse.move(endX, endY);\n+\n+ if (holdDurationMs) {\n+ await this.page.waitForTimeout(holdDurationMs);\n+ }\n+\n+ // Wait one rAF for position update\n+ await this.page.evaluate(() => new Promise((r) => requestAnimationFrame(r)));\n+ await this.page.mouse.up();\n+\n+ // Wait for drag to complete\n+ await expect(dragPreview).not.toBeVisible({ timeout: 2000 });\n+ }\n+\n+ /**\n+ * Get bounding box of a virtual scroll container.\n+ */\n+ async getContainerBox(list: 'list1' | 'list2') {\n+ const droppableId = list === 'list1' ? 'list-1' : 'list-2';\n+ const container = this.page.locator(`[data-droppable-id=\"${droppableId}\"] vdnd-virtual-scroll`);\n+ return container.boundingBox();\n+ }\n+\n+ /**\n+ * Get bounding box of a specific draggable item.\n+ */\n+ async getItemBox(list: 'list1' | 'list2', index: number) {\n+ const droppableId = list === 'list1' ? 'list-1' : 'list-2';\n+ const items = this.page.locator(`[data-droppable-id=\"${droppableId}\"] [data-draggable-id]`);\n+ return items.nth(index).boundingBox();\n+ }\n+\n+ /** Reset scroll position of both lists to the top. */\n+ async resetScrollPositions(): Promise {\n+ for (const id of ['list-1', 'list-2']) {\n+ await this.page.evaluate((droppableId) => {\n+ const el = document.querySelector(\n+ `[data-droppable-id=\"${droppableId}\"] vdnd-virtual-scroll`,\n+ ) as HTMLElement;\n+ if (el) el.scrollTop = 0;\n+ }, id);\n+ }\n+ await this.page.evaluate(() => new Promise((r) => requestAnimationFrame(r)));\n+ }\n+}\ndiff --git a/perf/fixtures/statistics.ts b/perf/fixtures/statistics.ts\nnew file mode 100644\nindex 0000000..8949d71\n--- /dev/null\n+++ b/perf/fixtures/statistics.ts\n@@ -0,0 +1,32 @@\n+export interface AggregatedMetrics {\n+ mean: number;\n+ median: number;\n+ p95: number;\n+ stddev: number;\n+ min: number;\n+ max: number;\n+ samples: number;\n+}\n+\n+export function aggregate(values: number[]): AggregatedMetrics {\n+ if (values.length === 0) {\n+ return { mean: 0, median: 0, p95: 0, stddev: 0, min: 0, max: 0, samples: 0 };\n+ }\n+\n+ const sorted = [...values].sort((a, b) => a - b);\n+ const n = sorted.length;\n+ const mean = sorted.reduce((a, b) => a + b, 0) / n;\n+ const median = n % 2 === 0 ? (sorted[n / 2 - 1] + sorted[n / 2]) / 2 : sorted[Math.floor(n / 2)];\n+ const p95Index = Math.ceil(n * 0.95) - 1;\n+ const p95 = sorted[Math.min(p95Index, n - 1)];\n+ const variance = sorted.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (n > 1 ? n - 1 : n);\n+ const stddev = Math.sqrt(variance);\n+\n+ return { mean, median, p95, stddev, min: sorted[0], max: sorted[n - 1], samples: n };\n+}\n+\n+/** Round a number to a fixed number of decimal places for display. */\n+export function round(value: number, decimals = 2): number {\n+ const factor = 10 ** decimals;\n+ return Math.round(value * factor) / factor;\n+}\ndiff --git a/perf/playwright.perf.config.ts b/perf/playwright.perf.config.ts\nnew file mode 100644\nindex 0000000..cb5332d\n--- /dev/null\n+++ b/perf/playwright.perf.config.ts\n@@ -0,0 +1,28 @@\n+import { defineConfig, devices } from '@playwright/test';\n+\n+export default defineConfig({\n+ testDir: './scenarios',\n+ testMatch: '**/*.perf.ts',\n+ fullyParallel: false,\n+ workers: 1,\n+ retries: 0,\n+ timeout: 120_000,\n+ reporter: [['list'], ['json', { outputFile: 'results/latest.json' }]],\n+ use: {\n+ baseURL: 'http://127.0.0.1:4200',\n+ ...devices['Desktop Chrome'],\n+ headless: true,\n+ video: 'off',\n+ screenshot: 'off',\n+ trace: 'off',\n+ launchOptions: {\n+ args: ['--js-flags=--expose-gc'],\n+ },\n+ },\n+ webServer: {\n+ command: 'npm start -- --host 127.0.0.1 --port 4200 -c production',\n+ url: 'http://127.0.0.1:4200',\n+ reuseExistingServer: !process.env.CI,\n+ timeout: 120_000,\n+ },\n+});\ndiff --git a/perf/report.ts b/perf/report.ts\nnew file mode 100644\nindex 0000000..36bf7e1\n--- /dev/null\n+++ b/perf/report.ts\n@@ -0,0 +1,82 @@\n+import { appendFileSync, existsSync } from 'node:fs';\n+import { resolve } from 'node:path';\n+import type { AggregatedMetrics } from './fixtures/statistics.ts';\n+import { extractScenarios } from './fixtures/extract-scenarios.ts';\n+\n+const METRIC_LABELS: Record = {\n+ totalBlockingTime: { label: 'Total Blocking Time', unit: 'ms' },\n+ longTaskCount: { label: 'Long Tasks (>50ms)', unit: '' },\n+ layoutCount: { label: 'Layouts', unit: '' },\n+ recalcStyleCount: { label: 'Style Recalcs', unit: '' },\n+ avgFrameTime: { label: 'Avg Frame Time', unit: 'ms' },\n+ maxFrameGap: { label: 'Max Frame Gap', unit: 'ms' },\n+ droppedFrames: { label: 'Dropped Frames (>16.7ms)', unit: '' },\n+ p99FrameTime: { label: 'p99 Frame Time', unit: 'ms' },\n+ jsHeapDelta: { label: 'Heap Delta', unit: 'KB' },\n+};\n+\n+function formatValue(value: number, unit: string): string {\n+ const rounded = Math.round(value * 10) / 10;\n+ return unit ? `${rounded} ${unit}` : `${rounded}`;\n+}\n+\n+function generateReport(scenarios: { scenario: string; [k: string]: unknown }[]): string {\n+ const lines: string[] = [];\n+\n+ lines.push('## Performance Benchmark Results');\n+ lines.push('');\n+ lines.push(`> CPU throttling: **4x** · Samples: **5** (1 warmup iteration excluded)`);\n+ lines.push('');\n+\n+ for (const scenario of scenarios) {\n+ lines.push(`### ${scenario.scenario}`);\n+ lines.push('');\n+ lines.push('| Metric | Max | Mean | Stddev |');\n+ lines.push('|--------|-----|------|--------|');\n+\n+ for (const [key, meta] of Object.entries(METRIC_LABELS)) {\n+ const m = scenario[key] as AggregatedMetrics | undefined;\n+ if (!m) continue;\n+\n+ lines.push(\n+ `| ${meta.label} | **${formatValue(m.max, meta.unit)}** | ${formatValue(m.mean, meta.unit)} | ±${formatValue(m.stddev, meta.unit)} |`,\n+ );\n+ }\n+\n+ lines.push('');\n+ }\n+\n+ return lines.join('\\n');\n+}\n+\n+function main(): void {\n+ const args = process.argv.slice(2);\n+ const inputIdx = args.indexOf('--input');\n+ const inputPath =\n+ inputIdx !== -1 && args[inputIdx + 1]\n+ ? resolve(args[inputIdx + 1])\n+ : resolve(import.meta.dirname, 'results/latest.json');\n+\n+ const outputIdx = args.indexOf('--output');\n+ const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : undefined;\n+\n+ if (!existsSync(inputPath)) {\n+ console.error(`Results file not found: ${inputPath}`);\n+ process.exit(1);\n+ }\n+\n+ const scenarios = extractScenarios(inputPath);\n+ if (scenarios.length === 0) {\n+ console.log('No benchmark data found in results.');\n+ process.exit(0);\n+ }\n+\n+ const report = generateReport(scenarios);\n+ console.log(report);\n+\n+ if (outputPath) {\n+ appendFileSync(outputPath, report + '\\n');\n+ }\n+}\n+\n+main();\ndiff --git a/perf/results/.gitkeep b/perf/results/.gitkeep\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/perf/scenarios/drag-between-lists.perf.ts b/perf/scenarios/drag-between-lists.perf.ts\nnew file mode 100644\nindex 0000000..865c196\n--- /dev/null\n+++ b/perf/scenarios/drag-between-lists.perf.ts\n@@ -0,0 +1,97 @@\n+import { test } from '@playwright/test';\n+import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector';\n+import { PerfPage } from '../fixtures/perf.page';\n+import { aggregate } from '../fixtures/statistics';\n+\n+const ITERATIONS = 5;\n+const WARMUP_ITERATIONS = 1;\n+const ITEM_COUNT = 1000;\n+const AUTOSCROLL_HOLD_MS = 3000;\n+const CPU_THROTTLE = 4;\n+\n+test.describe('Drag Between Lists Performance', () => {\n+ test('drag from list1 to list2 with autoscroll - 1000 items', async ({ page }, testInfo) => {\n+ const perfPage = new PerfPage(page);\n+ const collector = new MetricsCollector(page);\n+ await collector.init();\n+ await collector.setCpuThrottling(CPU_THROTTLE);\n+\n+ await perfPage.goto();\n+ await perfPage.setItemCount(ITEM_COUNT);\n+\n+ const results: ScenarioMetrics[] = [];\n+ const totalRuns = WARMUP_ITERATIONS + ITERATIONS;\n+\n+ for (let i = 0; i < totalRuns; i++) {\n+ // Reload for clean drag state\n+ await perfPage.goto();\n+ await perfPage.setItemCount(ITEM_COUNT);\n+ await page.waitForTimeout(300);\n+\n+ const sourceBox = await perfPage.getItemBox('list1', 0);\n+ const list2Box = await perfPage.getContainerBox('list2');\n+\n+ if (!sourceBox || !list2Box) {\n+ throw new Error('Could not get bounding boxes');\n+ }\n+\n+ const metrics = await collector.measureScenario(async () => {\n+ // Start drag from list1 item 0\n+ await page.mouse.move(\n+ sourceBox.x + sourceBox.width / 2,\n+ sourceBox.y + sourceBox.height / 2,\n+ );\n+ await page.mouse.down();\n+ await page.mouse.move(\n+ sourceBox.x + sourceBox.width / 2 + 5,\n+ sourceBox.y + sourceBox.height / 2 + 5,\n+ { steps: 2 },\n+ );\n+\n+ const dragPreview = page.getByTestId('vdnd-drag-preview');\n+ await dragPreview.waitFor({ state: 'visible', timeout: 2000 });\n+\n+ // Move to list2 bottom edge to trigger autoscroll\n+ const nearBottomY = list2Box.y + list2Box.height - 20;\n+ const centerX = list2Box.x + list2Box.width / 2;\n+ await page.mouse.move(centerX, nearBottomY, { steps: 15 });\n+ await page.mouse.move(centerX, nearBottomY);\n+\n+ // Hold at edge to accumulate autoscroll\n+ await page.waitForTimeout(AUTOSCROLL_HOLD_MS);\n+\n+ // Release\n+ await page.evaluate(() => new Promise((r) => requestAnimationFrame(r)));\n+ await page.mouse.up();\n+ await dragPreview.waitFor({ state: 'hidden', timeout: 2000 });\n+ });\n+\n+ if (i >= WARMUP_ITERATIONS) {\n+ results.push(metrics);\n+ }\n+ }\n+\n+ const report = {\n+ scenario: 'drag-between-lists-autoscroll-1000',\n+ cpuThrottle: CPU_THROTTLE,\n+ iterations: ITERATIONS,\n+ autoscrollHoldMs: AUTOSCROLL_HOLD_MS,\n+ totalBlockingTime: aggregate(results.map((r) => r.totalBlockingTime)),\n+ longTaskCount: aggregate(results.map((r) => r.longTaskCount)),\n+ layoutCount: aggregate(results.map((r) => r.layoutCount)),\n+ recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)),\n+ avgFrameTime: aggregate(results.map((r) => r.avgFrameTime)),\n+ maxFrameGap: aggregate(results.map((r) => r.maxFrameGap)),\n+ droppedFrames: aggregate(results.map((r) => r.droppedFrames)),\n+ p99FrameTime: aggregate(results.map((r) => r.p99FrameTime)),\n+ jsHeapDelta: aggregate(results.map((r) => r.jsHeapDelta / 1024)),\n+ };\n+\n+ testInfo.attach('drag-between-lists-autoscroll-1000', {\n+ body: JSON.stringify(report, null, 2),\n+ contentType: 'application/json',\n+ });\n+\n+ await collector.dispose();\n+ });\n+});\ndiff --git a/perf/scenarios/drag-within-list.perf.ts b/perf/scenarios/drag-within-list.perf.ts\nnew file mode 100644\nindex 0000000..e5f524b\n--- /dev/null\n+++ b/perf/scenarios/drag-within-list.perf.ts\n@@ -0,0 +1,75 @@\n+import { test } from '@playwright/test';\n+import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector';\n+import { PerfPage } from '../fixtures/perf.page';\n+import { aggregate } from '../fixtures/statistics';\n+\n+const ITERATIONS = 5;\n+const WARMUP_ITERATIONS = 1;\n+const ITEM_COUNT = 1000;\n+const CPU_THROTTLE = 4;\n+\n+test.describe('Drag Within List Performance', () => {\n+ test('drag item 0 to item 7 - 1000 items', async ({ page }, testInfo) => {\n+ const perfPage = new PerfPage(page);\n+ const collector = new MetricsCollector(page);\n+ await collector.init();\n+ await collector.setCpuThrottling(CPU_THROTTLE);\n+\n+ await perfPage.goto();\n+ await perfPage.setItemCount(ITEM_COUNT);\n+\n+ const results: ScenarioMetrics[] = [];\n+ const totalRuns = WARMUP_ITERATIONS + ITERATIONS;\n+\n+ for (let i = 0; i < totalRuns; i++) {\n+ // Reload page to get a clean state for each drag iteration\n+ await perfPage.goto();\n+ await perfPage.setItemCount(ITEM_COUNT);\n+ await page.waitForTimeout(300);\n+\n+ const sourceBox = await perfPage.getItemBox('list1', 0);\n+ const targetBox = await perfPage.getItemBox('list1', 7);\n+\n+ if (!sourceBox || !targetBox) {\n+ throw new Error('Could not get bounding boxes');\n+ }\n+\n+ const metrics = await collector.measureScenario(async () => {\n+ await perfPage.simulateDrag({\n+ startX: sourceBox.x + sourceBox.width / 2,\n+ startY: sourceBox.y + sourceBox.height / 2,\n+ // Target item ~7 visible positions down (items 0-7 in viewport)\n+ endX: targetBox.x + targetBox.width / 2,\n+ endY: targetBox.y + targetBox.height / 2,\n+ steps: 20,\n+ });\n+ });\n+\n+ if (i >= WARMUP_ITERATIONS) {\n+ results.push(metrics);\n+ }\n+ }\n+\n+ const report = {\n+ scenario: 'drag-within-list-1000',\n+ cpuThrottle: CPU_THROTTLE,\n+ iterations: ITERATIONS,\n+ totalBlockingTime: aggregate(results.map((r) => r.totalBlockingTime)),\n+ longTaskCount: aggregate(results.map((r) => r.longTaskCount)),\n+ layoutCount: aggregate(results.map((r) => r.layoutCount)),\n+ recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)),\n+ avgFrameTime: aggregate(results.map((r) => r.avgFrameTime)),\n+ maxFrameGap: aggregate(results.map((r) => r.maxFrameGap)),\n+ droppedFrames: aggregate(results.map((r) => r.droppedFrames)),\n+ p99FrameTime: aggregate(results.map((r) => r.p99FrameTime)),\n+ jsHeapDelta: aggregate(results.map((r) => r.jsHeapDelta / 1024)),\n+ };\n+\n+ testInfo.attach('drag-within-list-1000', {\n+ body: JSON.stringify(report, null, 2),\n+ contentType: 'application/json',\n+ });\n+\n+ await collector.dispose();\n+ });\n+});\ndiff --git a/perf/scenarios/dynamic-height.perf.ts b/perf/scenarios/dynamic-height.perf.ts\nnew file mode 100644\nindex 0000000..3cd7a74\n--- /dev/null\n+++ b/perf/scenarios/dynamic-height.perf.ts\n@@ -0,0 +1,90 @@\n+import { test } from '@playwright/test';\n+import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector';\n+import { PerfPage } from '../fixtures/perf.page';\n+import { aggregate } from '../fixtures/statistics';\n+\n+const ITERATIONS = 5;\n+const WARMUP_ITERATIONS = 1;\n+const CPU_THROTTLE = 4;\n+\n+test.describe('Dynamic Height Scroll Performance', () => {\n+ test('scroll through dynamic height list', async ({ page }, testInfo) => {\n+ const perfPage = new PerfPage(page);\n+ const collector = new MetricsCollector(page);\n+ await collector.init();\n+ await collector.setCpuThrottling(CPU_THROTTLE);\n+\n+ // Navigate to the dynamic height demo (150 tasks by default with varying heights)\n+ await perfPage.goto('/dynamic-height');\n+\n+ const results: ScenarioMetrics[] = [];\n+ const totalRuns = WARMUP_ITERATIONS + ITERATIONS;\n+\n+ for (let i = 0; i < totalRuns; i++) {\n+ // Reset scroll to top via Ionic's IonContent scroll container\n+ await page.evaluate(() => {\n+ const scrollable = document.querySelector('[vdndScrollable]') as HTMLElement;\n+ if (scrollable) scrollable.scrollTop = 0;\n+ });\n+ await page.waitForTimeout(300);\n+\n+ const metrics = await collector.measureScenario(async () => {\n+ // Scroll to the bottom of the dynamic-height list\n+ // 150 items * ~80px estimated height = ~12000px, but heights vary\n+ await page.evaluate(() => {\n+ return new Promise((resolve) => {\n+ const scrollable = document.querySelector('[vdndScrollable]') as HTMLElement;\n+ if (!scrollable) {\n+ resolve();\n+ return;\n+ }\n+ const target = scrollable.scrollHeight;\n+ const start = scrollable.scrollTop;\n+ const delta = target - start;\n+ const duration = 2000;\n+ const startTime = performance.now();\n+ const step = () => {\n+ const elapsed = performance.now() - startTime;\n+ const progress = Math.min(elapsed / duration, 1);\n+ const eased =\n+ progress < 0.5 ? 2 * progress * progress : 1 - (-2 * progress + 2) ** 2 / 2;\n+ scrollable.scrollTop = start + delta * eased;\n+ if (progress < 1) {\n+ requestAnimationFrame(step);\n+ } else {\n+ resolve();\n+ }\n+ };\n+ requestAnimationFrame(step);\n+ });\n+ });\n+ });\n+\n+ if (i >= WARMUP_ITERATIONS) {\n+ results.push(metrics);\n+ }\n+ }\n+\n+ const report = {\n+ scenario: 'dynamic-height-scroll',\n+ cpuThrottle: CPU_THROTTLE,\n+ iterations: ITERATIONS,\n+ totalBlockingTime: aggregate(results.map((r) => r.totalBlockingTime)),\n+ longTaskCount: aggregate(results.map((r) => r.longTaskCount)),\n+ layoutCount: aggregate(results.map((r) => r.layoutCount)),\n+ recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)),\n+ avgFrameTime: aggregate(results.map((r) => r.avgFrameTime)),\n+ maxFrameGap: aggregate(results.map((r) => r.maxFrameGap)),\n+ droppedFrames: aggregate(results.map((r) => r.droppedFrames)),\n+ p99FrameTime: aggregate(results.map((r) => r.p99FrameTime)),\n+ jsHeapDelta: aggregate(results.map((r) => r.jsHeapDelta / 1024)),\n+ };\n+\n+ testInfo.attach('dynamic-height-scroll', {\n+ body: JSON.stringify(report, null, 2),\n+ contentType: 'application/json',\n+ });\n+\n+ await collector.dispose();\n+ });\n+});\ndiff --git a/perf/scenarios/scroll.perf.ts b/perf/scenarios/scroll.perf.ts\nnew file mode 100644\nindex 0000000..d7ea951\n--- /dev/null\n+++ b/perf/scenarios/scroll.perf.ts\n@@ -0,0 +1,67 @@\n+import { test } from '@playwright/test';\n+import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector';\n+import { PerfPage } from '../fixtures/perf.page';\n+import { aggregate } from '../fixtures/statistics';\n+\n+const ITERATIONS = 5;\n+const WARMUP_ITERATIONS = 1;\n+const ITEM_COUNT = 2000;\n+const SCROLL_DURATION_MS = 2000;\n+const CPU_THROTTLE = 4;\n+\n+test.describe('Scroll Performance', () => {\n+ test('large list scroll - 2000 items', async ({ page }, testInfo) => {\n+ const perfPage = new PerfPage(page);\n+ const collector = new MetricsCollector(page);\n+ await collector.init();\n+ await collector.setCpuThrottling(CPU_THROTTLE);\n+\n+ await perfPage.goto();\n+ await perfPage.setItemCount(ITEM_COUNT);\n+\n+ const results: ScenarioMetrics[] = [];\n+ const totalRuns = WARMUP_ITERATIONS + ITERATIONS;\n+\n+ for (let i = 0; i < totalRuns; i++) {\n+ await perfPage.resetScrollPositions();\n+ // Allow GC and settling between iterations\n+ await page.waitForTimeout(300);\n+\n+ const maxScroll = (ITEM_COUNT / 2) * 50 - 400; // half items * height - container\n+ const metrics = await collector.measureScenario(async () => {\n+ await perfPage.smoothScroll(\n+ '[data-droppable-id=\"list-1\"] vdnd-virtual-scroll',\n+ maxScroll,\n+ SCROLL_DURATION_MS,\n+ );\n+ });\n+\n+ // Skip warmup iterations\n+ if (i >= WARMUP_ITERATIONS) {\n+ results.push(metrics);\n+ }\n+ }\n+\n+ const report = {\n+ scenario: 'scroll-2000-items',\n+ cpuThrottle: CPU_THROTTLE,\n+ iterations: ITERATIONS,\n+ totalBlockingTime: aggregate(results.map((r) => r.totalBlockingTime)),\n+ longTaskCount: aggregate(results.map((r) => r.longTaskCount)),\n+ layoutCount: aggregate(results.map((r) => r.layoutCount)),\n+ recalcStyleCount: aggregate(results.map((r) => r.recalcStyleCount)),\n+ avgFrameTime: aggregate(results.map((r) => r.avgFrameTime)),\n+ maxFrameGap: aggregate(results.map((r) => r.maxFrameGap)),\n+ droppedFrames: aggregate(results.map((r) => r.droppedFrames)),\n+ p99FrameTime: aggregate(results.map((r) => r.p99FrameTime)),\n+ jsHeapDelta: aggregate(results.map((r) => r.jsHeapDelta / 1024)),\n+ };\n+\n+ testInfo.attach('scroll-2000-items', {\n+ body: JSON.stringify(report, null, 2),\n+ contentType: 'application/json',\n+ });\n+\n+ await collector.dispose();\n+ });\n+});\ndiff --git a/tsconfig.e2e.json b/tsconfig.e2e.json\nindex 827f63d..90e9c77 100644\n--- a/tsconfig.e2e.json\n+++ b/tsconfig.e2e.json\n@@ -5,5 +5,5 @@\n \"outDir\": \"./out-tsc/e2e\",\n \"types\": [\"node\"]\n },\n- \"include\": [\"e2e/**/*.ts\"]\n+ \"include\": [\"e2e/**/*.ts\", \"perf/**/*.ts\"]\n }", "actualWorkers": 1 }, "id": "", "name": "", - "testDir": "/Users/crush/Projects/dnd/perf/scenarios", + "testDir": "/home/runner/work/angular-vdnd/angular-vdnd/perf/scenarios", "testIgnore": [], "testMatch": ["**/*.perf.ts"], "timeout": 120000 @@ -53,7 +105,7 @@ "webServer": { "command": "npm start -- --host 127.0.0.1 --port 4200 -c production", "url": "http://127.0.0.1:4200", - "reuseExistingServer": true, + "reuseExistingServer": false, "timeout": 120000 } }, @@ -87,18 +139,18 @@ "workerIndex": 0, "parallelIndex": 0, "status": "passed", - "duration": 22486, + "duration": 28097, "errors": [], "stdout": [], "stderr": [], "retry": 0, - "startTime": "2026-03-06T10:47:04.873Z", + "startTime": "2026-03-06T20:29:07.726Z", "annotations": [], "attachments": [ { "name": "drag-between-lists-autoscroll-1000", "contentType": "application/json", - "body": "ewogICJzY2VuYXJpbyI6ICJkcmFnLWJldHdlZW4tbGlzdHMtYXV0b3Njcm9sbC0xMDAwIiwKICAiY3B1VGhyb3R0bGUiOiA0LAogICJpdGVyYXRpb25zIjogNSwKICAiYXV0b3Njcm9sbEhvbGRNcyI6IDMwMDAsCiAgInRvdGFsQmxvY2tpbmdUaW1lIjogewogICAgIm1lYW4iOiAwLAogICAgIm1lZGlhbiI6IDAsCiAgICAicDk1IjogMCwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDAsCiAgICAibWF4IjogMCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxvbmdUYXNrQ291bnQiOiB7CiAgICAibWVhbiI6IDAsCiAgICAibWVkaWFuIjogMCwKICAgICJwOTUiOiAwLAogICAgInN0ZGRldiI6IDAsCiAgICAibWluIjogMCwKICAgICJtYXgiOiAwLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibGF5b3V0Q291bnQiOiB7CiAgICAibWVhbiI6IDcyLAogICAgIm1lZGlhbiI6IDcxLAogICAgInA5NSI6IDc0LAogICAgInN0ZGRldiI6IDEuMjY0OTExMDY0MDY3MzUxOCwKICAgICJtaW4iOiA3MSwKICAgICJtYXgiOiA3NCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInJlY2FsY1N0eWxlQ291bnQiOiB7CiAgICAibWVhbiI6IDEwNy42LAogICAgIm1lZGlhbiI6IDEwNywKICAgICJwOTUiOiAxMTAsCiAgICAic3RkZGV2IjogMS4zNTY0NjU5OTY2MjUwNTM2LAogICAgIm1pbiI6IDEwNiwKICAgICJtYXgiOiAxMTAsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJhdmdGcmFtZVRpbWUiOiB7CiAgICAibWVhbiI6IDguMTkyLAogICAgIm1lZGlhbiI6IDguMzIsCiAgICAicDk1IjogOC4zMywKICAgICJzdGRkZXYiOiAwLjE3MTc0Mzk5NTUyODIyOCwKICAgICJtaW4iOiA3LjksCiAgICAibWF4IjogOC4zMywKICAgICJzYW1wbGVzIjogNQogIH0sCiAgIm1heEZyYW1lR2FwIjogewogICAgIm1lYW4iOiAxMS45LAogICAgIm1lZGlhbiI6IDExLjksCiAgICAicDk1IjogMTMuMSwKICAgICJzdGRkZXYiOiAwLjcwNDI3MjY3NDQ2NjM2MDIsCiAgICAibWluIjogMTEuMSwKICAgICJtYXgiOiAxMy4xLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAianNIZWFwRGVsdGEiOiB7CiAgICAibWVhbiI6IC02MzcuODA1OTk5OTk5OTk5OSwKICAgICJtZWRpYW4iOiAtMTQ3Mi41NCwKICAgICJwOTUiOiAyMjMxLjA1LAogICAgInN0ZGRldiI6IDE2OTcuNzU1MjEzMjkzMTI5NiwKICAgICJtaW4iOiAtMjYwNy40MywKICAgICJtYXgiOiAyMjMxLjA1LAogICAgInNhbXBsZXMiOiA1CiAgfQp9" + "body": "ewogICJzY2VuYXJpbyI6ICJkcmFnLWJldHdlZW4tbGlzdHMtYXV0b3Njcm9sbC0xMDAwIiwKICAiY3B1VGhyb3R0bGUiOiA0LAogICJpdGVyYXRpb25zIjogNSwKICAiYXV0b3Njcm9sbEhvbGRNcyI6IDMwMDAsCiAgInRvdGFsQmxvY2tpbmdUaW1lIjogewogICAgIm1lYW4iOiA1MS4yLAogICAgIm1lZGlhbiI6IDQ3LAogICAgInA5NSI6IDYxLAogICAgInN0ZGRldiI6IDkuMTIxNDAzNDAwNzkzMTA0LAogICAgIm1pbiI6IDQyLAogICAgIm1heCI6IDYxLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibG9uZ1Rhc2tDb3VudCI6IHsKICAgICJtZWFuIjogMiwKICAgICJtZWRpYW4iOiAyLAogICAgInA5NSI6IDIsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAyLAogICAgIm1heCI6IDIsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsYXlvdXRDb3VudCI6IHsKICAgICJtZWFuIjogMzgsCiAgICAibWVkaWFuIjogMzgsCiAgICAicDk1IjogMzgsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAzOCwKICAgICJtYXgiOiAzOCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInJlY2FsY1N0eWxlQ291bnQiOiB7CiAgICAibWVhbiI6IDY5LjgsCiAgICAibWVkaWFuIjogNjksCiAgICAicDk1IjogNzEsCiAgICAic3RkZGV2IjogMS4wOTU0NDUxMTUwMTAzMzIxLAogICAgIm1pbiI6IDY5LAogICAgIm1heCI6IDcxLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAiYXZnRnJhbWVUaW1lIjogewogICAgIm1lYW4iOiAxNi43MjcxMjY0MzY3ODE2MDcsCiAgICAibWVkaWFuIjogMTYuNzM3OTMxMDM0NDgyODQ1LAogICAgInA5NSI6IDE2Ljc1NzM1Mjk0MTE3NjQ3LAogICAgInN0ZGRldiI6IDAuMDI1ODE5Njk3NDMxMTkzODIsCiAgICAibWluIjogMTYuNjkyNjEwODM3NDM4NDUsCiAgICAibWF4IjogMTYuNzU3MzUyOTQxMTc2NDcsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJtYXhGcmFtZUdhcCI6IHsKICAgICJtZWFuIjogMjkuMDYwMDAwMDAwMDAzNDksCiAgICAibWVkaWFuIjogMjguOTAwMDAwMDAwMDIzMjgzLAogICAgInA5NSI6IDI5LjgwMDAwMDAwMDAxNzQ2MiwKICAgICJzdGRkZXYiOiAwLjYxODg2OTkzNzg4MDc4OTksCiAgICAibWluIjogMjguMTk5OTk5OTk5OTgyNTM4LAogICAgIm1heCI6IDI5LjgwMDAwMDAwMDAxNzQ2MiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImRyb3BwZWRGcmFtZXMiOiB7CiAgICAibWVhbiI6IDEwMC44LAogICAgIm1lZGlhbiI6IDk5LAogICAgInA5NSI6IDEwNywKICAgICJzdGRkZXYiOiA0LjQ5NDQ0MTAxMDg0ODg0NiwKICAgICJtaW4iOiA5NywKICAgICJtYXgiOiAxMDcsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJwOTlGcmFtZVRpbWUiOiB7CiAgICAibWVhbiI6IDIxLjIwMDAwMDAwMDAwNTgyMiwKICAgICJtZWRpYW4iOiAyMS4yMDAwMDAwMDAwMTE2NCwKICAgICJwOTUiOiAyMi4yOTk5OTk5OTk5ODgzNiwKICAgICJzdGRkZXYiOiAxLjA3OTM1MTY1NzI0MDIxMywKICAgICJtaW4iOiAxOS44MDAwMDAwMDAwMTc0NjIsCiAgICAibWF4IjogMjIuMjk5OTk5OTk5OTg4MzYsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJqc0hlYXBEZWx0YSI6IHsKICAgICJtZWFuIjogMTA2Mi4yMzIwMzEyNSwKICAgICJtZWRpYW4iOiAxMjk5LjUxOTUzMTI1LAogICAgInA5NSI6IDEzMDIuNzg1MTU2MjUsCiAgICAic3RkZGV2IjogMzI2LjY4Mzk0ODA4NDkwNDEsCiAgICAibWluIjogNzAxLjE2NDA2MjUsCiAgICAibWF4IjogMTMwMi43ODUxNTYyNSwKICAgICJzYW1wbGVzIjogNQogIH0KfQ==" } ] } @@ -129,7 +181,7 @@ "column": 6, "specs": [ { - "title": "drag item 0 to item 10 - 1000 items", + "title": "drag item 0 to item 7 - 1000 items", "ok": true, "tags": [], "tests": [ @@ -144,18 +196,18 @@ "workerIndex": 0, "parallelIndex": 0, "status": "passed", - "duration": 5018, + "duration": 10877, "errors": [], "stdout": [], "stderr": [], "retry": 0, - "startTime": "2026-03-06T10:47:27.602Z", + "startTime": "2026-03-06T20:29:36.672Z", "annotations": [], "attachments": [ { "name": "drag-within-list-1000", "contentType": "application/json", - "body": "ewogICJzY2VuYXJpbyI6ICJkcmFnLXdpdGhpbi1saXN0LTEwMDAiLAogICJjcHVUaHJvdHRsZSI6IDQsCiAgIml0ZXJhdGlvbnMiOiA1LAogICJ0b3RhbEJsb2NraW5nVGltZSI6IHsKICAgICJtZWFuIjogMCwKICAgICJtZWRpYW4iOiAwLAogICAgInA5NSI6IDAsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAwLAogICAgIm1heCI6IDAsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsb25nVGFza0NvdW50IjogewogICAgIm1lYW4iOiAwLAogICAgIm1lZGlhbiI6IDAsCiAgICAicDk1IjogMCwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDAsCiAgICAibWF4IjogMCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxheW91dENvdW50IjogewogICAgIm1lYW4iOiA1LAogICAgIm1lZGlhbiI6IDUsCiAgICAicDk1IjogNSwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDUsCiAgICAibWF4IjogNSwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInJlY2FsY1N0eWxlQ291bnQiOiB7CiAgICAibWVhbiI6IDQ3LjYsCiAgICAibWVkaWFuIjogNDgsCiAgICAicDk1IjogNDksCiAgICAic3RkZGV2IjogMS4wMTk4MDM5MDI3MTg1NTY4LAogICAgIm1pbiI6IDQ2LAogICAgIm1heCI6IDQ5LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAiYXZnRnJhbWVUaW1lIjogewogICAgIm1lYW4iOiA3Ljk2ODAwMDAwMDAwMDAwMSwKICAgICJtZWRpYW4iOiA4LjAxLAogICAgInA5NSI6IDguMDcsCiAgICAic3RkZGV2IjogMC4wOTg0NjgyNjkwMDA3Mjk2MSwKICAgICJtaW4iOiA3LjgxLAogICAgIm1heCI6IDguMDcsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJtYXhGcmFtZUdhcCI6IHsKICAgICJtZWFuIjogMTEuMDQwMDAwMDAwMDAwMDAxLAogICAgIm1lZGlhbiI6IDExLAogICAgInA5NSI6IDEyLjIsCiAgICAic3RkZGV2IjogMC42ODg3NjcwMTQzMDg5MDIyLAogICAgIm1pbiI6IDEwLjMsCiAgICAibWF4IjogMTIuMiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImpzSGVhcERlbHRhIjogewogICAgIm1lYW4iOiA0MTMuNzMyMDAwMDAwMDAwMSwKICAgICJtZWRpYW4iOiA0MTMuNjEsCiAgICAicDk1IjogNDE2LjY3LAogICAgInN0ZGRldiI6IDEuODA0ODMxMjk0MDU0OTMxNiwKICAgICJtaW4iOiA0MTAuOTgsCiAgICAibWF4IjogNDE2LjY3LAogICAgInNhbXBsZXMiOiA1CiAgfQp9" + "body": "ewogICJzY2VuYXJpbyI6ICJkcmFnLXdpdGhpbi1saXN0LTEwMDAiLAogICJjcHVUaHJvdHRsZSI6IDQsCiAgIml0ZXJhdGlvbnMiOiA1LAogICJ0b3RhbEJsb2NraW5nVGltZSI6IHsKICAgICJtZWFuIjogMzcuMiwKICAgICJtZWRpYW4iOiAzNiwKICAgICJwOTUiOiA0NiwKICAgICJzdGRkZXYiOiA1LjQwMzcwMjQzNDQ0MjUxOCwKICAgICJtaW4iOiAzMiwKICAgICJtYXgiOiA0NiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxvbmdUYXNrQ291bnQiOiB7CiAgICAibWVhbiI6IDEuOCwKICAgICJtZWRpYW4iOiAyLAogICAgInA5NSI6IDIsCiAgICAic3RkZGV2IjogMC40NDcyMTM1OTU0OTk5NTgwNCwKICAgICJtaW4iOiAxLAogICAgIm1heCI6IDIsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsYXlvdXRDb3VudCI6IHsKICAgICJtZWFuIjogNSwKICAgICJtZWRpYW4iOiA1LAogICAgInA5NSI6IDUsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiA1LAogICAgIm1heCI6IDUsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJyZWNhbGNTdHlsZUNvdW50IjogewogICAgIm1lYW4iOiA0NS4yLAogICAgIm1lZGlhbiI6IDQ2LAogICAgInA5NSI6IDQ2LAogICAgInN0ZGRldiI6IDEuMDk1NDQ1MTE1MDEwMzMyMSwKICAgICJtaW4iOiA0NCwKICAgICJtYXgiOiA0NiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImF2Z0ZyYW1lVGltZSI6IHsKICAgICJtZWFuIjogMTYuODU3ODQ2MTA4MTQwMjIyLAogICAgIm1lZGlhbiI6IDE2Ljg2MDYwNjA2MDYwNTg4NSwKICAgICJwOTUiOiAxNi45OTQxMTc2NDcwNTkzMzcsCiAgICAic3RkZGV2IjogMC4xMjc2Nzc4OTAyOTEzMjgzNywKICAgICJtaW4iOiAxNi42Njk0NDQ0NDQ0NDQ2MDgsCiAgICAibWF4IjogMTYuOTk0MTE3NjQ3MDU5MzM3LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibWF4RnJhbWVHYXAiOiB7CiAgICAibWVhbiI6IDMwLjA0MDAwMDAwMDAwODE1LAogICAgIm1lZGlhbiI6IDI5LjkwMDAwMDAwMDAyMzI4MywKICAgICJwOTUiOiAzMS4xMDAwMDAwMDAwMDU4MiwKICAgICJzdGRkZXYiOiAxLjAzODI2Nzc4ODE5OTU4NzYsCiAgICAibWluIjogMjguNSwKICAgICJtYXgiOiAzMS4xMDAwMDAwMDAwMDU4MiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImRyb3BwZWRGcmFtZXMiOiB7CiAgICAibWVhbiI6IDE2LjYsCiAgICAibWVkaWFuIjogMTcsCiAgICAicDk1IjogMTgsCiAgICAic3RkZGV2IjogMS41MTY1NzUwODg4MTAzMSwKICAgICJtaW4iOiAxNSwKICAgICJtYXgiOiAxOCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInA5OUZyYW1lVGltZSI6IHsKICAgICJtZWFuIjogMzAuMDQwMDAwMDAwMDA4MTUsCiAgICAibWVkaWFuIjogMjkuOTAwMDAwMDAwMDIzMjgzLAogICAgInA5NSI6IDMxLjEwMDAwMDAwMDAwNTgyLAogICAgInN0ZGRldiI6IDEuMDM4MjY3Nzg4MTk5NTg3NiwKICAgICJtaW4iOiAyOC41LAogICAgIm1heCI6IDMxLjEwMDAwMDAwMDAwNTgyLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAianNIZWFwRGVsdGEiOiB7CiAgICAibWVhbiI6IDQyMi4xOTE0MDYyNSwKICAgICJtZWRpYW4iOiA0MjEuODMyMDMxMjUsCiAgICAicDk1IjogNDI1Ljk0NTMxMjUsCiAgICAic3RkZGV2IjogMi40MTA3NTYwNDExNTAwMzg2LAogICAgIm1pbiI6IDQyMC4wNzQyMTg3NSwKICAgICJtYXgiOiA0MjUuOTQ1MzEyNSwKICAgICJzYW1wbGVzIjogNQogIH0KfQ==" } ] } @@ -163,7 +215,7 @@ "status": "expected" } ], - "id": "a43c3a9bf74f7ffdb398-14fac9bab569ea34b83a", + "id": "a43c3a9bf74f7ffdb398-91706914204818fb718f", "file": "drag-within-list.perf.ts", "line": 10, "column": 3 @@ -201,18 +253,18 @@ "workerIndex": 0, "parallelIndex": 0, "status": "passed", - "duration": 14210, + "duration": 15004, "errors": [], "stdout": [], "stderr": [], "retry": 0, - "startTime": "2026-03-06T10:47:32.628Z", + "startTime": "2026-03-06T20:29:47.568Z", "annotations": [], "attachments": [ { "name": "dynamic-height-scroll", "contentType": "application/json", - "body": "ewogICJzY2VuYXJpbyI6ICJkeW5hbWljLWhlaWdodC1zY3JvbGwiLAogICJjcHVUaHJvdHRsZSI6IDQsCiAgIml0ZXJhdGlvbnMiOiA1LAogICJ0b3RhbEJsb2NraW5nVGltZSI6IHsKICAgICJtZWFuIjogMjgsCiAgICAibWVkaWFuIjogMjgsCiAgICAicDk1IjogMjgsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAyOCwKICAgICJtYXgiOiAyOCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxvbmdUYXNrQ291bnQiOiB7CiAgICAibWVhbiI6IDIsCiAgICAibWVkaWFuIjogMiwKICAgICJwOTUiOiAyLAogICAgInN0ZGRldiI6IDAsCiAgICAibWluIjogMiwKICAgICJtYXgiOiAyLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibGF5b3V0Q291bnQiOiB7CiAgICAibWVhbiI6IDEyOC42LAogICAgIm1lZGlhbiI6IDEyOSwKICAgICJwOTUiOiAxMzAsCiAgICAic3RkZGV2IjogMS4zNTY0NjU5OTY2MjUwNTM2LAogICAgIm1pbiI6IDEyNiwKICAgICJtYXgiOiAxMzAsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJyZWNhbGNTdHlsZUNvdW50IjogewogICAgIm1lYW4iOiAyNzMuNiwKICAgICJtZWRpYW4iOiAyNzMsCiAgICAicDk1IjogMjc2LAogICAgInN0ZGRldiI6IDEuMzU2NDY1OTk2NjI1MDUzNiwKICAgICJtaW4iOiAyNzIsCiAgICAibWF4IjogMjc2LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAiYXZnRnJhbWVUaW1lIjogewogICAgIm1lYW4iOiA3Ljg2MTk5OTk5OTk5OTk5OSwKICAgICJtZWRpYW4iOiA3Ljg4LAogICAgInA5NSI6IDcuOSwKICAgICJzdGRkZXYiOiAwLjAzNDg3MTE5MTU0ODMyNTUzNCwKICAgICJtaW4iOiA3LjgsCiAgICAibWF4IjogNy45LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibWF4RnJhbWVHYXAiOiB7CiAgICAibWVhbiI6IDExLjQ4LAogICAgIm1lZGlhbiI6IDExLAogICAgInA5NSI6IDEzLjIsCiAgICAic3RkZGV2IjogMC45NTE2MzAxODAyNjk2MjUzLAogICAgIm1pbiI6IDEwLjYsCiAgICAibWF4IjogMTMuMiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImpzSGVhcERlbHRhIjogewogICAgIm1lYW4iOiAtMjU5LjM4NCwKICAgICJtZWRpYW4iOiAyODIuMDMsCiAgICAicDk1IjogNDc1Ljg1LAogICAgInN0ZGRldiI6IDgzNi42NTkzNjQwNTY4NDI1LAogICAgIm1pbiI6IC0xNTQ2LjI0LAogICAgIm1heCI6IDQ3NS44NSwKICAgICJzYW1wbGVzIjogNQogIH0KfQ==" + "body": "ewogICJzY2VuYXJpbyI6ICJkeW5hbWljLWhlaWdodC1zY3JvbGwiLAogICJjcHVUaHJvdHRsZSI6IDQsCiAgIml0ZXJhdGlvbnMiOiA1LAogICJ0b3RhbEJsb2NraW5nVGltZSI6IHsKICAgICJtZWFuIjogMjE5LAogICAgIm1lZGlhbiI6IDIxOSwKICAgICJwOTUiOiAyMTksCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAyMTksCiAgICAibWF4IjogMjE5LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibG9uZ1Rhc2tDb3VudCI6IHsKICAgICJtZWFuIjogMywKICAgICJtZWRpYW4iOiAzLAogICAgInA5NSI6IDMsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAzLAogICAgIm1heCI6IDMsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsYXlvdXRDb3VudCI6IHsKICAgICJtZWFuIjogNzguNCwKICAgICJtZWRpYW4iOiA3OCwKICAgICJwOTUiOiA3OSwKICAgICJzdGRkZXYiOiAwLjU0NzcyMjU1NzUwNTE2NjEsCiAgICAibWluIjogNzgsCiAgICAibWF4IjogNzksCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJyZWNhbGNTdHlsZUNvdW50IjogewogICAgIm1lYW4iOiAxNzUuMiwKICAgICJtZWRpYW4iOiAxNzYsCiAgICAicDk1IjogMTc3LAogICAgInN0ZGRldiI6IDIuMDQ5MzkwMTUzMTkxOTIsCiAgICAibWluIjogMTczLAogICAgIm1heCI6IDE3NywKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImF2Z0ZyYW1lVGltZSI6IHsKICAgICJtZWFuIjogMTYuNTg1NDU0NTQ1NDU0NTY3LAogICAgIm1lZGlhbiI6IDE2LjU2NjExNTcwMjQ3OTM0LAogICAgInA5NSI6IDE2LjYzMTQwNDk1ODY3NzY0LAogICAgInN0ZGRldiI6IDAuMDM5NzA2NDIyMTM4MjEwMjEsCiAgICAibWluIjogMTYuNTQ1NDU0NTQ1NDU0NTQ3LAogICAgIm1heCI6IDE2LjYzMTQwNDk1ODY3NzY0LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibWF4RnJhbWVHYXAiOiB7CiAgICAibWVhbiI6IDI1LjczOTk5OTk5OTk5NjUwNiwKICAgICJtZWRpYW4iOiAyNS42OTk5OTk5OTk5ODI1MzgsCiAgICAicDk1IjogMjYuNSwKICAgICJzdGRkZXYiOiAwLjU1OTQ2NDAyOTIyODI4NjgsCiAgICAibWluIjogMjUsCiAgICAibWF4IjogMjYuNSwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImRyb3BwZWRGcmFtZXMiOiB7CiAgICAibWVhbiI6IDYwLAogICAgIm1lZGlhbiI6IDYwLAogICAgInA5NSI6IDYxLAogICAgInN0ZGRldiI6IDEsCiAgICAibWluIjogNTksCiAgICAibWF4IjogNjEsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJwOTlGcmFtZVRpbWUiOiB7CiAgICAibWVhbiI6IDI0Ljk4MDAwMDAwMDAwNDY1OCwKICAgICJtZWRpYW4iOiAyNC45MDAwMDAwMDAwMjMyODMsCiAgICAicDk1IjogMjUuNSwKICAgICJzdGRkZXYiOiAwLjQzMjQzNDk2NjIwNTY5NzE2LAogICAgIm1pbiI6IDI0LjM5OTk5OTk5OTk5NDE4LAogICAgIm1heCI6IDI1LjUsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJqc0hlYXBEZWx0YSI6IHsKICAgICJtZWFuIjogNDQzLjQ5Mzc1LAogICAgIm1lZGlhbiI6IDUwNy4xNzU3ODEyNSwKICAgICJwOTUiOiA1NjkuMjY5NTMxMjUsCiAgICAic3RkZGV2IjogMTY1LjQwMDU4NzY4MDk3NjUsCiAgICAibWluIjogMTYzLjEwOTM3NSwKICAgICJtYXgiOiA1NjkuMjY5NTMxMjUsCiAgICAic2FtcGxlcyI6IDUKICB9Cn0=" } ] } @@ -258,18 +310,18 @@ "workerIndex": 0, "parallelIndex": 0, "status": "passed", - "duration": 14274, + "duration": 15064, "errors": [], "stdout": [], "stderr": [], "retry": 0, - "startTime": "2026-03-06T10:47:46.846Z", + "startTime": "2026-03-06T20:30:02.589Z", "annotations": [], "attachments": [ { "name": "scroll-2000-items", "contentType": "application/json", - "body": "ewogICJzY2VuYXJpbyI6ICJzY3JvbGwtMjAwMC1pdGVtcyIsCiAgImNwdVRocm90dGxlIjogNCwKICAiaXRlcmF0aW9ucyI6IDUsCiAgInRvdGFsQmxvY2tpbmdUaW1lIjogewogICAgIm1lYW4iOiAzNywKICAgICJtZWRpYW4iOiAzNywKICAgICJwOTUiOiAzNywKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDM3LAogICAgIm1heCI6IDM3LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibG9uZ1Rhc2tDb3VudCI6IHsKICAgICJtZWFuIjogMiwKICAgICJtZWRpYW4iOiAyLAogICAgInA5NSI6IDIsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAyLAogICAgIm1heCI6IDIsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsYXlvdXRDb3VudCI6IHsKICAgICJtZWFuIjogMTIxLjIsCiAgICAibWVkaWFuIjogMTIxLAogICAgInA5NSI6IDEyMiwKICAgICJzdGRkZXYiOiAwLjc0ODMzMTQ3NzM1NDc4ODIsCiAgICAibWluIjogMTIwLAogICAgIm1heCI6IDEyMiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInJlY2FsY1N0eWxlQ291bnQiOiB7CiAgICAibWVhbiI6IDEyMS4yLAogICAgIm1lZGlhbiI6IDEyMSwKICAgICJwOTUiOiAxMjIsCiAgICAic3RkZGV2IjogMC43NDgzMzE0NzczNTQ3ODgyLAogICAgIm1pbiI6IDEyMCwKICAgICJtYXgiOiAxMjIsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJhdmdGcmFtZVRpbWUiOiB7CiAgICAibWVhbiI6IDcuOTA0MDAwMDAwMDAwMDAxLAogICAgIm1lZGlhbiI6IDcuODksCiAgICAicDk1IjogNy45OSwKICAgICJzdGRkZXYiOiAwLjA1MjAwMDAwMDAwMDAwMDA4LAogICAgIm1pbiI6IDcuODMsCiAgICAibWF4IjogNy45OSwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgIm1heEZyYW1lR2FwIjogewogICAgIm1lYW4iOiAxMy4wMiwKICAgICJtZWRpYW4iOiAxMywKICAgICJwOTUiOiAxMy41LAogICAgInN0ZGRldiI6IDAuNDc5MTY1OTQyMDI4NDM3NzYsCiAgICAibWluIjogMTIuMiwKICAgICJtYXgiOiAxMy41LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAianNIZWFwRGVsdGEiOiB7CiAgICAibWVhbiI6IC05NzcuNTMxOTk5OTk5OTk5OCwKICAgICJtZWRpYW4iOiAtNzY1LjE0LAogICAgInA5NSI6IDQzNTEuMTksCiAgICAic3RkZGV2IjogMzg0NC45OTg5MTE3MjYyNDQsCiAgICAibWluIjogLTU3NTUuODgsCiAgICAibWF4IjogNDM1MS4xOSwKICAgICJzYW1wbGVzIjogNQogIH0KfQ==" + "body": "ewogICJzY2VuYXJpbyI6ICJzY3JvbGwtMjAwMC1pdGVtcyIsCiAgImNwdVRocm90dGxlIjogNCwKICAiaXRlcmF0aW9ucyI6IDUsCiAgInRvdGFsQmxvY2tpbmdUaW1lIjogewogICAgIm1lYW4iOiAxOTYsCiAgICAibWVkaWFuIjogMTk2LAogICAgInA5NSI6IDE5NiwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDE5NiwKICAgICJtYXgiOiAxOTYsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsb25nVGFza0NvdW50IjogewogICAgIm1lYW4iOiAyLAogICAgIm1lZGlhbiI6IDIsCiAgICAicDk1IjogMiwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDIsCiAgICAibWF4IjogMiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxheW91dENvdW50IjogewogICAgIm1lYW4iOiA1OCwKICAgICJtZWRpYW4iOiA1OCwKICAgICJwOTUiOiA1OCwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDU4LAogICAgIm1heCI6IDU4LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAicmVjYWxjU3R5bGVDb3VudCI6IHsKICAgICJtZWFuIjogNTgsCiAgICAibWVkaWFuIjogNTgsCiAgICAicDk1IjogNTgsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiA1OCwKICAgICJtYXgiOiA1OCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImF2Z0ZyYW1lVGltZSI6IHsKICAgICJtZWFuIjogMTYuNTgxMTU3MDI0NzkzMzUsCiAgICAibWVkaWFuIjogMTYuNTgyNjQ0NjI4MDk5MTc1LAogICAgInA5NSI6IDE2LjYyMzE0MDQ5NTg2NzcyLAogICAgInN0ZGRldiI6IDAuMDMxOTMwMTQ1MzkzMTIyODYsCiAgICAibWluIjogMTYuNTQ3MTA3NDM4MDE2Mzg2LAogICAgIm1heCI6IDE2LjYyMzE0MDQ5NTg2NzcyLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibWF4RnJhbWVHYXAiOiB7CiAgICAibWVhbiI6IDI0Ljg2MDAwMDAwMDAwMzQ5LAogICAgIm1lZGlhbiI6IDI2LjEwMDAwMDAwMDAwNTgyLAogICAgInA5NSI6IDI3LAogICAgInN0ZGRldiI6IDIuMTg3MDA3MDg3MzI0NTc0LAogICAgIm1pbiI6IDIyLjMwMDAwMDAwMDAxNzQ2MiwKICAgICJtYXgiOiAyNywKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImRyb3BwZWRGcmFtZXMiOiB7CiAgICAibWVhbiI6IDYwLAogICAgIm1lZGlhbiI6IDYwLAogICAgInA5NSI6IDYxLAogICAgInN0ZGRldiI6IDAuNzA3MTA2NzgxMTg2NTQ3NiwKICAgICJtaW4iOiA1OSwKICAgICJtYXgiOiA2MSwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInA5OUZyYW1lVGltZSI6IHsKICAgICJtZWFuIjogMjIuOTgwMDAwMDAwMDA0NjU4LAogICAgIm1lZGlhbiI6IDIyLjY5OTk5OTk5OTk4MjUzOCwKICAgICJwOTUiOiAyNC43MDAwMDAwMDAwMTE2NCwKICAgICJzdGRkZXYiOiAxLjAzNTM3NDMyODQ0NTY2MywKICAgICJtaW4iOiAyMi4xMDAwMDAwMDAwMDU4MiwKICAgICJtYXgiOiAyNC43MDAwMDAwMDAwMTE2NCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImpzSGVhcERlbHRhIjogewogICAgIm1lYW4iOiAzMjcyLjExMzI4MTI1LAogICAgIm1lZGlhbiI6IDM2MjkuNTc0MjE4NzUsCiAgICAicDk1IjogNDY0Mi43NTc4MTI1LAogICAgInN0ZGRldiI6IDE0MzkuNzM3MDU3OTM3MTg0NSwKICAgICJtaW4iOiA5MzYuOTY0ODQzNzUsCiAgICAibWF4IjogNDY0Mi43NTc4MTI1LAogICAgInNhbXBsZXMiOiA1CiAgfQp9" } ] } @@ -289,8 +341,8 @@ ], "errors": [], "stats": { - "startTime": "2026-03-06T10:47:00.475Z", - "duration": 60692.924, + "startTime": "2026-03-06T20:28:57.741Z", + "duration": 80073.751, "expected": 4, "skipped": 0, "unexpected": 0,