diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 0000000..4093d27 --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,104 @@ +name: Performance Benchmarks + +on: + pull_request: + branches: [master] + +permissions: + contents: read + pull-requests: write + +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: 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: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + 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}${comparison}`; + + 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/ + retention-days: 30 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..451b6ba 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,10 @@ "e2e": "playwright test", "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", "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..83f9b71 --- /dev/null +++ b/perf/baselines/baseline.json @@ -0,0 +1,351 @@ +{ + "config": { + "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, + "globalTeardown": null, + "globalTimeout": 0, + "grep": {}, + "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", + "reporter": [ + ["list", null], + [ + "json", + { + "outputFile": "results/latest.json" + } + ] + ], + "reportSlowTests": { + "max": 5, + "threshold": 300000 + }, + "quiet": false, + "projects": [ + { + "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": "/home/runner/work/angular-vdnd/angular-vdnd/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": false, + "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": 28097, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-03-06T20:29:07.726Z", + "annotations": [], + "attachments": [ + { + "name": "drag-between-lists-autoscroll-1000", + "contentType": "application/json", + "body": "ewogICJzY2VuYXJpbyI6ICJkcmFnLWJldHdlZW4tbGlzdHMtYXV0b3Njcm9sbC0xMDAwIiwKICAiY3B1VGhyb3R0bGUiOiA0LAogICJpdGVyYXRpb25zIjogNSwKICAiYXV0b3Njcm9sbEhvbGRNcyI6IDMwMDAsCiAgInRvdGFsQmxvY2tpbmdUaW1lIjogewogICAgIm1lYW4iOiA1MS4yLAogICAgIm1lZGlhbiI6IDQ3LAogICAgInA5NSI6IDYxLAogICAgInN0ZGRldiI6IDkuMTIxNDAzNDAwNzkzMTA0LAogICAgIm1pbiI6IDQyLAogICAgIm1heCI6IDYxLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibG9uZ1Rhc2tDb3VudCI6IHsKICAgICJtZWFuIjogMiwKICAgICJtZWRpYW4iOiAyLAogICAgInA5NSI6IDIsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAyLAogICAgIm1heCI6IDIsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsYXlvdXRDb3VudCI6IHsKICAgICJtZWFuIjogMzgsCiAgICAibWVkaWFuIjogMzgsCiAgICAicDk1IjogMzgsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAzOCwKICAgICJtYXgiOiAzOCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInJlY2FsY1N0eWxlQ291bnQiOiB7CiAgICAibWVhbiI6IDY5LjgsCiAgICAibWVkaWFuIjogNjksCiAgICAicDk1IjogNzEsCiAgICAic3RkZGV2IjogMS4wOTU0NDUxMTUwMTAzMzIxLAogICAgIm1pbiI6IDY5LAogICAgIm1heCI6IDcxLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAiYXZnRnJhbWVUaW1lIjogewogICAgIm1lYW4iOiAxNi43MjcxMjY0MzY3ODE2MDcsCiAgICAibWVkaWFuIjogMTYuNzM3OTMxMDM0NDgyODQ1LAogICAgInA5NSI6IDE2Ljc1NzM1Mjk0MTE3NjQ3LAogICAgInN0ZGRldiI6IDAuMDI1ODE5Njk3NDMxMTkzODIsCiAgICAibWluIjogMTYuNjkyNjEwODM3NDM4NDUsCiAgICAibWF4IjogMTYuNzU3MzUyOTQxMTc2NDcsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJtYXhGcmFtZUdhcCI6IHsKICAgICJtZWFuIjogMjkuMDYwMDAwMDAwMDAzNDksCiAgICAibWVkaWFuIjogMjguOTAwMDAwMDAwMDIzMjgzLAogICAgInA5NSI6IDI5LjgwMDAwMDAwMDAxNzQ2MiwKICAgICJzdGRkZXYiOiAwLjYxODg2OTkzNzg4MDc4OTksCiAgICAibWluIjogMjguMTk5OTk5OTk5OTgyNTM4LAogICAgIm1heCI6IDI5LjgwMDAwMDAwMDAxNzQ2MiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImRyb3BwZWRGcmFtZXMiOiB7CiAgICAibWVhbiI6IDEwMC44LAogICAgIm1lZGlhbiI6IDk5LAogICAgInA5NSI6IDEwNywKICAgICJzdGRkZXYiOiA0LjQ5NDQ0MTAxMDg0ODg0NiwKICAgICJtaW4iOiA5NywKICAgICJtYXgiOiAxMDcsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJwOTlGcmFtZVRpbWUiOiB7CiAgICAibWVhbiI6IDIxLjIwMDAwMDAwMDAwNTgyMiwKICAgICJtZWRpYW4iOiAyMS4yMDAwMDAwMDAwMTE2NCwKICAgICJwOTUiOiAyMi4yOTk5OTk5OTk5ODgzNiwKICAgICJzdGRkZXYiOiAxLjA3OTM1MTY1NzI0MDIxMywKICAgICJtaW4iOiAxOS44MDAwMDAwMDAwMTc0NjIsCiAgICAibWF4IjogMjIuMjk5OTk5OTk5OTg4MzYsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJqc0hlYXBEZWx0YSI6IHsKICAgICJtZWFuIjogMTA2Mi4yMzIwMzEyNSwKICAgICJtZWRpYW4iOiAxMjk5LjUxOTUzMTI1LAogICAgInA5NSI6IDEzMDIuNzg1MTU2MjUsCiAgICAic3RkZGV2IjogMzI2LjY4Mzk0ODA4NDkwNDEsCiAgICAibWluIjogNzAxLjE2NDA2MjUsCiAgICAibWF4IjogMTMwMi43ODUxNTYyNSwKICAgICJzYW1wbGVzIjogNQogIH0KfQ==" + } + ] + } + ], + "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 7 - 1000 items", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 120000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "", + "projectName": "", + "results": [ + { + "workerIndex": 0, + "parallelIndex": 0, + "status": "passed", + "duration": 10877, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-03-06T20:29:36.672Z", + "annotations": [], + "attachments": [ + { + "name": "drag-within-list-1000", + "contentType": "application/json", + "body": "ewogICJzY2VuYXJpbyI6ICJkcmFnLXdpdGhpbi1saXN0LTEwMDAiLAogICJjcHVUaHJvdHRsZSI6IDQsCiAgIml0ZXJhdGlvbnMiOiA1LAogICJ0b3RhbEJsb2NraW5nVGltZSI6IHsKICAgICJtZWFuIjogMzcuMiwKICAgICJtZWRpYW4iOiAzNiwKICAgICJwOTUiOiA0NiwKICAgICJzdGRkZXYiOiA1LjQwMzcwMjQzNDQ0MjUxOCwKICAgICJtaW4iOiAzMiwKICAgICJtYXgiOiA0NiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxvbmdUYXNrQ291bnQiOiB7CiAgICAibWVhbiI6IDEuOCwKICAgICJtZWRpYW4iOiAyLAogICAgInA5NSI6IDIsCiAgICAic3RkZGV2IjogMC40NDcyMTM1OTU0OTk5NTgwNCwKICAgICJtaW4iOiAxLAogICAgIm1heCI6IDIsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsYXlvdXRDb3VudCI6IHsKICAgICJtZWFuIjogNSwKICAgICJtZWRpYW4iOiA1LAogICAgInA5NSI6IDUsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiA1LAogICAgIm1heCI6IDUsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJyZWNhbGNTdHlsZUNvdW50IjogewogICAgIm1lYW4iOiA0NS4yLAogICAgIm1lZGlhbiI6IDQ2LAogICAgInA5NSI6IDQ2LAogICAgInN0ZGRldiI6IDEuMDk1NDQ1MTE1MDEwMzMyMSwKICAgICJtaW4iOiA0NCwKICAgICJtYXgiOiA0NiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImF2Z0ZyYW1lVGltZSI6IHsKICAgICJtZWFuIjogMTYuODU3ODQ2MTA4MTQwMjIyLAogICAgIm1lZGlhbiI6IDE2Ljg2MDYwNjA2MDYwNTg4NSwKICAgICJwOTUiOiAxNi45OTQxMTc2NDcwNTkzMzcsCiAgICAic3RkZGV2IjogMC4xMjc2Nzc4OTAyOTEzMjgzNywKICAgICJtaW4iOiAxNi42Njk0NDQ0NDQ0NDQ2MDgsCiAgICAibWF4IjogMTYuOTk0MTE3NjQ3MDU5MzM3LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibWF4RnJhbWVHYXAiOiB7CiAgICAibWVhbiI6IDMwLjA0MDAwMDAwMDAwODE1LAogICAgIm1lZGlhbiI6IDI5LjkwMDAwMDAwMDAyMzI4MywKICAgICJwOTUiOiAzMS4xMDAwMDAwMDAwMDU4MiwKICAgICJzdGRkZXYiOiAxLjAzODI2Nzc4ODE5OTU4NzYsCiAgICAibWluIjogMjguNSwKICAgICJtYXgiOiAzMS4xMDAwMDAwMDAwMDU4MiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImRyb3BwZWRGcmFtZXMiOiB7CiAgICAibWVhbiI6IDE2LjYsCiAgICAibWVkaWFuIjogMTcsCiAgICAicDk1IjogMTgsCiAgICAic3RkZGV2IjogMS41MTY1NzUwODg4MTAzMSwKICAgICJtaW4iOiAxNSwKICAgICJtYXgiOiAxOCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInA5OUZyYW1lVGltZSI6IHsKICAgICJtZWFuIjogMzAuMDQwMDAwMDAwMDA4MTUsCiAgICAibWVkaWFuIjogMjkuOTAwMDAwMDAwMDIzMjgzLAogICAgInA5NSI6IDMxLjEwMDAwMDAwMDAwNTgyLAogICAgInN0ZGRldiI6IDEuMDM4MjY3Nzg4MTk5NTg3NiwKICAgICJtaW4iOiAyOC41LAogICAgIm1heCI6IDMxLjEwMDAwMDAwMDAwNTgyLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAianNIZWFwRGVsdGEiOiB7CiAgICAibWVhbiI6IDQyMi4xOTE0MDYyNSwKICAgICJtZWRpYW4iOiA0MjEuODMyMDMxMjUsCiAgICAicDk1IjogNDI1Ljk0NTMxMjUsCiAgICAic3RkZGV2IjogMi40MTA3NTYwNDExNTAwMzg2LAogICAgIm1pbiI6IDQyMC4wNzQyMTg3NSwKICAgICJtYXgiOiA0MjUuOTQ1MzEyNSwKICAgICJzYW1wbGVzIjogNQogIH0KfQ==" + } + ] + } + ], + "status": "expected" + } + ], + "id": "a43c3a9bf74f7ffdb398-91706914204818fb718f", + "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": 15004, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-03-06T20:29:47.568Z", + "annotations": [], + "attachments": [ + { + "name": "dynamic-height-scroll", + "contentType": "application/json", + "body": "ewogICJzY2VuYXJpbyI6ICJkeW5hbWljLWhlaWdodC1zY3JvbGwiLAogICJjcHVUaHJvdHRsZSI6IDQsCiAgIml0ZXJhdGlvbnMiOiA1LAogICJ0b3RhbEJsb2NraW5nVGltZSI6IHsKICAgICJtZWFuIjogMjE5LAogICAgIm1lZGlhbiI6IDIxOSwKICAgICJwOTUiOiAyMTksCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAyMTksCiAgICAibWF4IjogMjE5LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibG9uZ1Rhc2tDb3VudCI6IHsKICAgICJtZWFuIjogMywKICAgICJtZWRpYW4iOiAzLAogICAgInA5NSI6IDMsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiAzLAogICAgIm1heCI6IDMsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsYXlvdXRDb3VudCI6IHsKICAgICJtZWFuIjogNzguNCwKICAgICJtZWRpYW4iOiA3OCwKICAgICJwOTUiOiA3OSwKICAgICJzdGRkZXYiOiAwLjU0NzcyMjU1NzUwNTE2NjEsCiAgICAibWluIjogNzgsCiAgICAibWF4IjogNzksCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJyZWNhbGNTdHlsZUNvdW50IjogewogICAgIm1lYW4iOiAxNzUuMiwKICAgICJtZWRpYW4iOiAxNzYsCiAgICAicDk1IjogMTc3LAogICAgInN0ZGRldiI6IDIuMDQ5MzkwMTUzMTkxOTIsCiAgICAibWluIjogMTczLAogICAgIm1heCI6IDE3NywKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImF2Z0ZyYW1lVGltZSI6IHsKICAgICJtZWFuIjogMTYuNTg1NDU0NTQ1NDU0NTY3LAogICAgIm1lZGlhbiI6IDE2LjU2NjExNTcwMjQ3OTM0LAogICAgInA5NSI6IDE2LjYzMTQwNDk1ODY3NzY0LAogICAgInN0ZGRldiI6IDAuMDM5NzA2NDIyMTM4MjEwMjEsCiAgICAibWluIjogMTYuNTQ1NDU0NTQ1NDU0NTQ3LAogICAgIm1heCI6IDE2LjYzMTQwNDk1ODY3NzY0LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibWF4RnJhbWVHYXAiOiB7CiAgICAibWVhbiI6IDI1LjczOTk5OTk5OTk5NjUwNiwKICAgICJtZWRpYW4iOiAyNS42OTk5OTk5OTk5ODI1MzgsCiAgICAicDk1IjogMjYuNSwKICAgICJzdGRkZXYiOiAwLjU1OTQ2NDAyOTIyODI4NjgsCiAgICAibWluIjogMjUsCiAgICAibWF4IjogMjYuNSwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImRyb3BwZWRGcmFtZXMiOiB7CiAgICAibWVhbiI6IDYwLAogICAgIm1lZGlhbiI6IDYwLAogICAgInA5NSI6IDYxLAogICAgInN0ZGRldiI6IDEsCiAgICAibWluIjogNTksCiAgICAibWF4IjogNjEsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJwOTlGcmFtZVRpbWUiOiB7CiAgICAibWVhbiI6IDI0Ljk4MDAwMDAwMDAwNDY1OCwKICAgICJtZWRpYW4iOiAyNC45MDAwMDAwMDAwMjMyODMsCiAgICAicDk1IjogMjUuNSwKICAgICJzdGRkZXYiOiAwLjQzMjQzNDk2NjIwNTY5NzE2LAogICAgIm1pbiI6IDI0LjM5OTk5OTk5OTk5NDE4LAogICAgIm1heCI6IDI1LjUsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJqc0hlYXBEZWx0YSI6IHsKICAgICJtZWFuIjogNDQzLjQ5Mzc1LAogICAgIm1lZGlhbiI6IDUwNy4xNzU3ODEyNSwKICAgICJwOTUiOiA1NjkuMjY5NTMxMjUsCiAgICAic3RkZGV2IjogMTY1LjQwMDU4NzY4MDk3NjUsCiAgICAibWluIjogMTYzLjEwOTM3NSwKICAgICJtYXgiOiA1NjkuMjY5NTMxMjUsCiAgICAic2FtcGxlcyI6IDUKICB9Cn0=" + } + ] + } + ], + "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": 15064, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-03-06T20:30:02.589Z", + "annotations": [], + "attachments": [ + { + "name": "scroll-2000-items", + "contentType": "application/json", + "body": "ewogICJzY2VuYXJpbyI6ICJzY3JvbGwtMjAwMC1pdGVtcyIsCiAgImNwdVRocm90dGxlIjogNCwKICAiaXRlcmF0aW9ucyI6IDUsCiAgInRvdGFsQmxvY2tpbmdUaW1lIjogewogICAgIm1lYW4iOiAxOTYsCiAgICAibWVkaWFuIjogMTk2LAogICAgInA5NSI6IDE5NiwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDE5NiwKICAgICJtYXgiOiAxOTYsCiAgICAic2FtcGxlcyI6IDUKICB9LAogICJsb25nVGFza0NvdW50IjogewogICAgIm1lYW4iOiAyLAogICAgIm1lZGlhbiI6IDIsCiAgICAicDk1IjogMiwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDIsCiAgICAibWF4IjogMiwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImxheW91dENvdW50IjogewogICAgIm1lYW4iOiA1OCwKICAgICJtZWRpYW4iOiA1OCwKICAgICJwOTUiOiA1OCwKICAgICJzdGRkZXYiOiAwLAogICAgIm1pbiI6IDU4LAogICAgIm1heCI6IDU4LAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAicmVjYWxjU3R5bGVDb3VudCI6IHsKICAgICJtZWFuIjogNTgsCiAgICAibWVkaWFuIjogNTgsCiAgICAicDk1IjogNTgsCiAgICAic3RkZGV2IjogMCwKICAgICJtaW4iOiA1OCwKICAgICJtYXgiOiA1OCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImF2Z0ZyYW1lVGltZSI6IHsKICAgICJtZWFuIjogMTYuNTgxMTU3MDI0NzkzMzUsCiAgICAibWVkaWFuIjogMTYuNTgyNjQ0NjI4MDk5MTc1LAogICAgInA5NSI6IDE2LjYyMzE0MDQ5NTg2NzcyLAogICAgInN0ZGRldiI6IDAuMDMxOTMwMTQ1MzkzMTIyODYsCiAgICAibWluIjogMTYuNTQ3MTA3NDM4MDE2Mzg2LAogICAgIm1heCI6IDE2LjYyMzE0MDQ5NTg2NzcyLAogICAgInNhbXBsZXMiOiA1CiAgfSwKICAibWF4RnJhbWVHYXAiOiB7CiAgICAibWVhbiI6IDI0Ljg2MDAwMDAwMDAwMzQ5LAogICAgIm1lZGlhbiI6IDI2LjEwMDAwMDAwMDAwNTgyLAogICAgInA5NSI6IDI3LAogICAgInN0ZGRldiI6IDIuMTg3MDA3MDg3MzI0NTc0LAogICAgIm1pbiI6IDIyLjMwMDAwMDAwMDAxNzQ2MiwKICAgICJtYXgiOiAyNywKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImRyb3BwZWRGcmFtZXMiOiB7CiAgICAibWVhbiI6IDYwLAogICAgIm1lZGlhbiI6IDYwLAogICAgInA5NSI6IDYxLAogICAgInN0ZGRldiI6IDAuNzA3MTA2NzgxMTg2NTQ3NiwKICAgICJtaW4iOiA1OSwKICAgICJtYXgiOiA2MSwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgInA5OUZyYW1lVGltZSI6IHsKICAgICJtZWFuIjogMjIuOTgwMDAwMDAwMDA0NjU4LAogICAgIm1lZGlhbiI6IDIyLjY5OTk5OTk5OTk4MjUzOCwKICAgICJwOTUiOiAyNC43MDAwMDAwMDAwMTE2NCwKICAgICJzdGRkZXYiOiAxLjAzNTM3NDMyODQ0NTY2MywKICAgICJtaW4iOiAyMi4xMDAwMDAwMDAwMDU4MiwKICAgICJtYXgiOiAyNC43MDAwMDAwMDAwMTE2NCwKICAgICJzYW1wbGVzIjogNQogIH0sCiAgImpzSGVhcERlbHRhIjogewogICAgIm1lYW4iOiAzMjcyLjExMzI4MTI1LAogICAgIm1lZGlhbiI6IDM2MjkuNTc0MjE4NzUsCiAgICAicDk1IjogNDY0Mi43NTc4MTI1LAogICAgInN0ZGRldiI6IDE0MzkuNzM3MDU3OTM3MTg0NSwKICAgICJtaW4iOiA5MzYuOTY0ODQzNzUsCiAgICAibWF4IjogNDY0Mi43NTc4MTI1LAogICAgInNhbXBsZXMiOiA1CiAgfQp9" + } + ] + } + ], + "status": "expected" + } + ], + "id": "e4444aa54d21dcc13bb1-7b5cf2c5d052bafba4c6", + "file": "scroll.perf.ts", + "line": 11, + "column": 3 + } + ] + } + ] + } + ], + "errors": [], + "stats": { + "startTime": "2026-03-06T20:28:57.741Z", + "duration": 80073.751, + "expected": 4, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + } +} diff --git a/perf/compare.ts b/perf/compare.ts new file mode 100644 index 0000000..2cc8bfb --- /dev/null +++ b/perf/compare.ts @@ -0,0 +1,116 @@ +import { writeFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { AggregatedMetrics } from './fixtures/statistics.ts'; +import { extractScenarios } from './fixtures/extract-scenarios.ts'; + +const COMPARISON_METRICS = [ + 'totalBlockingTime', + 'longTaskCount', + 'layoutCount', + 'recalcStyleCount', + 'avgFrameTime', + 'maxFrameGap', + 'droppedFrames', + 'p99FrameTime', + 'jsHeapDelta', +]; + +function percentChange(baseline: number, current: number): number { + if (baseline === 0) return current === 0 ? 0 : 100; + 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); + const threshold = parseFloat(parseArg(args, '--threshold') ?? '25'); + const outputPath = parseArg(args, '--output'); + + 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.'); + process.exit(0); + } + + if (!existsSync(latestPath)) { + console.error('No latest results found. Run `npm run perf` first.'); + process.exit(1); + } + + 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`.'); + process.exit(0); + } + + 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) { + emit(`| ${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; + } + + emit( + `| ${name} | ${metric} | ${bMetric.p95.toFixed(1)} | ${cMetric.p95.toFixed(1)} | ${changeStr}${flag} |`, + ); + } + } + + emit(''); + + if (hasRegression) { + emit(`**Performance regression detected** (>${threshold}% on p95 values).`); + } else { + emit('No significant regressions detected.'); + } + + if (outputPath) { + writeFileSync(outputPath, lines.join('\n') + '\n'); + } + + if (hasRegression) { + process.exit(1); + } +} + +main(); 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 new file mode 100644 index 0000000..517dd19 --- /dev/null +++ b/perf/fixtures/metrics-collector.ts @@ -0,0 +1,170 @@ +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; + droppedFrames: number; + p99FrameTime: 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 }); + } + + /** 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; + 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 { + await this.forceGC(); + 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; + 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, + 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, + droppedFrames, + p99FrameTime, + }; + } + + 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..d42bcbd --- /dev/null +++ b/perf/fixtures/perf.page.ts @@ -0,0 +1,140 @@ +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.waitForLoadState('networkidle'); + 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..8949d71 --- /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 > 1 ? n - 1 : 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..cb5332d --- /dev/null +++ b/perf/playwright.perf.config.ts @@ -0,0 +1,28 @@ +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', + launchOptions: { + args: ['--js-flags=--expose-gc'], + }, + }, + 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/report.ts b/perf/report.ts new file mode 100644 index 0000000..36bf7e1 --- /dev/null +++ b/perf/report.ts @@ -0,0 +1,82 @@ +import { appendFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +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' }, + 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' }, + droppedFrames: { label: 'Dropped Frames (>16.7ms)', unit: '' }, + p99FrameTime: { label: 'p99 Frame Time', unit: 'ms' }, + jsHeapDelta: { label: 'Heap Delta', unit: 'KB' }, +}; + +function formatValue(value: number, unit: string): string { + const rounded = Math.round(value * 10) / 10; + return unit ? `${rounded} ${unit}` : `${rounded}`; +} + +function generateReport(scenarios: { scenario: string; [k: string]: unknown }[]): string { + const lines: string[] = []; + + lines.push('## Performance Benchmark Results'); + lines.push(''); + 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 | Max | 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.max, 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(); 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..865c196 --- /dev/null +++ b/perf/scenarios/drag-between-lists.perf.ts @@ -0,0 +1,97 @@ +import { test } from '@playwright/test'; +import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector'; +import { PerfPage } from '../fixtures/perf.page'; +import { aggregate } 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) => 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) => 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', { + 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..e5f524b --- /dev/null +++ b/perf/scenarios/drag-within-list.perf.ts @@ -0,0 +1,75 @@ +import { test } from '@playwright/test'; +import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector'; +import { PerfPage } from '../fixtures/perf.page'; +import { aggregate } 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 7 - 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) => 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) => 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', { + 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..3cd7a74 --- /dev/null +++ b/perf/scenarios/dynamic-height.perf.ts @@ -0,0 +1,90 @@ +import { test } from '@playwright/test'; +import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector'; +import { PerfPage } from '../fixtures/perf.page'; +import { aggregate } 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) => 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) => 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', { + 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..d7ea951 --- /dev/null +++ b/perf/scenarios/scroll.perf.ts @@ -0,0 +1,67 @@ +import { test } from '@playwright/test'; +import { MetricsCollector, ScenarioMetrics } from '../fixtures/metrics-collector'; +import { PerfPage } from '../fixtures/perf.page'; +import { aggregate } 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) => 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) => 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', { + 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"] }