diff --git a/.eslintignore b/.eslintignore
index 82855125d..c91bdd9bc 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -8,3 +8,6 @@ wt
test/e2e/report
test/e2e/static/basic-report
tmp
+coverage/**
+tsc-out
+test/browser-env/report/**
diff --git a/.eslintrc.js b/.eslintrc.js
index 89a2aae12..64f949094 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -7,6 +7,52 @@ module.exports = {
ecmaVersion: 2022,
},
overrides: [
+ {
+ files: ["src/browser/isomorphic/*.ts"],
+ rules: {
+ "@typescript-eslint/no-restricted-imports": [
+ "error",
+ {
+ patterns: ["../**"],
+ },
+ ],
+ },
+ },
+ {
+ files: ["src/**/*.ts"],
+ excludedFiles: ["src/browser/client-scripts/**"],
+ rules: {
+ "@typescript-eslint/no-restricted-imports": [
+ "error",
+ {
+ patterns: [
+ {
+ group: ["**/client-scripts/**"],
+ allowTypeImports: true,
+ message:
+ "Imports from client-scripts are forbidden. Use type-only imports when needed.",
+ },
+ ],
+ },
+ ],
+ },
+ },
+ {
+ files: ["src/browser/client-scripts/**/*.ts"],
+ rules: {
+ "@typescript-eslint/no-restricted-imports": [
+ "error",
+ {
+ patterns: [
+ {
+ group: ["../../**", "!../../isomorphic", "!../../isomorphic/**", "!../../..", "!../../../isomorphic", "!../../../isomorphic/**"],
+ message: "Client-scripts cannot import server-side code, except isomorphic modules.",
+ },
+ ],
+ },
+ ],
+ },
+ },
{
files: ["*.ts"],
rules: {
@@ -20,13 +66,5 @@ module.exports = {
"@typescript-eslint/no-var-requires": "off",
},
},
- {
- files: ["test/**"],
- rules: {
- "@typescript-eslint/no-empty-function": "off",
- // For convenient casting of test objects
- "@typescript-eslint/no-explicit-any": "off",
- },
- },
],
};
diff --git a/.github/workflows/browser-env.yml b/.github/workflows/browser-env.yml
new file mode 100644
index 000000000..22090b80b
--- /dev/null
+++ b/.github/workflows/browser-env.yml
@@ -0,0 +1,98 @@
+name: Testplane Browser Env Tests
+
+on:
+ pull_request:
+ branches: [testplane@9]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ testplane-browser-env:
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: write
+ pull-requests: write
+
+ env:
+ DOCKER_IMAGE_NAME: html-reporter-browsers
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 20
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: "npm"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build the package
+ run: npm run build
+
+ - name: "Prepare browser-env tests: Cache browser docker image"
+ uses: actions/cache@v3
+ with:
+ path: ~/.docker/cache
+ key: docker-browser-image-testplane
+
+ - name: "Prepare browser-env tests: Pull browser docker image"
+ run: |
+ mkdir -p ~/.docker/cache
+ if [ -f ~/.docker/cache/image.tar ]; then
+ docker load -i ~/.docker/cache/image.tar
+ else
+ docker pull yinfra/html-reporter-browsers
+ docker save yinfra/html-reporter-browsers -o ~/.docker/cache/image.tar
+ fi
+
+ - name: "Prepare browser-env tests: Run browser docker image"
+ run: docker run -d --name ${{ env.DOCKER_IMAGE_NAME }} -it --rm --network=host $(which colima >/dev/null || echo --add-host=host.docker.internal:0.0.0.0) yinfra/html-reporter-browsers
+
+ - name: "browser-env: Run Testplane"
+ id: "testplane"
+ continue-on-error: true
+ run: npm run test-browser-env
+
+ - name: "browser-env: Stop browser docker image"
+ run: |
+ docker kill ${{ env.DOCKER_IMAGE_NAME }} || true
+ docker rm ${{ env.DOCKER_IMAGE_NAME }} || true
+
+ - name: Deploy Testplane html-reporter reports
+ uses: jakejarvis/s3-sync-action@v0.5.1
+ with:
+ args: --acl public-read --follow-symlinks
+ env:
+ AWS_S3_BUCKET: gh-testplane-ci
+ AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ AWS_S3_ENDPOINT: https://s3.yandexcloud.net/
+ SOURCE_DIR: "test/browser-env/report"
+ DEST_DIR: "testplane-ci/browser-env-reports/${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}/"
+
+ - name: Construct PR comment
+ run: |
+ link="https://storage.yandexcloud.net/gh-testplane-ci/testplane-ci/browser-env-reports/${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}/index.html"
+ if [ "${{ steps.testplane.outcome }}" != "success" ]; then
+ comment="### ❌ Testplane browser-env run failed
[Report](${link})"
+ echo "PR_COMMENT=${comment}" >> $GITHUB_ENV
+ else
+ comment="### ✅ Testplane browser-env run succeed
[Report](${link})"
+ echo "PR_COMMENT=${comment}" >> $GITHUB_ENV
+ fi
+
+ - name: Leave comment to PR with link to Testplane HTML reports
+ if: github.event.pull_request
+ uses: thollander/actions-comment-pull-request@v3
+ with:
+ message: ${{ env.PR_COMMENT }}
+ comment-tag: testplane_results
+
+ - name: Fail the job if any Testplane job is failed
+ if: ${{ steps.testplane.outcome != 'success' }}
+ run: exit 1
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index f20a2350d..d5c01c9a1 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -85,10 +85,10 @@ jobs:
run: |
link="https://storage.yandexcloud.net/gh-testplane-ci/testplane-ci/e2e-reports/${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}/index.html"
if [ "${{ steps.testplane.outcome }}" != "success" ]; then
- comment="### ❌ Testplane run failed
[Report](${link})"
+ comment="### ❌ Testplane E2E run failed
[Report](${link})"
echo "PR_COMMENT=${comment}" >> $GITHUB_ENV
else
- comment="### ✅ Testplane run succeed
[Report](${link})"
+ comment="### ✅ Testplane E2E run succeed
[Report](${link})"
echo "PR_COMMENT=${comment}" >> $GITHUB_ENV
fi
diff --git a/.gitignore b/.gitignore
index 4c6943364..83d2c85da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,9 @@ bundle.compat.js
bundle.native.js
wt/**
tmp/**
+coverage/**
+tsc-out
+testplane/**
+testplane-report/**
+*.tsbuildinfo
+test/browser-env/report/**
diff --git a/.prettierignore b/.prettierignore
index fc15f0618..69bfa5405 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -10,5 +10,11 @@ bundle.compat.js
bundle.native.js
wt/**
test/e2e/report
+test/e2e/static/basic-report
tmp/**
.testplane/**
+coverage/**
+*.tsbuildinfo
+test/browser-env/report/**
+*.png
+*.DS_Store
diff --git a/package-lock.json b/package-lock.json
index 7156fe20a..6baeb68f4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "testplane",
- "version": "8.40.2",
+ "version": "9.0.0-rc.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "testplane",
- "version": "8.40.2",
+ "version": "9.0.0-rc.3",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "7.24.2",
@@ -103,6 +103,7 @@
"aliasify": "1.9.0",
"app-module-path": "2.2.0",
"browserify": "13.3.0",
+ "c8": "10.1.3",
"chai": "4.2.0",
"chai-as-promised": "7.1.1",
"concurrently": "8.2.2",
@@ -126,6 +127,7 @@
"sinon-chai": "3.7.0",
"standard-version": "9.5.0",
"ts-node": "10.9.1",
+ "tsconfig-paths": "4.2.0",
"type-fest": "3.11.1",
"typescript": "5.3.2",
"uglifyify": "3.0.4"
@@ -238,6 +240,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@blakeembrey/deque": {
"version": "1.0.5",
"dev": true,
@@ -1634,6 +1646,16 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
+ "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/@jest/expect-utils": {
"version": "28.1.3",
"license": "MIT",
@@ -4810,6 +4832,149 @@
"node": ">= 0.8"
}
},
+ "node_modules/c8": {
+ "version": "10.1.3",
+ "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
+ "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.1",
+ "@istanbuljs/schema": "^0.1.3",
+ "find-up": "^5.0.0",
+ "foreground-child": "^3.1.1",
+ "istanbul-lib-coverage": "^3.2.0",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.1.6",
+ "test-exclude": "^7.0.1",
+ "v8-to-istanbul": "^9.0.0",
+ "yargs": "^17.7.2",
+ "yargs-parser": "^21.1.1"
+ },
+ "bin": {
+ "c8": "bin/c8.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "monocart-coverage-reports": "^2"
+ },
+ "peerDependenciesMeta": {
+ "monocart-coverage-reports": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/c8/node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/c8/node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/c8/node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/c8/node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/c8/node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/c8/node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/c8/node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/cacheable-lookup": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
@@ -9204,6 +9369,13 @@
"node": ">=18"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/html-reporter": {
"version": "11.8.3",
"resolved": "https://registry.npmjs.org/html-reporter/-/html-reporter-11.8.3.tgz",
@@ -9893,6 +10065,45 @@
"version": "2.0.0",
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -10155,6 +10366,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/jsonfile": {
"version": "4.0.0",
"license": "MIT",
@@ -10325,14 +10549,6 @@
"node": ">=4"
}
},
- "node_modules/load-json-file/node_modules/strip-bom": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
"node_modules/local-pkg": {
"version": "0.4.3",
"license": "MIT",
@@ -10562,6 +10778,22 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/make-error": {
"version": "1.3.6",
"dev": true,
@@ -14148,6 +14380,16 @@
"node": ">=8"
}
},
+ "node_modules/strip-bom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+ "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/strip-final-newline": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
@@ -14256,6 +14498,115 @@
"rimraf": "bin.js"
}
},
+ "node_modules/test-exclude": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
+ "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^10.4.1",
+ "minimatch": "^10.2.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/test-exclude/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/test-exclude/node_modules/brace-expansion": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/test-exclude/node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/test-exclude/node_modules/glob/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/test-exclude/node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/text-decoder": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
@@ -14533,6 +14884,21 @@
"node": ">=0.3.1"
}
},
+ "node_modules/tsconfig-paths": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
+ "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json5": "^2.2.2",
+ "minimist": "^1.2.6",
+ "strip-bom": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/tslib": {
"version": "2.6.2",
"license": "0BSD"
@@ -14983,6 +15349,39 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/v8-to-istanbul": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
+ "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.12",
+ "@types/istanbul-lib-coverage": "^2.0.1",
+ "convert-source-map": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.12.0"
+ }
+ },
+ "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/v8-to-istanbul/node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/validate-npm-package-license": {
"version": "3.0.4",
"dev": true,
@@ -15997,6 +16396,12 @@
"@babel/helper-validator-identifier": "^7.28.5"
}
},
+ "@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true
+ },
"@blakeembrey/deque": {
"version": "1.0.5",
"dev": true
@@ -16792,6 +17197,12 @@
}
}
},
+ "@istanbuljs/schema": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
+ "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
+ "dev": true
+ },
"@jest/expect-utils": {
"version": "28.1.3",
"requires": {
@@ -19013,6 +19424,96 @@
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"dev": true
},
+ "c8": {
+ "version": "10.1.3",
+ "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
+ "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==",
+ "dev": true,
+ "requires": {
+ "@bcoe/v8-coverage": "^1.0.1",
+ "@istanbuljs/schema": "^0.1.3",
+ "find-up": "^5.0.0",
+ "foreground-child": "^3.1.1",
+ "istanbul-lib-coverage": "^3.2.0",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.1.6",
+ "test-exclude": "^7.0.1",
+ "v8-to-istanbul": "^9.0.0",
+ "yargs": "^17.7.2",
+ "yargs-parser": "^21.1.1"
+ },
+ "dependencies": {
+ "cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "requires": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ }
+ },
+ "find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ }
+ },
+ "locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^5.0.0"
+ }
+ },
+ "p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "requires": {
+ "yocto-queue": "^0.1.0"
+ }
+ },
+ "p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^3.0.2"
+ }
+ },
+ "yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "requires": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ }
+ },
+ "yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true
+ }
+ }
+ },
"cacheable-lookup": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
@@ -21886,6 +22387,12 @@
"whatwg-encoding": "^3.1.1"
}
},
+ "html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true
+ },
"html-reporter": {
"version": "11.8.3",
"resolved": "https://registry.npmjs.org/html-reporter/-/html-reporter-11.8.3.tgz",
@@ -22315,6 +22822,33 @@
"isexe": {
"version": "2.0.0"
},
+ "istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true
+ },
+ "istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "requires": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "requires": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ }
+ },
"jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -22498,6 +23032,12 @@
"version": "5.0.1",
"dev": true
},
+ "json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true
+ },
"jsonfile": {
"version": "4.0.0",
"requires": {
@@ -22624,10 +23164,6 @@
"pify": {
"version": "3.0.0",
"dev": true
- },
- "strip-bom": {
- "version": "3.0.0",
- "dev": true
}
}
},
@@ -22781,6 +23317,15 @@
}
}
},
+ "make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "requires": {
+ "semver": "^7.5.3"
+ }
+ },
"make-error": {
"version": "1.3.6",
"dev": true
@@ -25152,6 +25697,12 @@
"ansi-regex": "^5.0.1"
}
},
+ "strip-bom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+ "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+ "dev": true
+ },
"strip-final-newline": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
@@ -25218,6 +25769,83 @@
}
}
},
+ "test-exclude": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
+ "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
+ "dev": true,
+ "requires": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^10.4.1",
+ "minimatch": "^10.2.2"
+ },
+ "dependencies": {
+ "balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true
+ },
+ "brace-expansion": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "dev": true,
+ "requires": {
+ "balanced-match": "^4.0.2"
+ }
+ },
+ "glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "dev": true,
+ "requires": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "dependencies": {
+ "balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "dev": true,
+ "requires": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^2.0.2"
+ }
+ }
+ }
+ },
+ "minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^5.0.5"
+ }
+ }
+ }
+ },
"text-decoder": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
@@ -25400,6 +26028,17 @@
}
}
},
+ "tsconfig-paths": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
+ "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
+ "dev": true,
+ "requires": {
+ "json5": "^2.2.2",
+ "minimist": "^1.2.6",
+ "strip-bom": "^3.0.0"
+ }
+ },
"tslib": {
"version": "2.6.2"
},
@@ -25686,6 +26325,35 @@
"version": "3.0.1",
"dev": true
},
+ "v8-to-istanbul": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
+ "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/trace-mapping": "^0.3.12",
+ "@types/istanbul-lib-coverage": "^2.0.1",
+ "convert-source-map": "^2.0.0"
+ },
+ "dependencies": {
+ "@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true
+ }
+ }
+ },
"validate-npm-package-license": {
"version": "3.0.4",
"dev": true,
diff --git a/package.json b/package.json
index ef5359397..f902114e7 100644
--- a/package.json
+++ b/package.json
@@ -19,25 +19,28 @@
},
"scripts": {
"browsers:launch": "docker run -it --rm --network=host $(which colima >/dev/null || echo --add-host=host.docker.internal:0.0.0.0) yinfra/html-reporter-browsers",
- "build": "tsc --build && npm run build-bundles && npm run copy-static && node scripts/create-client-scripts-symlinks.js",
- "copy-static": "copyfiles 'src/browser/client-scripts/*' 'src/**/[!cache]*/autogenerated/**/*.json' build",
+ "build": "tsc --build && npm run build-bundles && npm run copy-static",
+ "copy-static": "copyfiles 'src/browser/client-scripts/**/*.js' 'src/**/[!cache]*/autogenerated/**/*.json' build",
"build-node-bundle": "esbuild ./src/bundle/cjs/index.ts --outdir=./build/src/bundle/cjs --bundle --format=cjs --platform=node --target=ES2021",
- "build-browser-bundle": "node ./src/browser/client-scripts/build.js",
+ "build-browser-bundle": "node ./src/browser/client-scripts/build.js ./src/browser/client-scripts/screen-shooter && node ./src/browser/client-scripts/build.js ./src/browser/client-scripts/browser-utils",
"build-bundles": "concurrently -c 'auto' 'npm:build-browser-bundle' 'npm:build-node-bundle --minify'",
- "create-client-scripts-symlinks": "node scripts/create-client-scripts-symlinks.js",
"resolve-ubuntu-dependencies": "ts-node ./src/browser-installer/ubuntu-packages/collect-dependencies",
"check-types": "tsc --project test/tsconfig.json",
"clean": "rimraf build/ *.tsbuildinfo",
"lint": "eslint --cache . && prettier --check .",
"reformat": "eslint --fix . && prettier --write .",
"prettier-watch": "onchange '**' --exclude-path .prettierignore -- prettier --write {{changed}}",
- "test-unit": "npm run create-client-scripts-symlinks && _mocha \"test/!(integration|e2e)/**/*.js\"",
+ "test-unit": "_mocha \"test/!(integration|e2e|browser-env)/**/*.[jt]s\"",
+ "test-unit:coverage": "c8 --all --src=src --reporter=html --reporter=text-summary --exclude=\"build/**\" --exclude=\"test/**\" --exclude=\"**/*.d.ts\" _mocha \"test/!(integration|e2e)/**/*.[jt]s\"",
+ "test-unit:generate-fixtures": "TS_NODE_PROJECT=test/tsconfig.json node -r ts-node/register -r tsconfig-paths/register test/src/browser/screen-shooter/composite-image/fixtures/generate.ts",
"test": "npm run test-unit && npm run check-types && npm run lint",
- "test-integration": "npm run create-client-scripts-symlinks && mocha -r ts-node/register -r test/integration/*/**",
+ "test-integration": "TS_NODE_TRANSPILE_ONLY=1 mocha -r ts-node/register test/integration/*/**",
"test-e2e": "npm run test-e2e:generate-fixtures && npm run test-e2e:run-tests",
"test-e2e:run-tests": "node bin/testplane --config test/e2e/testplane.config.ts",
"test-e2e:generate-fixtures": "node bin/testplane --config test/e2e/fixtures/basic-report/testplane.config.ts || true",
"test-e2e:gui": "node bin/testplane --config test/e2e/testplane.config.ts gui",
+ "test-browser-env": "NODE_OPTIONS='-r tsconfig-paths/register' TS_NODE_PROJECT=./test/browser-env/tsconfig.json node bin/testplane --config test/browser-env/testplane.config.ts",
+ "test-browser-env:gui": "NODE_OPTIONS='-r tsconfig-paths/register' TS_NODE_PROJECT=./test/browser-env/tsconfig.json node bin/testplane gui --config test/browser-env/testplane.config.ts",
"toc": "doctoc docs --title '### Contents'",
"precommit": "npm run lint",
"prepack": "npm run clean && npm run build",
@@ -159,6 +162,7 @@
"aliasify": "1.9.0",
"app-module-path": "2.2.0",
"browserify": "13.3.0",
+ "c8": "10.1.3",
"chai": "4.2.0",
"chai-as-promised": "7.1.1",
"concurrently": "8.2.2",
@@ -182,6 +186,7 @@
"sinon-chai": "3.7.0",
"standard-version": "9.5.0",
"ts-node": "10.9.1",
+ "tsconfig-paths": "4.2.0",
"type-fest": "3.11.1",
"typescript": "5.3.2",
"uglifyify": "3.0.4"
diff --git a/test/browser-env/screens/0467357/chrome/compute-safe-area-header-and-footer.png b/test/browser-env/screens/0467357/chrome/compute-safe-area-header-and-footer.png
new file mode 100644
index 000000000..ec852b087
Binary files /dev/null and b/test/browser-env/screens/0467357/chrome/compute-safe-area-header-and-footer.png differ
diff --git a/test/browser-env/screens/0629e08/chrome/multiple-matches.png b/test/browser-env/screens/0629e08/chrome/multiple-matches.png
new file mode 100644
index 000000000..a041300ac
Binary files /dev/null and b/test/browser-env/screens/0629e08/chrome/multiple-matches.png differ
diff --git a/test/browser-env/screens/06be008/chrome/multiple-selectors.png b/test/browser-env/screens/06be008/chrome/multiple-selectors.png
new file mode 100644
index 000000000..7ffe91ce9
Binary files /dev/null and b/test/browser-env/screens/06be008/chrome/multiple-selectors.png differ
diff --git a/test/browser-env/screens/0a7819f/chrome/pseudo-elements.png b/test/browser-env/screens/0a7819f/chrome/pseudo-elements.png
new file mode 100644
index 000000000..a0e28ab94
Binary files /dev/null and b/test/browser-env/screens/0a7819f/chrome/pseudo-elements.png differ
diff --git a/test/browser-env/screens/0ad6793/chrome/fractional-positions.png b/test/browser-env/screens/0ad6793/chrome/fractional-positions.png
new file mode 100644
index 000000000..3059ff9a4
Binary files /dev/null and b/test/browser-env/screens/0ad6793/chrome/fractional-positions.png differ
diff --git a/test/browser-env/screens/0dc056b/chrome/partially-visible-after-scroll.png b/test/browser-env/screens/0dc056b/chrome/partially-visible-after-scroll.png
new file mode 100644
index 000000000..0f598c06f
Binary files /dev/null and b/test/browser-env/screens/0dc056b/chrome/partially-visible-after-scroll.png differ
diff --git a/test/browser-env/screens/0e38251/chrome/compute-safe-area-sticky-toolbar-in-panel.png b/test/browser-env/screens/0e38251/chrome/compute-safe-area-sticky-toolbar-in-panel.png
new file mode 100644
index 000000000..b5eae3ac7
Binary files /dev/null and b/test/browser-env/screens/0e38251/chrome/compute-safe-area-sticky-toolbar-in-panel.png differ
diff --git a/test/browser-env/screens/122ded8/chrome/compute-safe-area-nested-stacking-overlay-behind.png b/test/browser-env/screens/122ded8/chrome/compute-safe-area-nested-stacking-overlay-behind.png
new file mode 100644
index 000000000..4293ffd97
Binary files /dev/null and b/test/browser-env/screens/122ded8/chrome/compute-safe-area-nested-stacking-overlay-behind.png differ
diff --git a/test/browser-env/screens/14dd26d/chrome/box-shadow.png b/test/browser-env/screens/14dd26d/chrome/box-shadow.png
new file mode 100644
index 000000000..879306570
Binary files /dev/null and b/test/browser-env/screens/14dd26d/chrome/box-shadow.png differ
diff --git a/test/browser-env/screens/1da4083/chrome/compute-safe-area-stacking-context-filter-in-front.png b/test/browser-env/screens/1da4083/chrome/compute-safe-area-stacking-context-filter-in-front.png
new file mode 100644
index 000000000..a9fde325e
Binary files /dev/null and b/test/browser-env/screens/1da4083/chrome/compute-safe-area-stacking-context-filter-in-front.png differ
diff --git a/test/browser-env/screens/2719428/chrome/compute-safe-area-sticky-footer-in-panel.png b/test/browser-env/screens/2719428/chrome/compute-safe-area-sticky-footer-in-panel.png
new file mode 100644
index 000000000..3acdcaeaf
Binary files /dev/null and b/test/browser-env/screens/2719428/chrome/compute-safe-area-sticky-footer-in-panel.png differ
diff --git a/test/browser-env/screens/271e892/chrome/compute-safe-area-scrollable-container-with-border-radius.png b/test/browser-env/screens/271e892/chrome/compute-safe-area-scrollable-container-with-border-radius.png
new file mode 100644
index 000000000..2f37433a0
Binary files /dev/null and b/test/browser-env/screens/271e892/chrome/compute-safe-area-scrollable-container-with-border-radius.png differ
diff --git a/test/browser-env/screens/2b6ec19/chrome/compute-safe-area-sticky-header-with-shadow.png b/test/browser-env/screens/2b6ec19/chrome/compute-safe-area-sticky-header-with-shadow.png
new file mode 100644
index 000000000..31f2f92ae
Binary files /dev/null and b/test/browser-env/screens/2b6ec19/chrome/compute-safe-area-sticky-header-with-shadow.png differ
diff --git a/test/browser-env/screens/2bf5101/chrome/target.png b/test/browser-env/screens/2bf5101/chrome/target.png
new file mode 100644
index 000000000..dfe9f01d8
Binary files /dev/null and b/test/browser-env/screens/2bf5101/chrome/target.png differ
diff --git a/test/browser-env/screens/33527ef/chrome/compute-safe-area-floating-help-button.png b/test/browser-env/screens/33527ef/chrome/compute-safe-area-floating-help-button.png
new file mode 100644
index 000000000..9c588c492
Binary files /dev/null and b/test/browser-env/screens/33527ef/chrome/compute-safe-area-floating-help-button.png differ
diff --git a/test/browser-env/screens/34ca123/chrome/compute-safe-area-stacking-context-filter-in-front.png b/test/browser-env/screens/34ca123/chrome/compute-safe-area-stacking-context-filter-in-front.png
new file mode 100644
index 000000000..8bdc94b9c
Binary files /dev/null and b/test/browser-env/screens/34ca123/chrome/compute-safe-area-stacking-context-filter-in-front.png differ
diff --git a/test/browser-env/screens/3d5257a/chrome/compute-safe-area-fixed-app-header.png b/test/browser-env/screens/3d5257a/chrome/compute-safe-area-fixed-app-header.png
new file mode 100644
index 000000000..af8214c02
Binary files /dev/null and b/test/browser-env/screens/3d5257a/chrome/compute-safe-area-fixed-app-header.png differ
diff --git a/test/browser-env/screens/3d7960d/chrome/compute-safe-area-stacking-context-opacity-behind.png b/test/browser-env/screens/3d7960d/chrome/compute-safe-area-stacking-context-opacity-behind.png
new file mode 100644
index 000000000..520125f74
Binary files /dev/null and b/test/browser-env/screens/3d7960d/chrome/compute-safe-area-stacking-context-opacity-behind.png differ
diff --git a/test/browser-env/screens/4552371/chrome/compute-safe-area-absolute-inside-panel.png b/test/browser-env/screens/4552371/chrome/compute-safe-area-absolute-inside-panel.png
new file mode 100644
index 000000000..2c23a955e
Binary files /dev/null and b/test/browser-env/screens/4552371/chrome/compute-safe-area-absolute-inside-panel.png differ
diff --git a/test/browser-env/screens/4e40d07/chrome/transformed-elements.png b/test/browser-env/screens/4e40d07/chrome/transformed-elements.png
new file mode 100644
index 000000000..5d15e9342
Binary files /dev/null and b/test/browser-env/screens/4e40d07/chrome/transformed-elements.png differ
diff --git a/test/browser-env/screens/51f98b1/chrome/offscreen.png b/test/browser-env/screens/51f98b1/chrome/offscreen.png
new file mode 100644
index 000000000..e63fef18c
Binary files /dev/null and b/test/browser-env/screens/51f98b1/chrome/offscreen.png differ
diff --git a/test/browser-env/screens/5d260ee/chrome/pseudo-elements.png b/test/browser-env/screens/5d260ee/chrome/pseudo-elements.png
new file mode 100644
index 000000000..c7e4f760e
Binary files /dev/null and b/test/browser-env/screens/5d260ee/chrome/pseudo-elements.png differ
diff --git a/test/browser-env/screens/5efd363/chrome/compute-safe-area-modal-backdrop.png b/test/browser-env/screens/5efd363/chrome/compute-safe-area-modal-backdrop.png
new file mode 100644
index 000000000..02a4477e6
Binary files /dev/null and b/test/browser-env/screens/5efd363/chrome/compute-safe-area-modal-backdrop.png differ
diff --git a/test/browser-env/screens/6c02c6b/chrome/inset-box-shadow.png b/test/browser-env/screens/6c02c6b/chrome/inset-box-shadow.png
new file mode 100644
index 000000000..d083cbacf
Binary files /dev/null and b/test/browser-env/screens/6c02c6b/chrome/inset-box-shadow.png differ
diff --git a/test/browser-env/screens/7382524/chrome/fixed-parent-in-overflow-hidden-external-containing-block.png b/test/browser-env/screens/7382524/chrome/fixed-parent-in-overflow-hidden-external-containing-block.png
new file mode 100644
index 000000000..e8da8dede
Binary files /dev/null and b/test/browser-env/screens/7382524/chrome/fixed-parent-in-overflow-hidden-external-containing-block.png differ
diff --git a/test/browser-env/screens/76b005c/chrome/nested-elements.png b/test/browser-env/screens/76b005c/chrome/nested-elements.png
new file mode 100644
index 000000000..7f312f6a7
Binary files /dev/null and b/test/browser-env/screens/76b005c/chrome/nested-elements.png differ
diff --git a/test/browser-env/screens/776b3f5/chrome/target.png b/test/browser-env/screens/776b3f5/chrome/target.png
new file mode 100644
index 000000000..8b0aa89ff
Binary files /dev/null and b/test/browser-env/screens/776b3f5/chrome/target.png differ
diff --git a/test/browser-env/screens/78382b0/chrome/mixed-visibility.png b/test/browser-env/screens/78382b0/chrome/mixed-visibility.png
new file mode 100644
index 000000000..01730eb75
Binary files /dev/null and b/test/browser-env/screens/78382b0/chrome/mixed-visibility.png differ
diff --git a/test/browser-env/screens/79b6388/chrome/single-element.png b/test/browser-env/screens/79b6388/chrome/single-element.png
new file mode 100644
index 000000000..ea58405b7
Binary files /dev/null and b/test/browser-env/screens/79b6388/chrome/single-element.png differ
diff --git a/test/browser-env/screens/817287f/chrome/transformed-pseudo-elements.png b/test/browser-env/screens/817287f/chrome/transformed-pseudo-elements.png
new file mode 100644
index 000000000..de6d65d8d
Binary files /dev/null and b/test/browser-env/screens/817287f/chrome/transformed-pseudo-elements.png differ
diff --git a/test/browser-env/screens/8957391/chrome/compute-safe-area-huge-fixed-banner.png b/test/browser-env/screens/8957391/chrome/compute-safe-area-huge-fixed-banner.png
new file mode 100644
index 000000000..832ce1d63
Binary files /dev/null and b/test/browser-env/screens/8957391/chrome/compute-safe-area-huge-fixed-banner.png differ
diff --git a/test/browser-env/screens/92d8952/chrome/outline.png b/test/browser-env/screens/92d8952/chrome/outline.png
new file mode 100644
index 000000000..f04685c5f
Binary files /dev/null and b/test/browser-env/screens/92d8952/chrome/outline.png differ
diff --git a/test/browser-env/screens/a371dfa/chrome/compute-safe-area-absolute-overlay-outside-panel.png b/test/browser-env/screens/a371dfa/chrome/compute-safe-area-absolute-overlay-outside-panel.png
new file mode 100644
index 000000000..8ca124d50
Binary files /dev/null and b/test/browser-env/screens/a371dfa/chrome/compute-safe-area-absolute-overlay-outside-panel.png differ
diff --git a/test/browser-env/screens/aaf8f86/chrome/compute-safe-area-nested-stacking-overlay-in-front.png b/test/browser-env/screens/aaf8f86/chrome/compute-safe-area-nested-stacking-overlay-in-front.png
new file mode 100644
index 000000000..eb7dd07fb
Binary files /dev/null and b/test/browser-env/screens/aaf8f86/chrome/compute-safe-area-nested-stacking-overlay-in-front.png differ
diff --git a/test/browser-env/screens/ad102bd/chrome/compute-safe-area-target-element-inside-fixed.png b/test/browser-env/screens/ad102bd/chrome/compute-safe-area-target-element-inside-fixed.png
new file mode 100644
index 000000000..ba221c2fb
Binary files /dev/null and b/test/browser-env/screens/ad102bd/chrome/compute-safe-area-target-element-inside-fixed.png differ
diff --git a/test/browser-env/screens/bfcf748/chrome/overflow-hidden.png b/test/browser-env/screens/bfcf748/chrome/overflow-hidden.png
new file mode 100644
index 000000000..d4504b493
Binary files /dev/null and b/test/browser-env/screens/bfcf748/chrome/overflow-hidden.png differ
diff --git a/test/browser-env/screens/c5a4957/chrome/fixed-in-overflow.png b/test/browser-env/screens/c5a4957/chrome/fixed-in-overflow.png
new file mode 100644
index 000000000..e8da8dede
Binary files /dev/null and b/test/browser-env/screens/c5a4957/chrome/fixed-in-overflow.png differ
diff --git a/test/browser-env/screens/d070381/chrome/scrollable-container-scrolled.png b/test/browser-env/screens/d070381/chrome/scrollable-container-scrolled.png
new file mode 100644
index 000000000..90eb456fe
Binary files /dev/null and b/test/browser-env/screens/d070381/chrome/scrollable-container-scrolled.png differ
diff --git a/test/browser-env/screens/de10274/chrome/overflow-scroll.png b/test/browser-env/screens/de10274/chrome/overflow-scroll.png
new file mode 100644
index 000000000..5054b2e0a
Binary files /dev/null and b/test/browser-env/screens/de10274/chrome/overflow-scroll.png differ
diff --git a/test/browser-env/screens/de90b0a/chrome/compute-safe-area-fixed-element-outside-of-viewport.png b/test/browser-env/screens/de90b0a/chrome/compute-safe-area-fixed-element-outside-of-viewport.png
new file mode 100644
index 000000000..2a0b3f631
Binary files /dev/null and b/test/browser-env/screens/de90b0a/chrome/compute-safe-area-fixed-element-outside-of-viewport.png differ
diff --git a/test/browser-env/screens/e0232e8/chrome/compute-safe-area-cookie-consent-bar.png b/test/browser-env/screens/e0232e8/chrome/compute-safe-area-cookie-consent-bar.png
new file mode 100644
index 000000000..157b42f64
Binary files /dev/null and b/test/browser-env/screens/e0232e8/chrome/compute-safe-area-cookie-consent-bar.png differ
diff --git a/test/browser-env/screens/e3b08bb/chrome/pseudo-elements-ancestor-cb.png b/test/browser-env/screens/e3b08bb/chrome/pseudo-elements-ancestor-cb.png
new file mode 100644
index 000000000..b76296202
Binary files /dev/null and b/test/browser-env/screens/e3b08bb/chrome/pseudo-elements-ancestor-cb.png differ
diff --git a/test/browser-env/screens/eb18712/chrome/margin-padding-border.png b/test/browser-env/screens/eb18712/chrome/margin-padding-border.png
new file mode 100644
index 000000000..660627834
Binary files /dev/null and b/test/browser-env/screens/eb18712/chrome/margin-padding-border.png differ
diff --git a/test/browser-env/testplane.config.ts b/test/browser-env/testplane.config.ts
new file mode 100644
index 000000000..c3990b2e1
--- /dev/null
+++ b/test/browser-env/testplane.config.ts
@@ -0,0 +1,76 @@
+///
+
+import path from "path";
+
+const shouldUseLocalBrowser = Boolean(process.env.USE_LOCAL_BROWSER);
+
+export default {
+ gridUrl: shouldUseLocalBrowser ? "local" : "http://127.0.0.1:4444/",
+ baseUrl: shouldUseLocalBrowser ? "http://localhost:5173" : "http://host.docker.internal:5173",
+ sessionsPerBrowser: 1,
+ testsPerSession: 10,
+
+ screenshotsDir: "test/browser-env/screens",
+
+ takeScreenshotOnFails: {
+ testFail: false,
+ assertViewFail: false,
+ },
+
+ system: {
+ workers: 1,
+ testRunEnv: ["browser", { viteConfig: path.join(__dirname, "vite.config.ts") }],
+ },
+
+ sets: {
+ all: {
+ files: [path.join(__dirname, "tests/desktop/**/*.testplane.ts")],
+ browsers: ["chrome"],
+ },
+ mobileDpr3: {
+ files: [path.join(__dirname, "tests/high-pixel-ratio/**/*.testplane.ts")],
+ browsers: ["chrome-mobile-dpr3"],
+ },
+ },
+
+ browsers: {
+ chrome: {
+ windowSize: { width: 1280, height: 1000 },
+ headless: !shouldUseLocalBrowser,
+ desiredCapabilities: {
+ browserName: "chrome",
+ "goog:chromeOptions": {
+ args: ["no-sandbox", "hide-scrollbars", "disable-dev-shm-usage"],
+ binary: shouldUseLocalBrowser ? undefined : "/usr/bin/chromium",
+ },
+ },
+ waitTimeout: 3000,
+ },
+ "chrome-mobile-dpr3": {
+ headless: !shouldUseLocalBrowser,
+ desiredCapabilities: {
+ browserName: "chrome",
+ "goog:chromeOptions": {
+ args: ["no-sandbox", "hide-scrollbars", "disable-dev-shm-usage"],
+ binary: shouldUseLocalBrowser ? undefined : "/usr/bin/chromium",
+ mobileEmulation: {
+ deviceMetrics: {
+ width: 390,
+ height: 844,
+ pixelRatio: 3,
+ mobile: true,
+ touch: true,
+ },
+ },
+ },
+ },
+ },
+ },
+
+ plugins: {
+ "html-reporter/testplane": {
+ enabled: true,
+ path: "test/browser-env/report",
+ },
+ },
+};
diff --git a/test/browser-env/tests/desktop/screenshooter/computeCaptureSpecs.testplane.ts b/test/browser-env/tests/desktop/screenshooter/computeCaptureSpecs.testplane.ts
new file mode 100644
index 000000000..7ff539de8
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/computeCaptureSpecs.testplane.ts
@@ -0,0 +1,357 @@
+import { computeCaptureSpecs } from "../../../../../src/browser/client-scripts/screen-shooter/operations";
+import { createDebugLogger } from "../../../../../src/browser/client-scripts/shared/logger";
+import { visualizeCaptureSpecs } from "../../utils";
+
+describe("computeCaptureSpecs", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ document.body.style.cssText = "";
+ });
+
+ describe("error cases", () => {
+ it("should throw when selectors array is empty", () => {
+ expect(() => computeCaptureSpecs([])).toThrow("No selectors to compute capture area");
+ });
+
+ it("should throw on invalid CSS selector", () => {
+ expect(() => computeCaptureSpecs(["[[[invalid"])).toThrow();
+ });
+ });
+
+ describe("empty results", () => {
+ it("should return empty array when selector matches nothing", () => {
+ const result = computeCaptureSpecs([".nonexistent"]);
+ expect(result.captureSpecs).toEqual([]);
+ });
+
+ it("should return empty array when all matched elements are hidden", async () => {
+ const { default: html } = await import("./fixtures/capture-areas/hidden-elements.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([
+ ".hidden-display",
+ ".hidden-visibility",
+ ".hidden-opacity",
+ ".hidden-zero-size",
+ ]);
+ expect(result.captureSpecs).toEqual([]);
+ });
+ });
+
+ describe("single element", () => {
+ it("should return rect for a single visible element", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/single-element.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".target"]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("single-element");
+ });
+
+ it("should expand rect to include box-shadow", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/box-shadow.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".shadow-target"]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("box-shadow");
+ });
+
+ it("should not expand rect for inset box-shadow", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/inset-box-shadow.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".inset-shadow-target"]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("inset-box-shadow");
+ });
+
+ it("should expand rect to include outline", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/outline.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".outline-target"]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("outline");
+ });
+
+ it("should not include pseudo-element geometry in base element capture rect", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/pseudo-elements.html?raw");
+ document.body.innerHTML = html;
+
+ const elementResult = computeCaptureSpecs([".pseudo-target"]);
+
+ visualizeCaptureSpecs(elementResult.captureSpecs);
+ await browser.assertView("pseudo-elements");
+ });
+
+ it("should capture pseudo-elements when they are passed explicitly as selectors", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/pseudo-elements.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".pseudo-target::before", ".pseudo-target::after"]);
+ expect(result.captureSpecs).toHaveLength(2);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("pseudo-elements");
+ });
+
+ it("should capture pseudo-elements positioned relative to an ancestor (not direct parent) when selected explicitly", async ({
+ browser,
+ }) => {
+ const { default: html } = await import("./fixtures/capture-areas/pseudo-elements-ancestor-cb.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".parent::before", ".parent::after"]);
+ expect(result.captureSpecs).toHaveLength(2);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("pseudo-elements-ancestor-cb");
+ });
+
+ it("should account for CSS transforms (translate, rotate, scale, skew) on pseudo-elements selected explicitly", async ({
+ browser,
+ }) => {
+ const { default: html } = await import("./fixtures/capture-areas/transformed-pseudo-elements.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".target::before", ".target::after"]);
+ expect(result.captureSpecs).toHaveLength(2);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("transformed-pseudo-elements");
+ });
+
+ it("should handle CSS transforms on the element itself (rotate, scale, translate, skew, combined)", async ({
+ browser,
+ }) => {
+ const { default: html } = await import("./fixtures/capture-areas/transformed-element.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".rotated", ".scaled", ".translated", ".skewed", ".combined"]);
+ expect(result.captureSpecs).toHaveLength(5);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("transformed-elements");
+ });
+ });
+
+ describe("multiple elements", () => {
+ it("should return rects for multiple selectors each matching one element", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/multiple-elements.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".a", ".b", ".c"]);
+ expect(result.captureSpecs).toHaveLength(3);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("multiple-selectors");
+ });
+
+ it("should return rect for the first element when selector matches multiple elements", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/multiple-selector-matches.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".item"]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("multiple-matches");
+ });
+
+ it("should return duplicate rects when multiple selectors match the same element", async () => {
+ const { default: html } = await import("./fixtures/capture-areas/single-element.html?raw");
+ document.body.innerHTML = html;
+
+ // Both selectors match the same .target element
+ const result = computeCaptureSpecs([".target", "div.target"]);
+ expect(result.captureSpecs).toHaveLength(2);
+
+ // Both rects should be identical
+ expect(result.captureSpecs[0]).toEqual(result.captureSpecs[1]);
+ });
+
+ it("should only return visible elements from a mix of visible and hidden", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/hidden-elements.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([
+ ".visible",
+ ".hidden-display",
+ ".hidden-visibility",
+ ".hidden-opacity",
+ ".hidden-zero-size",
+ ]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("mixed-visibility");
+ });
+ });
+
+ describe("scrollable container", () => {
+ it("should compute correct rect for element inside a scrolled container", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/scrollable-container.html?raw");
+ document.body.innerHTML = html;
+
+ const container = document.querySelector(".container")!;
+ container.scrollTop = 200;
+
+ const result = computeCaptureSpecs([".target"]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("scrollable-container-scrolled");
+ });
+ });
+
+ describe("box model", () => {
+ it("should include padding and border but not margin in bounding rect", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/margin-padding-border.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".box-model-target"]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("margin-padding-border");
+ });
+
+ it("should handle element partially off-screen", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/offscreen-element.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".offscreen"]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("offscreen");
+ });
+
+ it("should handle long element partially visible only after window scroll", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/partially-visible-after-scroll.html?raw");
+ document.body.innerHTML = html;
+
+ window.scrollTo(0, 560);
+
+ const result = computeCaptureSpecs([".long-target"]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("partially-visible-after-scroll");
+ });
+
+ it("should handle elements with fractional pixel positions", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/fractional-positions.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".fractional", ".transformed"]);
+ expect(result.captureSpecs).toHaveLength(2);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("fractional-positions");
+ });
+ });
+
+ describe("nested elements", () => {
+ it("should return separate rects for parent and children", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/nested-elements.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".parent", ".child"]);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("nested-elements");
+ });
+ });
+
+ describe("overflow clipping", () => {
+ it("should clip visible rect to overflow:hidden container while full rect extends beyond", async ({
+ browser,
+ }) => {
+ const { default: html } = await import("./fixtures/capture-areas/overflow-hidden.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".target"]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("overflow-hidden");
+ });
+
+ it("should clip visible rect to overflow:scroll container", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/overflow-scroll.html?raw");
+ document.body.innerHTML = html;
+
+ const logger = createDebugLogger({ debug: ["screen-shooter"] }, "screen-shooter");
+ const result = computeCaptureSpecs([".target"], logger);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("overflow-scroll");
+ });
+
+ it("should not clip fixed-position element by overflow:hidden ancestor", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/fixed-in-overflow.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".target"]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ const spec = result.captureSpecs[0];
+ // Fixed element escapes overflow clipping — full and visible should be the same
+ expect(spec.full.width).toBe(spec.visible.width);
+ expect(spec.full.height).toBe(spec.visible.height);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("fixed-in-overflow");
+ });
+
+ it("should not clip element inside fixed-position parent by overflow:hidden ancestor", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/capture-areas/fixed-parent-in-overflow-hidden.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".target"]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("fixed-parent-in-overflow-hidden-external-containing-block");
+ });
+
+ it("should not clip absolutely positioned element when containing block is outside overflow:hidden ancestor", async ({
+ browser,
+ }) => {
+ const { default: html } = await import(
+ "./fixtures/capture-areas/absolute-in-overflow-hidden-external-containing-block.html?raw"
+ );
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".target"]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("target");
+ });
+
+ it("should not clip element inside absolutely positioned parent because it escapes overflow:hidden ancestor", async ({
+ browser,
+ }) => {
+ const { default: html } = await import("./fixtures/capture-areas/absolute-overflows-parent.html?raw");
+ document.body.innerHTML = html;
+
+ const result = computeCaptureSpecs([".target"]);
+ expect(result.captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(result.captureSpecs);
+ await browser.assertView("target");
+ });
+ });
+});
diff --git a/test/browser-env/tests/desktop/screenshooter/computePixelRatio.testplane.ts b/test/browser-env/tests/desktop/screenshooter/computePixelRatio.testplane.ts
new file mode 100644
index 000000000..92ba9e049
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/computePixelRatio.testplane.ts
@@ -0,0 +1,15 @@
+import { computePixelRatio } from "../../../../../src/browser/client-scripts/screen-shooter/operations";
+
+describe("computePixelRatio", () => {
+ testplane.only.in("chrome-mobile-dpr3");
+ it("returns emulated mobile pixel ratio from window.devicePixelRatio", () => {
+ const pixelRatio = computePixelRatio().pixelRatio;
+ expect(pixelRatio).toBe(3);
+ });
+
+ testplane.only.in("chrome-mobile-dpr3");
+ it("returns 1 when usePixelRatio is disabled", () => {
+ const pixelRatio = computePixelRatio(false).pixelRatio;
+ expect(pixelRatio).toBe(1);
+ });
+});
diff --git a/test/browser-env/tests/desktop/screenshooter/computeSafeArea.testplane.ts b/test/browser-env/tests/desktop/screenshooter/computeSafeArea.testplane.ts
new file mode 100644
index 000000000..fbdd46730
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/computeSafeArea.testplane.ts
@@ -0,0 +1,352 @@
+import {
+ computeCaptureSpecs,
+ computeSafeArea,
+ computeViewportSize,
+} from "../../../../../src/browser/client-scripts/screen-shooter/operations";
+import { createDebugLogger } from "../../../../../src/browser/client-scripts/shared/logger";
+import { visualizeCaptureSpecs, visualizeSafeArea } from "../../utils";
+
+describe("computeSafeArea", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ document.body.style.cssText = "";
+ });
+
+ it("should shrink safe area below a fixed app header", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/safe-areas/fixed-app-header.html?raw");
+ document.body.innerHTML = html;
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors).safeArea;
+ const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+
+ expect(captureSpecs).toHaveLength(1);
+ expect(safeArea.top).toBeGreaterThan(0);
+
+ visualizeCaptureSpecs(captureSpecs);
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-fixed-app-header");
+ });
+
+ it("should shrink from bottom for cookie consent bar", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/safe-areas/cookie-consent-bar.html?raw");
+ document.body.innerHTML = html;
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors).safeArea;
+ const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+
+ expect(captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(captureSpecs);
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-cookie-consent-bar");
+ });
+
+ it("should not shrink for backdrop behind focused modal", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/safe-areas/modal-with-backdrop.html?raw");
+ document.body.innerHTML = html;
+
+ const selectors = [".target-modal"];
+ const safeArea = computeSafeArea(selectors).safeArea;
+ const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+
+ visualizeCaptureSpecs(captureSpecs);
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-modal-backdrop");
+ });
+
+ it("should ignore fixed help button with no horizontal overlap", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/safe-areas/floating-help-button.html?raw");
+ document.body.innerHTML = html;
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors).safeArea;
+ const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+
+ visualizeCaptureSpecs(captureSpecs);
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-floating-help-button");
+ });
+
+ it("should handle sticky toolbar inside scrollable panel", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/safe-areas/sticky-toolbar-in-panel.html?raw");
+ document.body.innerHTML = html;
+
+ const panel = document.querySelector(".panel");
+ if (!panel) {
+ throw new Error("Failed to find .panel");
+ }
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors, panel).safeArea;
+ const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+
+ expect(captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(captureSpecs);
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-sticky-toolbar-in-panel");
+ });
+
+ it("should compute safe area for capture inside scrollable panel with large border radius", async ({ browser }) => {
+ const { default: html } = await import(
+ "./fixtures/safe-areas/scrollable-container-with-border-radius.html?raw"
+ );
+ document.body.innerHTML = html;
+
+ const panel = document.querySelector(".panel");
+ if (!panel) {
+ throw new Error("Failed to find .panel");
+ }
+
+ const selectors = [".content"];
+ const safeArea = computeSafeArea(selectors, panel).safeArea;
+ const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+
+ visualizeCaptureSpecs(captureSpecs);
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-scrollable-container-with-border-radius");
+ });
+
+ it("should compute safe area for target element inside fixed element", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/safe-areas/target-element-inside-fixed.html?raw");
+ document.body.innerHTML = html;
+
+ const logger = createDebugLogger(
+ { debug: ["testplane:screenshots:browser:computeSafeArea"] },
+ "testplane:screenshots:browser:computeSafeArea",
+ );
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors, undefined, logger).safeArea;
+ const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+
+ visualizeCaptureSpecs(captureSpecs);
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-target-element-inside-fixed");
+
+ console.log(logger.log);
+ });
+
+ it("should handle sticky header with shadow", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/safe-areas/sticky-header-with-shadow.html?raw");
+ document.body.innerHTML = html;
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors).safeArea;
+
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-sticky-header-with-shadow");
+ });
+
+ it("should not shrink safe area if fixed element is outside of viewport", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/safe-areas/fixed-element-outside-of-viewport.html?raw");
+ document.body.innerHTML = html;
+
+ const logger = createDebugLogger(
+ { debug: ["testplane:screenshots:browser:computeSafeArea"] },
+ "testplane:screenshots:browser:computeSafeArea",
+ );
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors, undefined, logger).safeArea;
+ const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+
+ visualizeCaptureSpecs(captureSpecs);
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-fixed-element-outside-of-viewport");
+ });
+
+ it("should ignore obstruction if shrinking would exceed half of original safe area", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/safe-areas/huge-fixed-banner.html?raw");
+ document.body.innerHTML = html;
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors).safeArea;
+ const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+
+ visualizeCaptureSpecs(captureSpecs);
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-huge-fixed-banner");
+ });
+
+ it("should return full viewport when no selectors match", () => {
+ document.body.innerHTML = "
some content
";
+
+ const { viewportSize } = computeViewportSize();
+ const safeArea = computeSafeArea([".does-not-exist"]).safeArea;
+
+ expect(safeArea.top).toBe(0);
+ expect(safeArea.height).toBe(viewportSize.height);
+ });
+
+ it("should shrink from bottom for sticky footer in panel", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/safe-areas/sticky-footer-in-panel.html?raw");
+ document.body.innerHTML = html;
+
+ const panel = document.querySelector(".panel");
+ if (!panel) {
+ throw new Error("Failed to find .panel");
+ }
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors, panel).safeArea;
+ const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+
+ expect(captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(captureSpecs);
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-sticky-footer-in-panel");
+ });
+
+ it("should not shrink for absolute element whose containing block is inside the panel", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/safe-areas/absolute-inside-scroll-panel.html?raw");
+ document.body.innerHTML = html;
+
+ const panel = document.querySelector(".panel");
+ if (!panel) {
+ throw new Error("Failed to find .panel");
+ }
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors, panel).safeArea;
+ const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+
+ visualizeCaptureSpecs(captureSpecs);
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-absolute-inside-panel");
+ });
+
+ it("should shrink from both top and bottom for simultaneous header and footer", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/safe-areas/header-and-footer.html?raw");
+ document.body.innerHTML = html;
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors).safeArea;
+ const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+
+ const headerBcr = document.querySelector(".header")!.getBoundingClientRect();
+ const footerBcr = document.querySelector(".footer")!.getBoundingClientRect();
+
+ // Both elements must shrink the safe area — top moves down, bottom moves up
+ expect(safeArea.top).toBeCloseTo(headerBcr.bottom, 0);
+ expect(safeArea.top + safeArea.height).toBeCloseTo(footerBcr.top, 0);
+
+ visualizeCaptureSpecs(captureSpecs);
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-header-and-footer");
+ });
+
+ it("should treat absolute overlay outside panel as interference for panel scrolling", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/safe-areas/absolute-overlay-outside-panel.html?raw");
+ document.body.innerHTML = html;
+
+ const panel = document.querySelector(".panel");
+ if (!panel) {
+ throw new Error("Failed to find .panel");
+ }
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors, panel).safeArea;
+ const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+
+ expect(captureSpecs).toHaveLength(1);
+
+ visualizeCaptureSpecs(captureSpecs);
+ visualizeSafeArea(safeArea.top, safeArea.height);
+
+ await browser.assertView("compute-safe-area-absolute-overlay-outside-panel");
+ });
+
+ it("should not shrink when fixed element is behind target due to z-index despite opacity stacking context", async ({
+ browser,
+ }) => {
+ const { default: html } = await import("./fixtures/safe-areas/stacking-context-opacity-behind.html?raw");
+ document.body.innerHTML = html;
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors).safeArea;
+
+ // overlay: z:5 / target's container: z:10 -> isChainBehind returns true -> no shrink
+ const { viewportSize } = computeViewportSize();
+ expect(safeArea.top).toBe(0);
+ expect(safeArea.height).toBe(viewportSize.height);
+
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-stacking-context-opacity-behind");
+ });
+
+ it("should shrink for sticky header", async ({ browser }) => {
+ const logger = createDebugLogger(
+ { debug: ["testplane:screenshots:browser:computeSafeArea"] },
+ "testplane:screenshots:browser:computeSafeArea",
+ );
+
+ const { default: html } = await import("./fixtures/safe-areas/simple-sticky-header.html?raw");
+ document.body.innerHTML = html;
+
+ window.scrollTo(0, document.documentElement.scrollHeight - document.documentElement.clientHeight - 200);
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors, undefined, logger).safeArea;
+
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-stacking-context-filter-in-front");
+ });
+
+ it("should shrink for fixed header that creates stacking context via filter and is in front", async ({
+ browser,
+ }) => {
+ const { default: html } = await import("./fixtures/safe-areas/stacking-context-filter-in-front.html?raw");
+ document.body.innerHTML = html;
+
+ window.scrollTo(0, 5000);
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors).safeArea;
+
+ // header: z:10 / target: z:0 -> isChainBehind returns false -> does shrink
+ const headerBcr = document.querySelector(".header")!.getBoundingClientRect();
+ expect(safeArea.top).toBeCloseTo(headerBcr.bottom, 0);
+
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-stacking-context-filter-in-front");
+ });
+
+ it("should not shrink when fixed overlay is behind a nested stacking context containing the target", async ({
+ browser,
+ }) => {
+ const { default: html } = await import("./fixtures/safe-areas/nested-stacking-overlay-behind.html?raw");
+ document.body.innerHTML = html;
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors).safeArea;
+
+ // overlay: z:50 / app-shell: z:100 -> common ctx = documentElement -> 50 < 100 -> behind -> no shrink
+ const { viewportSize } = computeViewportSize();
+ expect(safeArea.top).toBe(0);
+ expect(safeArea.height).toBe(viewportSize.height);
+
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-nested-stacking-overlay-behind");
+ });
+
+ it("should shrink when fixed overlay is in front of a nested stacking context containing the target", async ({
+ browser,
+ }) => {
+ const { default: html } = await import("./fixtures/safe-areas/nested-stacking-overlay-in-front.html?raw");
+ document.body.innerHTML = html;
+
+ const selectors = [".target"];
+ const safeArea = computeSafeArea(selectors).safeArea;
+
+ // overlay: z:50 / app-shell: z:10 -> common ctx = documentElement -> 50 < 10 is false -> in front -> does shrink
+ const overlayBcr = document.querySelector(".fixed-overlay")!.getBoundingClientRect();
+ expect(safeArea.top).toBeCloseTo(overlayBcr.bottom, 0);
+
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-nested-stacking-overlay-in-front");
+ });
+});
diff --git a/test/browser-env/tests/desktop/screenshooter/computeViewportSize.testplane.ts b/test/browser-env/tests/desktop/screenshooter/computeViewportSize.testplane.ts
new file mode 100644
index 000000000..fa836217b
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/computeViewportSize.testplane.ts
@@ -0,0 +1,10 @@
+import { computeViewportSize } from "../../../../../src/browser/client-scripts/screen-shooter/operations";
+
+describe("computeViewportSize", () => {
+ it("should return the viewport size", () => {
+ const viewportSize = computeViewportSize();
+
+ expect(viewportSize.viewportSize.width).toBe(1280);
+ expect(viewportSize.viewportSize.height).toBe(1000);
+ });
+});
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/absolute-in-overflow-hidden-external-containing-block.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/absolute-in-overflow-hidden-external-containing-block.html
new file mode 100644
index 000000000..8e3df5844
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/absolute-in-overflow-hidden-external-containing-block.html
@@ -0,0 +1,44 @@
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/absolute-overflows-parent.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/absolute-overflows-parent.html
new file mode 100644
index 000000000..4ba1cdf7d
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/absolute-overflows-parent.html
@@ -0,0 +1,50 @@
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/box-shadow.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/box-shadow.html
new file mode 100644
index 000000000..658d609ca
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/box-shadow.html
@@ -0,0 +1,14 @@
+
+Box shadow element
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/fixed-in-overflow.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/fixed-in-overflow.html
new file mode 100644
index 000000000..08ecd42a9
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/fixed-in-overflow.html
@@ -0,0 +1,34 @@
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/fixed-parent-in-overflow-hidden.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/fixed-parent-in-overflow-hidden.html
new file mode 100644
index 000000000..18855f566
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/fixed-parent-in-overflow-hidden.html
@@ -0,0 +1,41 @@
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/fractional-positions.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/fractional-positions.html
new file mode 100644
index 000000000..7dbcf2361
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/fractional-positions.html
@@ -0,0 +1,22 @@
+
+Fractional width
+Transformed
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/hidden-elements.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/hidden-elements.html
new file mode 100644
index 000000000..0824e7cb7
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/hidden-elements.html
@@ -0,0 +1,40 @@
+
+Visible
+Hidden (display:none)
+Hidden (visibility:hidden)
+Hidden (opacity:0)
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/inset-box-shadow.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/inset-box-shadow.html
new file mode 100644
index 000000000..2084a46b9
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/inset-box-shadow.html
@@ -0,0 +1,14 @@
+
+Inset box shadow
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/margin-padding-border.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/margin-padding-border.html
new file mode 100644
index 000000000..1b49b3259
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/margin-padding-border.html
@@ -0,0 +1,15 @@
+
+Box model
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/multiple-elements.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/multiple-elements.html
new file mode 100644
index 000000000..6a7ce05b5
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/multiple-elements.html
@@ -0,0 +1,33 @@
+
+Element A
+Element B
+Element C
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/multiple-selector-matches.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/multiple-selector-matches.html
new file mode 100644
index 000000000..95767a96c
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/multiple-selector-matches.html
@@ -0,0 +1,29 @@
+
+Item 1
+Item 2
+Item 3
+Item 4
+Item 5
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/nested-elements.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/nested-elements.html
new file mode 100644
index 000000000..b4e49266c
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/nested-elements.html
@@ -0,0 +1,23 @@
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/offscreen-element.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/offscreen-element.html
new file mode 100644
index 000000000..e7519b393
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/offscreen-element.html
@@ -0,0 +1,15 @@
+
+Partially off-screen
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/outline.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/outline.html
new file mode 100644
index 000000000..9aec36bfa
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/outline.html
@@ -0,0 +1,14 @@
+
+Outline element
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/overflow-hidden.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/overflow-hidden.html
new file mode 100644
index 000000000..c59795696
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/overflow-hidden.html
@@ -0,0 +1,34 @@
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/overflow-scroll.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/overflow-scroll.html
new file mode 100644
index 000000000..0e0a0e31a
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/overflow-scroll.html
@@ -0,0 +1,49 @@
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/partially-visible-after-scroll.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/partially-visible-after-scroll.html
new file mode 100644
index 000000000..cf5d45caf
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/partially-visible-after-scroll.html
@@ -0,0 +1,36 @@
+
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/pseudo-elements-ancestor-cb.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/pseudo-elements-ancestor-cb.html
new file mode 100644
index 000000000..96bceef26
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/pseudo-elements-ancestor-cb.html
@@ -0,0 +1,50 @@
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/pseudo-elements.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/pseudo-elements.html
new file mode 100644
index 000000000..abccf8cb6
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/pseudo-elements.html
@@ -0,0 +1,37 @@
+
+Pseudo-element target
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/scrollable-container.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/scrollable-container.html
new file mode 100644
index 000000000..b8b9698f3
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/scrollable-container.html
@@ -0,0 +1,34 @@
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/single-element.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/single-element.html
new file mode 100644
index 000000000..d634a13e0
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/single-element.html
@@ -0,0 +1,13 @@
+
+Single element
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/transformed-element.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/transformed-element.html
new file mode 100644
index 000000000..dcb63f4cc
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/transformed-element.html
@@ -0,0 +1,41 @@
+
+
+
Rotated
+
Scaled
+
Translated
+
Skewed
+
Combined
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/transformed-pseudo-elements.html b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/transformed-pseudo-elements.html
new file mode 100644
index 000000000..cdec57eb2
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/capture-areas/transformed-pseudo-elements.html
@@ -0,0 +1,46 @@
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/common-scroll-parent/analytics-dashboard.html b/test/browser-env/tests/desktop/screenshooter/fixtures/common-scroll-parent/analytics-dashboard.html
new file mode 100644
index 000000000..7b02cae6e
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/common-scroll-parent/analytics-dashboard.html
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/common-scroll-parent/fixed-modal.html b/test/browser-env/tests/desktop/screenshooter/fixtures/common-scroll-parent/fixed-modal.html
new file mode 100644
index 000000000..19d88e918
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/common-scroll-parent/fixed-modal.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+ Deployment Confirmation
+
+
Review services before publishing.
+
modal target
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/common-scroll-parent/split-panels.html b/test/browser-env/tests/desktop/screenshooter/fixtures/common-scroll-parent/split-panels.html
new file mode 100644
index 000000000..c971daf01
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/common-scroll-parent/split-panels.html
@@ -0,0 +1,67 @@
+
+
+
+
+ Build Queue
+
+ left panel target
+
+
+
+
+ Logs
+
+ right panel target
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/absolute-inside-scroll-panel.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/absolute-inside-scroll-panel.html
new file mode 100644
index 000000000..13c027823
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/absolute-inside-scroll-panel.html
@@ -0,0 +1,67 @@
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/absolute-overlay-outside-panel.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/absolute-overlay-outside-panel.html
new file mode 100644
index 000000000..bc69affea
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/absolute-overlay-outside-panel.html
@@ -0,0 +1,69 @@
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/cookie-consent-bar.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/cookie-consent-bar.html
new file mode 100644
index 000000000..1657a0b6c
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/cookie-consent-bar.html
@@ -0,0 +1,44 @@
+
+
+Product Details
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/fixed-app-header.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/fixed-app-header.html
new file mode 100644
index 000000000..f160f31e3
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/fixed-app-header.html
@@ -0,0 +1,45 @@
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/fixed-element-outside-of-viewport.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/fixed-element-outside-of-viewport.html
new file mode 100644
index 000000000..877118df4
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/fixed-element-outside-of-viewport.html
@@ -0,0 +1,33 @@
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/floating-help-button.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/floating-help-button.html
new file mode 100644
index 000000000..85226175c
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/floating-help-button.html
@@ -0,0 +1,43 @@
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/half-viewport-banner.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/half-viewport-banner.html
new file mode 100644
index 000000000..2fbb0ed8d
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/half-viewport-banner.html
@@ -0,0 +1,40 @@
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/header-and-footer.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/header-and-footer.html
new file mode 100644
index 000000000..433cd5d49
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/header-and-footer.html
@@ -0,0 +1,62 @@
+
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/huge-fixed-banner.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/huge-fixed-banner.html
new file mode 100644
index 000000000..c9d848477
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/huge-fixed-banner.html
@@ -0,0 +1,34 @@
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/modal-with-backdrop.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/modal-with-backdrop.html
new file mode 100644
index 000000000..13d1a295f
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/modal-with-backdrop.html
@@ -0,0 +1,47 @@
+
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/nested-stacking-overlay-behind.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/nested-stacking-overlay-behind.html
new file mode 100644
index 000000000..7fffe4790
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/nested-stacking-overlay-behind.html
@@ -0,0 +1,58 @@
+
+
+
+Overlay (z:50) — behind app-shell (z:100)
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/nested-stacking-overlay-in-front.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/nested-stacking-overlay-in-front.html
new file mode 100644
index 000000000..23d03575e
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/nested-stacking-overlay-in-front.html
@@ -0,0 +1,59 @@
+
+
+
+Overlay (z:50) — in front of app-shell (z:10)
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/scrollable-container-with-border-radius.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/scrollable-container-with-border-radius.html
new file mode 100644
index 000000000..c76b5d205
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/scrollable-container-with-border-radius.html
@@ -0,0 +1,62 @@
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/simple-sticky-header.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/simple-sticky-header.html
new file mode 100644
index 000000000..f550f1a05
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/simple-sticky-header.html
@@ -0,0 +1,54 @@
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/stacking-context-filter-in-front.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/stacking-context-filter-in-front.html
new file mode 100644
index 000000000..c7e1c1989
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/stacking-context-filter-in-front.html
@@ -0,0 +1,58 @@
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/stacking-context-opacity-behind.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/stacking-context-opacity-behind.html
new file mode 100644
index 000000000..56d1a624c
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/stacking-context-opacity-behind.html
@@ -0,0 +1,61 @@
+
+
+
+Overlay (z:5, opacity:0.9) — behind target layer (z:10)
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/sticky-footer-in-panel.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/sticky-footer-in-panel.html
new file mode 100644
index 000000000..044e1317e
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/sticky-footer-in-panel.html
@@ -0,0 +1,57 @@
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/sticky-header-with-shadow.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/sticky-header-with-shadow.html
new file mode 100644
index 000000000..3ca6056d7
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/sticky-header-with-shadow.html
@@ -0,0 +1,35 @@
+
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/sticky-toolbar-in-panel.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/sticky-toolbar-in-panel.html
new file mode 100644
index 000000000..da5e0e4c2
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/sticky-toolbar-in-panel.html
@@ -0,0 +1,57 @@
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/target-element-inside-fixed.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/target-element-inside-fixed.html
new file mode 100644
index 000000000..bd3276d1e
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/target-element-inside-fixed.html
@@ -0,0 +1,46 @@
+
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-element-by/layout.html b/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-element-by/layout.html
new file mode 100644
index 000000000..dda82c654
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-element-by/layout.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
Scrollable panel content
+
Large area to provide realistic scrollHeight and scrollWidth.
+
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/below-fold-window.html b/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/below-fold-window.html
new file mode 100644
index 000000000..d059445c3
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/below-fold-window.html
@@ -0,0 +1,23 @@
+
+
+Below fold
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/labeled-scroll-panel.html b/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/labeled-scroll-panel.html
new file mode 100644
index 000000000..c44842a3f
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/labeled-scroll-panel.html
@@ -0,0 +1,41 @@
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/nested-scroll.html b/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/nested-scroll.html
new file mode 100644
index 000000000..d6f8bc41f
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/nested-scroll.html
@@ -0,0 +1,51 @@
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/scrollable-container.html b/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/scrollable-container.html
new file mode 100644
index 000000000..474aa9e2e
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/scrollable-container.html
@@ -0,0 +1,34 @@
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/visible-target.html b/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/visible-target.html
new file mode 100644
index 000000000..a1fe05650
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/visible-target.html
@@ -0,0 +1,22 @@
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/window-then-panel-scroll.html b/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/window-then-panel-scroll.html
new file mode 100644
index 000000000..3419d91c2
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/scroll-to-capture/window-then-panel-scroll.html
@@ -0,0 +1,53 @@
+
+
+
+
diff --git a/test/browser-env/tests/desktop/screenshooter/getCommonScrollParent.testplane.ts b/test/browser-env/tests/desktop/screenshooter/getCommonScrollParent.testplane.ts
new file mode 100644
index 000000000..6bf3a804b
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/getCommonScrollParent.testplane.ts
@@ -0,0 +1,89 @@
+import { getCommonScrollParent } from "../../../../../src/browser/client-scripts/screen-shooter/utils/scroll";
+
+function query(selector: string): Element {
+ const el = document.querySelector(selector);
+ if (!el) {
+ throw new Error(`Element not found: ${selector}`);
+ }
+ return el;
+}
+
+describe("getCommonScrollParent", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ document.body.style.cssText = "";
+ window.scrollTo(0, 0);
+ });
+
+ it("should return documentElement when selectors array is empty", () => {
+ const result = getCommonScrollParent([]);
+
+ expect(result).toBe(document.documentElement);
+ });
+
+ it("should return documentElement when none of selectors match", async () => {
+ const { default: html } = await import("./fixtures/common-scroll-parent/analytics-dashboard.html?raw");
+ document.body.innerHTML = html;
+
+ const result = getCommonScrollParent([".missing-a", ".missing-b"]);
+
+ expect(result).toBe(document.documentElement);
+ });
+
+ it("should return nearest scrollable parent for a single matched selector", async () => {
+ const { default: html } = await import("./fixtures/common-scroll-parent/analytics-dashboard.html?raw");
+ document.body.innerHTML = html;
+
+ const result = getCommonScrollParent(["#single-in-dashboard"]);
+ const expected = query(".dashboard-scroll");
+
+ expect(result).toBe(expected);
+ });
+
+ it("should return documentElement for a single matched selector inside fixed overlay", async () => {
+ const { default: html } = await import("./fixtures/common-scroll-parent/fixed-modal.html?raw");
+ document.body.innerHTML = html;
+
+ const result = getCommonScrollParent(["#modal-target"]);
+ expect(result).toBe(document.documentElement);
+ });
+
+ it("should return deepest common scroll parent for multiple selectors in same nested scroll container", async () => {
+ const { default: html } = await import("./fixtures/common-scroll-parent/analytics-dashboard.html?raw");
+ document.body.innerHTML = html;
+
+ const result = getCommonScrollParent(["#feed-item-a", "#feed-item-b"]);
+ const expected = query(".feed-scroll");
+
+ expect(result).toBe(expected);
+ });
+
+ it("should return outer shared scroll parent when elements belong to different nested scroll levels", async () => {
+ const { default: html } = await import("./fixtures/common-scroll-parent/analytics-dashboard.html?raw");
+ document.body.innerHTML = html;
+
+ const result = getCommonScrollParent(["#dashboard-item", "#feed-item-b"]);
+ const expected = query(".dashboard-scroll");
+
+ expect(result).toBe(expected);
+ });
+
+ it("should return documentElement when matched elements belong to different independent scroll roots", async () => {
+ const { default: html } = await import("./fixtures/common-scroll-parent/split-panels.html?raw");
+ document.body.innerHTML = html;
+
+ const result = getCommonScrollParent(["#left-target", "#right-target"]);
+
+ expect(result).toBe(document.documentElement);
+ });
+
+ it("should ignore unmatched selectors and compute parent from matched ones", async () => {
+ const { default: html } = await import("./fixtures/common-scroll-parent/split-panels.html?raw");
+ document.body.innerHTML = html;
+
+ const result = getCommonScrollParent(["#left-target", ".non-existent-selector"]);
+ const expected = query(".left-panel");
+
+ expect(result).toBe(expected);
+ });
+});
diff --git a/test/browser-env/tests/desktop/screenshooter/scrollElementBy.testplane.ts b/test/browser-env/tests/desktop/screenshooter/scrollElementBy.testplane.ts
new file mode 100644
index 000000000..1ebbbebfe
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/scrollElementBy.testplane.ts
@@ -0,0 +1,129 @@
+import { scrollElementBy } from "../../../../../src/browser/client-scripts/screen-shooter/utils/scroll";
+import { Coord } from "../../../../../src/browser/isomorphic/geometry";
+
+function query(selector: string): Element {
+ const el = document.querySelector(selector);
+ if (!el) {
+ throw new Error(`Element not found: ${selector}`);
+ }
+ return el;
+}
+
+function getMaxScrollY(element: Element): number {
+ return element.scrollHeight - element.clientHeight;
+}
+
+describe("scrollElementBy", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ document.body.style.cssText = "";
+ window.scrollTo(0, 0);
+ });
+
+ describe("documentElement scroll", () => {
+ it("should scroll root by floored delta value", async () => {
+ const { default: html } = await import("./fixtures/scroll-element-by/layout.html?raw");
+ document.body.innerHTML = html;
+
+ scrollElementBy(document.documentElement, 120.9 as Coord<"page", "css", "y">);
+
+ expect(window.scrollY).toBe(120);
+ });
+
+ it("should clamp root scroll at zero for large negative delta", async () => {
+ const { default: html } = await import("./fixtures/scroll-element-by/layout.html?raw");
+ document.body.innerHTML = html;
+ window.scrollTo(0, 260);
+
+ scrollElementBy(document.documentElement, -1000 as Coord<"page", "css", "y">);
+
+ expect(window.scrollY).toBe(0);
+ });
+
+ it("should clamp root scroll at max for overshoot delta", async () => {
+ const { default: html } = await import("./fixtures/scroll-element-by/layout.html?raw");
+ document.body.innerHTML = html;
+
+ const maxScrollY = getMaxScrollY(document.documentElement);
+ window.scrollTo(0, maxScrollY - 5);
+
+ scrollElementBy(document.documentElement, 500 as Coord<"page", "css", "y">);
+
+ expect(window.scrollY).toBe(maxScrollY);
+ });
+ });
+
+ describe("non-root element scroll", () => {
+ it("should scroll container by floored delta and keeps horizontal scroll", async () => {
+ const { default: html } = await import("./fixtures/scroll-element-by/layout.html?raw");
+ document.body.innerHTML = html;
+
+ const panel = query(".panel") as HTMLElement;
+ panel.scrollTo(90, 100);
+
+ scrollElementBy(panel, 40.8 as Coord<"page", "css", "y">);
+
+ expect(panel.scrollTop).toBe(140);
+ expect(panel.scrollLeft).toBe(90);
+ });
+
+ it("should clamp container scroll at zero", async () => {
+ const { default: html } = await import("./fixtures/scroll-element-by/layout.html?raw");
+ document.body.innerHTML = html;
+
+ const panel = query(".panel") as HTMLElement;
+ panel.scrollTop = 6;
+
+ scrollElementBy(panel, -100 as Coord<"page", "css", "y">);
+
+ expect(panel.scrollTop).toBe(0);
+ });
+
+ it("should clamp container scroll at max", async () => {
+ const { default: html } = await import("./fixtures/scroll-element-by/layout.html?raw");
+ document.body.innerHTML = html;
+
+ const panel = query(".panel") as HTMLElement;
+ const maxScrollY = getMaxScrollY(panel);
+ panel.scrollTop = maxScrollY - 3;
+
+ scrollElementBy(panel, 200 as Coord<"page", "css", "y">);
+
+ expect(panel.scrollTop).toBe(maxScrollY);
+ });
+
+ it("should not move when floored delta is zero", async () => {
+ const { default: html } = await import("./fixtures/scroll-element-by/layout.html?raw");
+ document.body.innerHTML = html;
+
+ const panel = query(".panel") as HTMLElement;
+ panel.scrollTop = 42;
+
+ scrollElementBy(panel, 0.99 as Coord<"page", "css", "y">);
+
+ expect(panel.scrollTop).toBe(42);
+ });
+ });
+
+ describe('"almost-root" element scroll', () => {
+ it("should scroll page when body is passed as scroll element", async () => {
+ const { default: html } = await import("./fixtures/scroll-element-by/layout.html?raw");
+ document.body.innerHTML = html;
+ window.scrollTo(0, 0);
+
+ scrollElementBy(document.body, 120 as Coord<"page", "css", "y">);
+
+ expect(window.scrollY).toBeGreaterThan(0);
+ });
+
+ it("should scroll page when html is passed as scroll element", async () => {
+ const { default: html } = await import("./fixtures/scroll-element-by/layout.html?raw");
+ document.body.innerHTML = html;
+ window.scrollTo(0, 0);
+
+ scrollElementBy(document.body.parentElement as Element, 120 as Coord<"page", "css", "y">);
+
+ expect(window.scrollY).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/test/browser-env/tests/desktop/screenshooter/scrollToCaptureAreaIfNeeded.testplane.ts b/test/browser-env/tests/desktop/screenshooter/scrollToCaptureAreaIfNeeded.testplane.ts
new file mode 100644
index 000000000..42b5e0ce6
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/scrollToCaptureAreaIfNeeded.testplane.ts
@@ -0,0 +1,227 @@
+import { getCoveringRect } from "../../../../../src/browser/isomorphic";
+import { OutsideOfViewportError } from "../../../../../src/browser/client-scripts/screen-shooter/errors/outside-of-viewport";
+import {
+ computeCaptureSpecs,
+ computeSafeArea,
+ scrollToCaptureAreaIfNeeded,
+} from "../../../../../src/browser/client-scripts/screen-shooter/operations";
+
+function clientRectSnapshot(el: Element): { top: number; left: number; width: number; height: number } {
+ const r = el.getBoundingClientRect();
+ return { top: r.top, left: r.left, width: r.width, height: r.height };
+}
+
+function expectClientRectClose(
+ a: { top: number; left: number; width: number; height: number },
+ b: { top: number; left: number; width: number; height: number },
+ tol = 1,
+): void {
+ expect(a.top).toBeCloseTo(b.top, tol);
+ expect(a.left).toBeCloseTo(b.left, tol);
+ expect(a.width).toBeCloseTo(b.width, tol);
+ expect(a.height).toBeCloseTo(b.height, tol);
+}
+
+function coveringFullTop(selectors: string[]): number {
+ const specs = computeCaptureSpecs(selectors).captureSpecs;
+ const area = getCoveringRect(specs.map(s => s.full));
+ return area.top as number;
+}
+
+function expectCaptureAlignedToSafeArea(selectors: string[], scrollElement: Element | undefined): void {
+ const top = coveringFullTop(selectors);
+ const safe = computeSafeArea(selectors, scrollElement).safeArea;
+ expect(top).toBeCloseTo(safe.top as number, 0);
+}
+
+describe("scrollToCaptureAreaIfNeeded", () => {
+ beforeEach(() => {
+ window.scrollTo(0, 0);
+ document.body.innerHTML = "";
+ document.body.style.cssText = "";
+ });
+
+ it("should throw OutsideOfViewportError when the target is outside the viewport and captureElementFromTop is false", async () => {
+ const { default: html } = await import("./fixtures/scroll-to-capture/below-fold-window.html?raw");
+ document.body.innerHTML = html;
+
+ const target = document.querySelector(".target")!;
+ const before = clientRectSnapshot(target);
+
+ expect(() => scrollToCaptureAreaIfNeeded([".target"], false)).toThrow(OutsideOfViewportError);
+
+ expectClientRectClose(clientRectSnapshot(target), before);
+ expect(window.scrollY).toBe(0);
+ });
+
+ it("should not scroll when the target is already in view", async () => {
+ const { default: html } = await import("./fixtures/scroll-to-capture/visible-target.html?raw");
+ document.body.innerHTML = html;
+ const scrollBefore = window.scrollY;
+
+ scrollToCaptureAreaIfNeeded([".target"], true);
+
+ expect(window.scrollY).toBe(scrollBefore);
+ });
+
+ it("should scroll when the target starts above the viewport", async () => {
+ const { default: html } = await import("./fixtures/scroll-to-capture/window-then-panel-scroll.html?raw");
+ document.body.innerHTML = html;
+
+ const target = document.getElementById("capture-panel")!;
+ window.scrollTo(0, target!.getBoundingClientRect().top + 100);
+
+ expect(target.getBoundingClientRect().top).toBeLessThan(0);
+
+ scrollToCaptureAreaIfNeeded(["#capture-panel"], true);
+
+ expect(target.getBoundingClientRect().top).toBe(0);
+ });
+
+ it("should treat omitted captureElementFromTop like false for an in-viewport target", async () => {
+ const { default: html } = await import("./fixtures/scroll-to-capture/visible-target.html?raw");
+ document.body.innerHTML = html;
+
+ const target = document.querySelector(".target")!;
+ const before = clientRectSnapshot(target);
+
+ const result = scrollToCaptureAreaIfNeeded([".target"]);
+
+ expect(result).toEqual({});
+ expectClientRectClose(clientRectSnapshot(target), before);
+ });
+
+ it("should return {} without scrolling when captureElementFromTop is true but the safe-area intersection is already tall enough", async () => {
+ const { default: html } = await import("./fixtures/safe-areas/fixed-app-header.html?raw");
+ document.body.innerHTML = html;
+
+ const target = document.querySelector(".target")!;
+ const before = clientRectSnapshot(target);
+ const scrollBefore = window.scrollY;
+
+ const result = scrollToCaptureAreaIfNeeded([".target"], true);
+
+ expect(result).toEqual({});
+ expectClientRectClose(clientRectSnapshot(target), before);
+ expect(window.scrollY).toBe(scrollBefore);
+ });
+
+ it("should scroll the window so the capture area aligns with the safe area when the target is below the fold", async () => {
+ const { default: html } = await import("./fixtures/scroll-to-capture/below-fold-window.html?raw");
+ document.body.innerHTML = html;
+
+ const target = document.querySelector(".target")!;
+ const before = clientRectSnapshot(target);
+ expect(before.top).toBeGreaterThan(700);
+
+ const result = scrollToCaptureAreaIfNeeded([".target"], true);
+
+ expect(result).toEqual({ readableSelectorToScrollDescr: "html" });
+ expect(window.scrollY).toBeGreaterThan(0);
+ expectCaptureAlignedToSafeArea([".target"], document.documentElement);
+ expect(clientRectSnapshot(target).top).toBeLessThan(before.top);
+ });
+
+ it("should scroll the window to the panel and then scrolls inside the panel", async () => {
+ const { default: html } = await import("./fixtures/scroll-to-capture/window-then-panel-scroll.html?raw");
+ document.body.innerHTML = html;
+
+ const panel = document.querySelector("#capture-panel")!;
+ const target = document.querySelector(".target")!;
+ panel.scrollTop = 0;
+ window.scrollTo(0, 0);
+
+ const before = clientRectSnapshot(target);
+ expect(before.top).toBeGreaterThan(window.innerHeight);
+ expect(panel.scrollTop).toBe(0);
+
+ const result = scrollToCaptureAreaIfNeeded([".target"], true);
+
+ expect(result.readableSelectorToScrollDescr).toBe("div#capture-panel");
+ expect(window.scrollY).toBeGreaterThan(0);
+ expect(panel.scrollTop).toBeGreaterThan(0);
+ expect(clientRectSnapshot(target).top).toBeLessThan(before.top);
+ expectCaptureAlignedToSafeArea([".target"], panel);
+ });
+
+ it("should scroll a single overflow container without changing window scroll", async () => {
+ const { default: html } = await import("./fixtures/scroll-to-capture/scrollable-container.html?raw");
+ document.body.innerHTML = html;
+
+ const container = document.querySelector(".container")!;
+ const target = document.querySelector(".target")!;
+ container.scrollTop = 0;
+
+ const before = clientRectSnapshot(target);
+ expect(before.top).toBeGreaterThan(container.getBoundingClientRect().bottom);
+
+ const result = scrollToCaptureAreaIfNeeded([".target"], true);
+
+ expect(result.readableSelectorToScrollDescr).toMatch(/container/);
+ expect(window.scrollY).toBe(0);
+ expect(container.scrollTop).toBeGreaterThan(0);
+ expectCaptureAlignedToSafeArea([".target"], container);
+ });
+
+ it("should walk nested scroll parents and align the capture area to the inner scroll root safe area", async () => {
+ const { default: html } = await import("./fixtures/scroll-to-capture/nested-scroll.html?raw");
+ document.body.innerHTML = html;
+
+ const outer = document.querySelector(".outer")!;
+ const inner = document.querySelector(".inner")!;
+ const target = document.querySelector(".target")!;
+
+ outer.scrollTop = 0;
+ inner.scrollTop = 0;
+ window.scrollTo(0, 0);
+
+ const before = clientRectSnapshot(target);
+ expect(before.top).toBeGreaterThan(inner.getBoundingClientRect().bottom - 20);
+
+ const result = scrollToCaptureAreaIfNeeded([".target"], true);
+
+ expect(inner.className).toContain("inner");
+ expect(result.readableSelectorToScrollDescr).toMatch(/inner/);
+ expect(outer.scrollTop).toBeGreaterThan(0);
+ expect(inner.scrollTop).toBeGreaterThan(0);
+ expectCaptureAlignedToSafeArea([".target"], inner);
+ });
+
+ it("should use selectorToScroll as the scroll root and report it in readableSelectorToScrollDescr", async () => {
+ const { default: html } = await import("./fixtures/scroll-to-capture/labeled-scroll-panel.html?raw");
+ document.body.innerHTML = html;
+
+ const panel = document.querySelector("#labeled-panel")!;
+ const target = document.querySelector(".target")!;
+ panel.scrollTop = 0;
+
+ const before = clientRectSnapshot(target);
+ expect(before.top).toBeGreaterThan(panel.getBoundingClientRect().bottom);
+
+ const result = scrollToCaptureAreaIfNeeded([".target"], true, false, "#labeled-panel");
+
+ expect(result).toEqual({ readableSelectorToScrollDescr: "#labeled-panel" });
+ expect(panel.scrollTop).toBeGreaterThan(0);
+ expect(window.scrollY).toBe(0);
+ expectCaptureAlignedToSafeArea([".target"], panel);
+ });
+
+ it("should fall back to the common scroll parent when selectorToScroll does not match any element", async () => {
+ const { default: html } = await import("./fixtures/scroll-to-capture/labeled-scroll-panel.html?raw");
+ document.body.innerHTML = html;
+
+ const panel = document.querySelector("#labeled-panel")!;
+ const target = document.querySelector(".target")!;
+ panel.scrollTop = 0;
+
+ const before = clientRectSnapshot(target);
+
+ const result = scrollToCaptureAreaIfNeeded([".target"], true, false, ".nonexistent-scroll-root");
+
+ expect(result).toEqual({ readableSelectorToScrollDescr: ".nonexistent-scroll-root" });
+ expect(panel.scrollTop).toBeGreaterThan(0);
+ expect(window.scrollY).toBe(0);
+ expect(clientRectSnapshot(target).top).toBeLessThan(before.top);
+ expectCaptureAlignedToSafeArea([".target"], panel);
+ });
+});
diff --git a/test/browser-env/tests/high-pixel-ratio/fixtures/viewport-screenshot/dashboard-long-page.html b/test/browser-env/tests/high-pixel-ratio/fixtures/viewport-screenshot/dashboard-long-page.html
new file mode 100644
index 000000000..18590b85e
--- /dev/null
+++ b/test/browser-env/tests/high-pixel-ratio/fixtures/viewport-screenshot/dashboard-long-page.html
@@ -0,0 +1,89 @@
+
+
+
+
diff --git a/test/browser-env/tests/high-pixel-ratio/prepareViewportScreenshot.testplane.ts b/test/browser-env/tests/high-pixel-ratio/prepareViewportScreenshot.testplane.ts
new file mode 100644
index 000000000..0ba4b201a
--- /dev/null
+++ b/test/browser-env/tests/high-pixel-ratio/prepareViewportScreenshot.testplane.ts
@@ -0,0 +1,45 @@
+import { prepareViewportScreenshot } from "../../../../src/browser/client-scripts/screen-shooter/implementation";
+import {
+ computeDocumentSize,
+ computeViewportOffset,
+ computeViewportSize,
+} from "../../../../src/browser/client-scripts/screen-shooter/operations";
+import type { PrepareViewportScreenshotSuccess } from "../../../../src/browser/client-scripts/screen-shooter/types";
+import { isBrowserSideError } from "../../../../src/browser/isomorphic/types";
+
+function getPrepareResult(opts: Parameters[0]): PrepareViewportScreenshotSuccess {
+ const result = prepareViewportScreenshot(opts);
+
+ if (isBrowserSideError(result)) {
+ throw new Error(`Browser-side error in prepareViewportScreenshot: ${result.errorCode}: ${result.message}`);
+ }
+
+ return result;
+}
+
+describe("prepareViewportScreenshot in high pixel ratio mode", () => {
+ beforeEach(async () => {
+ const { default: html } = await import("./fixtures/viewport-screenshot/dashboard-long-page.html?raw");
+ document.body.innerHTML = html;
+ document.body.style.margin = "0";
+ window.scrollTo(0, 0);
+ });
+
+ it("returns viewport and document dimensions translated to device pixels", async ({ browser }) => {
+ window.scrollTo(0, 245);
+
+ const cssViewportSize = computeViewportSize().viewportSize;
+ const cssViewportOffset = computeViewportOffset().viewportOffset;
+ const cssDocumentSize = computeDocumentSize().documentSize;
+
+ const result = getPrepareResult({ usePixelRatio: true });
+
+ expect(result.pixelRatio).toBe(3);
+ expect(result.viewportSize.width).toBe((cssViewportSize.width as number) * 3);
+ expect(result.viewportSize.height).toBe((cssViewportSize.height as number) * 3);
+ expect(result.viewportOffset.left).toBe(Math.floor(cssViewportOffset.left as number) * 3);
+ expect(result.viewportOffset.top).toBe(Math.floor(cssViewportOffset.top as number) * 3);
+ expect(result.documentSize.width).toBe(Math.ceil((cssDocumentSize.width as number) * 3));
+ expect(result.documentSize.height).toBe(Math.ceil((cssDocumentSize.height as number) * 3));
+ });
+});
diff --git a/test/browser-env/tests/utils.ts b/test/browser-env/tests/utils.ts
new file mode 100644
index 000000000..e92536113
--- /dev/null
+++ b/test/browser-env/tests/utils.ts
@@ -0,0 +1,48 @@
+import { CaptureSpec } from "../../../src/browser/client-scripts/screen-shooter/types";
+
+export function visualizeCaptureSpecs(specs: CaptureSpec<"viewport", "css">[]): void {
+ specs.forEach((spec, i) => {
+ // Full area — green
+ const fullDiv = document.createElement("div");
+ fullDiv.style.position = "fixed";
+ fullDiv.style.left = `${spec.full.left}px`;
+ fullDiv.style.top = `${spec.full.top}px`;
+ fullDiv.style.width = `${spec.full.width}px`;
+ fullDiv.style.height = `${spec.full.height}px`;
+ fullDiv.style.backgroundColor = "rgba(0, 255, 0, 0.3)";
+ fullDiv.style.outline = "1px solid green";
+ fullDiv.style.zIndex = "99999";
+ fullDiv.style.pointerEvents = "none";
+ fullDiv.dataset.captureArea = String(i);
+ document.body.appendChild(fullDiv);
+
+ // Visible area — blue
+ const visDiv = document.createElement("div");
+ visDiv.style.position = "fixed";
+ visDiv.style.left = `${spec.visible.left}px`;
+ visDiv.style.top = `${spec.visible.top}px`;
+ visDiv.style.width = `${spec.visible.width}px`;
+ visDiv.style.height = `${spec.visible.height}px`;
+ visDiv.style.backgroundColor = "rgba(0, 100, 255, 0.3)";
+ visDiv.style.outline = "1px dashed blue";
+ visDiv.style.zIndex = "99999";
+ visDiv.style.pointerEvents = "none";
+ visDiv.dataset.visibleArea = String(i);
+ document.body.appendChild(visDiv);
+ });
+}
+
+export function visualizeSafeArea(top: number, height: number): void {
+ const safeDiv = document.createElement("div");
+ safeDiv.style.position = "fixed";
+ safeDiv.style.left = "0";
+ safeDiv.style.top = `${top}px`;
+ safeDiv.style.width = "100vw";
+ safeDiv.style.height = `${height}px`;
+ safeDiv.style.backgroundColor = "rgba(0, 200, 120, 0.2)";
+ safeDiv.style.outline = "3px solid rgba(0, 160, 100, 0.95)";
+ safeDiv.style.zIndex = "2147483645";
+ safeDiv.style.pointerEvents = "none";
+ safeDiv.dataset.safeArea = "1";
+ document.body.appendChild(safeDiv);
+}
diff --git a/test/browser-env/tsconfig.json b/test/browser-env/tsconfig.json
new file mode 100644
index 000000000..0c8f9666e
--- /dev/null
+++ b/test/browser-env/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "target": "esnext",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "types": [],
+ "paths": {
+ "@isomorphic": ["../../src/browser/isomorphic/index.ts"]
+ }
+ },
+ "include": ["testplane.config.ts", "tests", "types.d.ts"]
+}
diff --git a/test/browser-env/types.d.ts b/test/browser-env/types.d.ts
new file mode 100644
index 000000000..c4cbfdc01
--- /dev/null
+++ b/test/browser-env/types.d.ts
@@ -0,0 +1,4 @@
+declare module "*.html?raw" {
+ const content: string;
+ export default content;
+}
diff --git a/test/browser-env/vite.config.ts b/test/browser-env/vite.config.ts
new file mode 100644
index 000000000..4c244136f
--- /dev/null
+++ b/test/browser-env/vite.config.ts
@@ -0,0 +1,44 @@
+import { defineConfig } from "vite";
+import path from "path";
+
+export default defineConfig({
+ plugins: [],
+ server: {
+ host: "0.0.0.0",
+ port: 5173,
+ strictPort: true,
+ },
+ define: {
+ global: "globalThis",
+ exports: "{}",
+ module: "{ exports: {} }",
+ },
+ optimizeDeps: {
+ include: [
+ "lib/**/*.js",
+ "expect",
+ "aria-query",
+ "css-shorthand-properties",
+ "css-value",
+ "grapheme-splitter",
+ "lodash.clonedeep",
+ "lodash.zip",
+ "minimatch",
+ "rgb2hex",
+ "ws",
+ "debug",
+ ],
+ },
+ resolve: {
+ alias: {
+ "@isomorphic": path.resolve(__dirname, "../../src/browser/isomorphic/index.ts"),
+ "@": path.resolve(__dirname, "../../lib"),
+ lib: path.resolve(__dirname, "../../lib"),
+ },
+ },
+ css: {
+ modules: {
+ localsConvention: "camelCase",
+ },
+ },
+});