diff --git a/.eslintignore b/.eslintignore
index c7d3ca923..c91bdd9bc 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -6,4 +6,8 @@ bundle.compat.js
bundle.native.js
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..e694a78f8 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -7,6 +7,59 @@ 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: {
diff --git a/.github/workflows/browser-env.yml b/.github/workflows/browser-env.yml
new file mode 100644
index 000000000..e95aa04e7
--- /dev/null
+++ b/.github/workflows/browser-env.yml
@@ -0,0 +1,98 @@
+name: Testplane Browser Env Tests
+
+on:
+ pull_request:
+ branches: [master]
+
+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_browser_env_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
new file mode 100644
index 000000000..fb26437ac
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,104 @@
+name: Testplane E2E
+
+on:
+ pull_request:
+ branches: [master]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ testplane-e2e:
+ 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 e2e tests: Cache browser docker image"
+ uses: actions/cache@v3
+ with:
+ path: ~/.docker/cache
+ key: docker-browser-image-testplane
+
+ - name: "Prepare e2e 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 e2e 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: 'Prepare e2e tests: Setup env'
+ # run: |
+ # REPORT_PREFIX=testplane-reports
+ # REPORT_DATE=$(date '+%Y-%m-%d')
+ # echo "DEST_REPORTS_DIR=$REPORT_PREFIX/$REPORT_DATE/${{ github.run_id }}/${{ github.run_attempt }}" >> $GITHUB_ENV
+
+ - name: "e2e: Run Testplane"
+ id: "testplane"
+ continue-on-error: true
+ run: npm run test-e2e
+
+ - name: "e2e: 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/e2e/report"
+ DEST_DIR: "testplane-ci/e2e-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/e2e-reports/${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}/index.html"
+ if [ "${{ steps.testplane.outcome }}" != "success" ]; then
+ comment="### ❌ Testplane E2E run failed
[Report](${link})"
+ echo "PR_COMMENT=${comment}" >> $GITHUB_ENV
+ else
+ comment="### ✅ Testplane E2E 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/node.js.yml b/.github/workflows/node.js.yml
index 68ab791e4..a52c03ec9 100644
--- a/.github/workflows/node.js.yml
+++ b/.github/workflows/node.js.yml
@@ -15,7 +15,7 @@ jobs:
strategy:
matrix:
- node-version: [18.x, 20.18.1, 22.x, 24.x]
+ node-version: [22.x, 24.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
@@ -26,12 +26,9 @@ jobs:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
+ - run: npm run build
- run: npm test
- - name: Build
- if: ${{ startsWith(matrix.node-version, '20') }}
- run: npm run build
-
- name: Publish
- if: ${{ startsWith(matrix.node-version, '20') }}
+ if: ${{ startsWith(matrix.node-version, '24') }}
run: npx pkg-pr-new publish
diff --git a/.github/workflows/repl-e2e.yml b/.github/workflows/repl-e2e.yml
index 2b0903fc8..baab6d0dd 100644
--- a/.github/workflows/repl-e2e.yml
+++ b/.github/workflows/repl-e2e.yml
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- node-version: [20.18.1, 24.x]
+ node-version: [24.x]
steps:
- uses: actions/checkout@v4
diff --git a/.github/workflows/standalone-e2e.yml b/.github/workflows/standalone-e2e.yml
index 5a551dffa..d7b773752 100644
--- a/.github/workflows/standalone-e2e.yml
+++ b/.github/workflows/standalone-e2e.yml
@@ -7,9 +7,12 @@ on:
jobs:
integration-test:
runs-on: ubuntu-latest
+ env:
+ DOCKER_IMAGE_NAME: html-reporter-browsers
+
strategy:
matrix:
- node-version: [20.18.1]
+ node-version: [24.x]
browser: [chrome, firefox]
steps:
@@ -36,7 +39,35 @@ jobs:
- name: Build project
run: npm run build
+ - name: "Prepare screenshot tests: Cache browser docker image"
+ if: ${{ matrix.browser == 'chrome' }}
+ uses: actions/cache@v3
+ with:
+ path: ~/.docker/cache
+ key: docker-browser-image-testplane
+
+ - name: "Prepare screenshot tests: Pull browser docker image"
+ if: ${{ matrix.browser == 'chrome' }}
+ 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 screenshot tests: Run browser docker image"
+ if: ${{ matrix.browser == 'chrome' }}
+ 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: Run integration tests for ${{ matrix.browser }}
env:
BROWSER: ${{ matrix.browser }}
run: npm run test-integration
+
+ - name: "Screenshot tests: Stop browser docker image"
+ if: ${{ always() && matrix.browser == 'chrome' }}
+ run: |
+ docker kill ${{ env.DOCKER_IMAGE_NAME }} || true
+ docker rm ${{ env.DOCKER_IMAGE_NAME }} || true
diff --git a/.gitignore b/.gitignore
index f0b17a144..5c41634c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ build
*.tgz
.fuse*
.project
+.testplane
node_modules
artifacts
npm-debug.log
@@ -21,3 +22,9 @@ bundle.native.js
wt/**
tmp/**
test/e2e/**/fixture-project/.testplane/
+coverage/**
+tsc-out
+testplane/**
+testplane-report/**
+*.tsbuildinfo
+test/browser-env/report/**
diff --git a/.mocharc.js b/.mocharc.js
index 896c1f327..ef3f672bc 100644
--- a/.mocharc.js
+++ b/.mocharc.js
@@ -3,5 +3,6 @@
module.exports = {
recursive: true,
extension: [".js", ".ts"],
- require: ["./test/setup", "./test/assert-ext", "./test/ts-node"],
+ ignore: ["./test/browser-env/**", "**/report/**", "**/basic-report/**"],
+ require: ["./test/setup", "./test/assert-ext", "./test/ts-node", "tsconfig-paths/register"],
};
diff --git a/.prettierignore b/.prettierignore
index 7ab55538a..85ba44d94 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -11,4 +11,13 @@ bundle.native.js
wt/**
test/e2e/report
test/e2e/**/fixture-project/.testplane/
+test/e2e/static/basic-report
tmp/**
+.testplane/**
+coverage/**
+*.tsbuildinfo
+test/browser-env/report/**
+*.png
+*.DS_Store
+.claude
+tsc-out
diff --git a/examples/component-testing/create-react-app/testplane.config.ts b/examples/component-testing/create-react-app/testplane.config.ts
index 568152857..6c541a186 100644
--- a/examples/component-testing/create-react-app/testplane.config.ts
+++ b/examples/component-testing/create-react-app/testplane.config.ts
@@ -1,7 +1,7 @@
export default {
gridUrl: "local",
baseUrl: "http://localhost",
- automationProtocol: "devtools",
+ automationProtocol: "webdriver",
sessionsPerBrowser: 1,
testsPerSession: 10,
windowSize: "1280x720",
diff --git a/package-lock.json b/package-lock.json
index 4dec67aaf..052c64bdf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,7 +14,6 @@
"@jspm/core": "2.0.1",
"@jsquash/png": "3.1.1",
"@puppeteer/browsers": "2.7.1",
- "@testplane/devtools": "8.32.5",
"@testplane/wdio-protocols": "9.4.7",
"@testplane/wdio-utils": "9.5.4",
"@testplane/webdriverio": "9.5.28",
@@ -103,6 +102,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",
@@ -113,6 +113,7 @@
"eslint-config-prettier": "8.7.0",
"execa": "5.1.1",
"glob-extra": "5.0.2",
+ "html-reporter": "11.9.3",
"husky": "0.11.4",
"js-levenshtein": "1.1.6",
"jsdom": "^24.0.0",
@@ -125,6 +126,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"
@@ -237,6 +239,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,
@@ -1373,6 +1385,13 @@
"version": "2.15.4",
"license": "MIT"
},
+ "node_modules/@gemini-testing/sql.js": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@gemini-testing/sql.js/-/sql.js-3.0.0.tgz",
+ "integrity": "sha512-q/g1XuUdTc5v/eBspc7KJYS3RHeyIPmGN5K9N9YZRO+Op01GF4kZIC5RpjYIE8g61JAc23zOLlYMVLJB7SuIKA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@humanwhocodes/config-array": {
"version": "0.10.7",
"dev": true,
@@ -1434,6 +1453,109 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@inquirer/ansi": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
+ "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/confirm": {
+ "version": "5.1.21",
+ "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz",
+ "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/core": {
+ "version": "10.3.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz",
+ "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^1.0.2",
+ "@inquirer/figures": "^1.0.15",
+ "@inquirer/type": "^3.0.10",
+ "cli-width": "^4.1.0",
+ "mute-stream": "^2.0.0",
+ "signal-exit": "^4.1.0",
+ "wrap-ansi": "^6.2.0",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/core/node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@inquirer/figures": {
+ "version": "1.0.15",
+ "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz",
+ "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/type": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz",
+ "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1523,6 +1645,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",
@@ -2264,6 +2396,8 @@
"version": "8.32.5",
"resolved": "https://registry.npmjs.org/@testplane/devtools/-/devtools-8.32.5.tgz",
"integrity": "sha512-VpQIUDMgBB65iao2WgeuoqEPdiwek9bJEVTLtWZikxfDabkPEVwODelJmgh6Yy5lvEIDsHinsJVGvJhg2y+5fg==",
+ "optional": true,
+ "peer": true,
"dependencies": {
"@testplane/wdio-config": "9.5.4",
"@testplane/wdio-logger": "9.4.7",
@@ -2288,6 +2422,8 @@
"version": "9.4.7",
"resolved": "https://registry.npmjs.org/@testplane/wdio-logger/-/wdio-logger-9.4.7.tgz",
"integrity": "sha512-O7wuGM1S3BmptkyaFgozMPWaLJP9d3TGXH0ZwVAOji6UO02ROBSYCvFvt5yPWC93mN4/ocpApF0Oj0gv9dkaiQ==",
+ "optional": true,
+ "peer": true,
"dependencies": {
"chalk": "^4.1.2",
"loglevel": "^1.6.0",
@@ -2302,6 +2438,8 @@
"version": "9.5.4",
"resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.4.tgz",
"integrity": "sha512-r6M7+T9lSEAiuM79XN3tc1/ARMayEINmvjjgroRgOKGv9Eks3KlzqJIjqhhHZcms9MtTueYvhrL/qUcAn4t9rA==",
+ "optional": true,
+ "peer": true,
"dependencies": {
"@types/node": "^20.1.0"
},
@@ -2314,6 +2452,8 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz",
"integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2322,6 +2462,8 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "optional": true,
+ "peer": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
@@ -2337,6 +2479,8 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
"integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+ "optional": true,
+ "peer": true,
"engines": {
"node": ">=16"
}
@@ -2345,12 +2489,16 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
"node_modules/@testplane/devtools/node_modules/which": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
"integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+ "optional": true,
+ "peer": true,
"dependencies": {
"isexe": "^3.1.1"
},
@@ -2504,6 +2652,8 @@
"version": "9.5.4",
"resolved": "https://registry.npmjs.org/@testplane/wdio-config/-/wdio-config-9.5.4.tgz",
"integrity": "sha512-GXZ/uSmTTFMTbmfhg6X34sSSzkxh+vx3mF/XPFoWshmEg5n/rIXwbbL5FOKoggrEmdgQJZ/0hTjxZJMlHyf5Kg==",
+ "optional": true,
+ "peer": true,
"dependencies": {
"@testplane/wdio-logger": "9.4.7",
"@testplane/wdio-types": "9.5.4",
@@ -2520,6 +2670,8 @@
"version": "9.4.7",
"resolved": "https://registry.npmjs.org/@testplane/wdio-logger/-/wdio-logger-9.4.7.tgz",
"integrity": "sha512-O7wuGM1S3BmptkyaFgozMPWaLJP9d3TGXH0ZwVAOji6UO02ROBSYCvFvt5yPWC93mN4/ocpApF0Oj0gv9dkaiQ==",
+ "optional": true,
+ "peer": true,
"dependencies": {
"chalk": "^4.1.2",
"loglevel": "^1.6.0",
@@ -2534,6 +2686,8 @@
"version": "9.5.4",
"resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.4.tgz",
"integrity": "sha512-r6M7+T9lSEAiuM79XN3tc1/ARMayEINmvjjgroRgOKGv9Eks3KlzqJIjqhhHZcms9MtTueYvhrL/qUcAn4t9rA==",
+ "optional": true,
+ "peer": true,
"dependencies": {
"@types/node": "^20.1.0"
},
@@ -2546,6 +2700,8 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz",
"integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2554,6 +2710,8 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "optional": true,
+ "peer": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -2562,6 +2720,8 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "optional": true,
+ "peer": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
@@ -2577,6 +2737,8 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "optional": true,
+ "peer": true,
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
@@ -2596,6 +2758,8 @@
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "optional": true,
+ "peer": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -2610,7 +2774,9 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
"node_modules/@testplane/wdio-logger": {
"version": "9.4.6",
@@ -4035,6 +4201,19 @@
"node": ">=6"
}
},
+ "node_modules/ansi-html-community": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz",
+ "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==",
+ "dev": true,
+ "engines": [
+ "node >= 0.8.0"
+ ],
+ "license": "Apache-2.0",
+ "bin": {
+ "ansi-html": "bin/ansi-html"
+ }
+ },
"node_modules/ansi-regex": {
"version": "5.0.1",
"license": "MIT",
@@ -4319,6 +4498,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/array-ify": {
"version": "1.0.0",
"dev": true,
@@ -4394,6 +4580,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/axios": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz",
+ "integrity": "sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.0",
+ "form-data": "^4.0.0",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
"node_modules/b4a": {
"version": "1.6.4",
"license": "ISC"
@@ -4527,8 +4725,37 @@
"node": ">=8"
}
},
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/bl/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/bluebird": {
- "version": "3.5.1",
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"dev": true,
"license": "MIT"
},
@@ -4538,6 +4765,44 @@
"integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==",
"dev": true
},
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/body-parser/node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -4844,67 +5109,91 @@
"dev": true,
"license": "MIT"
},
- "node_modules/cacheable-lookup": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
- "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==",
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true,
+ "license": "MIT",
"engines": {
- "node": ">=14.16"
+ "node": ">= 0.8"
}
},
- "node_modules/cacheable-request": {
- "version": "10.2.14",
- "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz",
- "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==",
+ "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": {
- "@types/http-cache-semantics": "^4.0.2",
- "get-stream": "^6.0.1",
- "http-cache-semantics": "^4.1.1",
- "keyv": "^4.5.3",
- "mimic-response": "^4.0.0",
- "normalize-url": "^8.0.0",
- "responselike": "^3.0.0"
+ "@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": ">=14.16"
- }
- },
- "node_modules/cacheable-request/node_modules/get-stream": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
- "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
- "engines": {
- "node": ">=10"
+ "node": ">=18"
},
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "peerDependencies": {
+ "monocart-coverage-reports": "^2"
+ },
+ "peerDependenciesMeta": {
+ "monocart-coverage-reports": {
+ "optional": true
+ }
}
},
- "node_modules/cached-path-relative": {
- "version": "1.1.0",
+ "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": "MIT"
- },
- "node_modules/caller-path": {
- "version": "0.1.0",
- "license": "MIT",
+ "license": "ISC",
"dependencies": {
- "callsites": "^0.2.0"
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
},
"engines": {
- "node": ">=0.10.0"
+ "node": ">=12"
}
},
- "node_modules/callsites": {
- "version": "0.2.0",
+ "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": ">=0.10.0"
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/camelcase": {
- "version": "6.3.0",
+ "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"
},
@@ -4912,26 +5201,170 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/camelcase-keys": {
- "version": "6.2.2",
+ "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": {
- "camelcase": "^5.3.1",
- "map-obj": "^4.0.0",
- "quick-lru": "^4.0.1"
+ "p-limit": "^3.0.2"
},
"engines": {
- "node": ">=8"
+ "node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/camelcase-keys/node_modules/camelcase": {
- "version": "5.3.1",
- "dev": true,
- "license": "MIT",
+ "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",
+ "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==",
+ "engines": {
+ "node": ">=14.16"
+ }
+ },
+ "node_modules/cacheable-request": {
+ "version": "10.2.14",
+ "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz",
+ "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==",
+ "dependencies": {
+ "@types/http-cache-semantics": "^4.0.2",
+ "get-stream": "^6.0.1",
+ "http-cache-semantics": "^4.1.1",
+ "keyv": "^4.5.3",
+ "mimic-response": "^4.0.0",
+ "normalize-url": "^8.0.0",
+ "responselike": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14.16"
+ }
+ },
+ "node_modules/cacheable-request/node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cached-path-relative": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/caller-path": {
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "0.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "6.3.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/camelcase-keys": {
+ "version": "6.2.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "camelcase": "^5.3.1",
+ "map-obj": "^4.0.0",
+ "quick-lru": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/camelcase-keys/node_modules/camelcase": {
+ "version": "5.3.1",
+ "dev": true,
+ "license": "MIT",
"engines": {
"node": ">=6"
}
@@ -5091,6 +5524,8 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.1.2.tgz",
"integrity": "sha512-YclTJey34KUm5jB1aEJCq807bSievi7Nb/TU4Gu504fUYi3jw3KCIaH6L7nFWQhdEgH3V+wCh+kKD1P5cXnfxw==",
+ "optional": true,
+ "peer": true,
"dependencies": {
"@types/node": "*",
"escape-string-regexp": "^4.0.0",
@@ -5108,6 +5543,8 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "optional": true,
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -5159,6 +5596,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/cli-cursor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
+ "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "restore-cursor": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/cli-progress": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
@@ -5170,6 +5620,29 @@
"node": ">=4"
}
},
+ "node_modules/cli-spinners": {
+ "version": "2.9.2",
+ "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
+ "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-width": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
+ "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/cliui": {
"version": "7.0.4",
"license": "ISC",
@@ -5179,6 +5652,16 @@
"wrap-ansi": "^7.0.0"
}
},
+ "node_modules/clone": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
+ "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"license": "MIT",
@@ -5471,6 +5954,50 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-disposition/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/conventional-changelog": {
"version": "3.1.25",
"dev": true,
@@ -6080,6 +6607,13 @@
"node": ">= 0.6"
}
},
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/copyfiles": {
"version": "2.4.1",
"dev": true,
@@ -6550,6 +7084,19 @@
"node": ">=16.0.0"
}
},
+ "node_modules/defaults": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
+ "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "clone": "^1.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
@@ -6558,6 +7105,16 @@
"node": ">=10"
}
},
+ "node_modules/define-lazy-prop": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
+ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/defined": {
"version": "1.0.0",
"dev": true,
@@ -6584,6 +7141,16 @@
"node": ">=0.4.0"
}
},
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/deps-sort": {
"version": "2.0.1",
"dev": true,
@@ -6607,6 +7174,17 @@
"minimalistic-assert": "^1.0.0"
}
},
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
"node_modules/detect-indent": {
"version": "6.1.0",
"dev": true,
@@ -6864,6 +7442,21 @@
"node": ">=4"
}
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/duplexer2": {
"version": "0.1.4",
"dev": true,
@@ -6943,6 +7536,13 @@
"node": "^16.13.0 || >=18.0.0"
}
},
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/elliptic": {
"version": "6.5.5",
"dev": true,
@@ -6967,6 +7567,16 @@
"version": "8.0.0",
"license": "MIT"
},
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/end-of-stream": {
"version": "1.4.1",
"license": "MIT",
@@ -7127,6 +7737,39 @@
"stackframe": "^1.3.4"
}
},
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/esbuild": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
@@ -7174,6 +7817,13 @@
"node": ">=6"
}
},
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/escape-string-regexp": {
"version": "1.0.5",
"license": "MIT",
@@ -7577,6 +8227,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@@ -7585,6 +8245,20 @@
"node": ">=6"
}
},
+ "node_modules/eventemitter2": {
+ "version": "6.4.7",
+ "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz",
+ "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/eventemitter3": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
+ "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/events": {
"version": "1.1.1",
"dev": true,
@@ -7665,6 +8339,91 @@
"jest-matcher-utils": "^28.1.0"
}
},
+ "node_modules/express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express/node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express/node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/express/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/extend": {
"version": "3.0.2",
"dev": true,
@@ -7887,6 +8646,16 @@
"node": "^10.12.0 || >=12.0.0"
}
},
+ "node_modules/filesize": {
+ "version": "8.0.7",
+ "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz",
+ "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
"node_modules/fill-keys": {
"version": "1.0.2",
"dev": true,
@@ -7910,6 +8679,25 @@
"node": ">=8"
}
},
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/find-up": {
"version": "4.1.0",
"dev": true,
@@ -7960,6 +8748,27 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
"node_modules/foreach": {
"version": "2.0.6",
"dev": true,
@@ -8019,6 +8828,26 @@
"node": ">=12.20.0"
}
},
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/fs-extra": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
@@ -8213,6 +9042,31 @@
"node": "*"
}
},
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/get-pkg-repo": {
"version": "4.2.1",
"dev": true,
@@ -8240,6 +9094,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@@ -8523,6 +9391,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/got": {
"version": "12.6.1",
"resolved": "https://registry.npmjs.org/got/-/got-12.6.1.tgz",
@@ -8638,6 +9519,19 @@
"node": ">=4"
}
},
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/hash-base": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz",
@@ -8713,23 +9607,146 @@
"dev": true,
"license": "ISC",
"dependencies": {
- "lru-cache": "^6.0.0"
+ "lru-cache": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "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.9.3",
+ "resolved": "https://registry.npmjs.org/html-reporter/-/html-reporter-11.9.3.tgz",
+ "integrity": "sha512-PGqkeCwJg5m8byPt2Y5o/QjjrT//6V75c6GfbwtkT3NjqLIedKOCB6t5J/0AE2n46xLBXVMQSK2aSdaH4KCr0A==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "test/func/fixtures/*",
+ "test/func/packages/*",
+ "test/func/tests",
+ "test/component"
+ ],
+ "dependencies": {
+ "@gemini-testing/commander": "^2.15.3",
+ "@gemini-testing/sql.js": "^3.0.0",
+ "@inquirer/confirm": "^5.1.15",
+ "ansi-html-community": "^0.0.8",
+ "axios": "1.6.3",
+ "bluebird": "^3.5.3",
+ "body-parser": "^1.18.2",
+ "chalk": "^4.1.2",
+ "debug": "^4.1.1",
+ "escape-html": "^1.0.3",
+ "eventemitter2": "6.4.7",
+ "express": "^4.16.2",
+ "fast-glob": "^3.2.12",
+ "filesize": "^8.0.6",
+ "fs-extra": "^7.0.1",
+ "gemini-configparser": "^1.4.2",
+ "http-codes": "1.0.0",
+ "image-size": "^1.0.2",
+ "json-stringify-safe": "^5.0.1",
+ "lodash": "^4.17.4",
+ "looks-same": "^10.0.1",
+ "nested-error-stacks": "^2.1.0",
+ "npm-which": "^3.0.1",
+ "open": "^8.4.2",
+ "ora": "^5.4.1",
+ "p-queue": "^5.0.0",
+ "qs": "^6.9.1",
+ "signal-exit": "^4.1.0",
+ "strip-ansi": "^6.0.1",
+ "tmp": "^0.1.0",
+ "url-join": "^4.0.1",
+ "worker-farm": "^1.7.0",
+ "yazl": "^3.3.1"
+ },
+ "bin": {
+ "html-reporter": "bin/html-reporter"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "hermione": ">=8.0.0",
+ "jest": "*",
+ "playwright": "*",
+ "testplane": "*"
+ },
+ "peerDependenciesMeta": {
+ "hermione": {
+ "optional": true
+ },
+ "jest": {
+ "optional": true
+ },
+ "playwright": {
+ "optional": true
+ },
+ "testplane": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/html-reporter/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
}
},
- "node_modules/html-encoding-sniffer": {
- "version": "4.0.0",
+ "node_modules/html-reporter/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "whatwg-encoding": "^3.1.1"
+ "ms": "^2.1.3"
},
"engines": {
- "node": ">=18"
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
}
},
+ "node_modules/html-reporter/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/htmlescape": {
"version": "1.1.1",
"dev": true,
@@ -8767,6 +9784,34 @@
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="
},
+ "node_modules/http-codes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/http-codes/-/http-codes-1.0.0.tgz",
+ "integrity": "sha512-yPS0/Sp66fmRSpZ6rbeFinytku7KnBiLa/CaDxHbcr0gAZuQqeGjgyfElyQ5xPiK21e4oL4iqooHLRcr2s7LoA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"license": "MIT",
@@ -8923,6 +9968,22 @@
"node": ">= 4"
}
},
+ "node_modules/image-size": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
+ "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "queue": "6.0.2"
+ },
+ "bin": {
+ "image-size": "bin/image-size.js"
+ },
+ "engines": {
+ "node": ">=16.x"
+ }
+ },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -9045,6 +10106,16 @@
"node": ">= 12"
}
},
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/is-alphabetical": {
"version": "1.0.4",
"dev": true,
@@ -9115,6 +10186,7 @@
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "devOptional": true,
"bin": {
"is-docker": "cli.js"
},
@@ -9158,6 +10230,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/is-interactive": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
+ "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -9228,6 +10310,7 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "devOptional": true,
"dependencies": {
"is-docker": "^2.0.0"
},
@@ -9243,6 +10326,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",
@@ -9505,6 +10627,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",
@@ -9631,6 +10766,8 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.1.tgz",
"integrity": "sha512-ioBrW3s2i97noEmnXxmUq7cjIcVRjT5HBpAYy8zE11CxU9HqlWHHeRxfeN1tn8F7OEMVPIC9x1f8t3Z7US9ehQ==",
+ "optional": true,
+ "peer": true,
"dependencies": {
"debug": "^2.6.9",
"marky": "^1.2.2"
@@ -9675,14 +10812,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",
@@ -9912,6 +11041,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,
@@ -9943,7 +11088,19 @@
"node_modules/marky": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz",
- "integrity": "sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q=="
+ "integrity": "sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
},
"node_modules/md5.js": {
"version": "1.3.5",
@@ -10114,6 +11271,16 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/meow": {
"version": "8.1.2",
"dev": true,
@@ -10150,9 +11317,14 @@
}
},
"node_modules/merge-descriptors": {
- "version": "1.0.1",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
},
"node_modules/merge-stream": {
"version": "2.0.0",
@@ -10167,6 +11339,16 @@
"node": ">= 8"
}
},
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/micromark": {
"version": "2.11.4",
"dev": true,
@@ -10336,6 +11518,19 @@
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"dev": true
},
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/mime-db": {
"version": "1.52.0",
"license": "MIT",
@@ -10703,6 +11898,16 @@
"version": "2.0.0",
"license": "MIT"
},
+ "node_modules/mute-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
+ "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -10867,6 +12072,35 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/npm-path": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/npm-path/-/npm-path-2.0.4.tgz",
+ "integrity": "sha512-IFsj0R9C7ZdR5cP+ET342q77uSRdtWOlWpih5eC+lu29tIDbNEgDbzgVJ5UFvYHWhxDZ5TFkJafFioO0pPQjCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "which": "^1.2.10"
+ },
+ "bin": {
+ "npm-path": "bin/npm-path"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/npm-path/node_modules/which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "which": "bin/which"
+ }
+ },
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
@@ -10879,6 +12113,44 @@
"node": ">=8"
}
},
+ "node_modules/npm-which": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/npm-which/-/npm-which-3.0.1.tgz",
+ "integrity": "sha512-CM8vMpeFQ7MAPin0U3wzDhSGV0hMHNwHU0wjo402IVizPDrs45jSfSuoC+wThevY88LQti8VvaAnqYAeVy3I1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^2.9.0",
+ "npm-path": "^2.0.2",
+ "which": "^1.2.10"
+ },
+ "bin": {
+ "npm-which": "bin/npm-which.js"
+ },
+ "engines": {
+ "node": ">=4.2.0"
+ }
+ },
+ "node_modules/npm-which/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/npm-which/node_modules/which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "which": "bin/which"
+ }
+ },
"node_modules/nwsapi": {
"version": "2.2.7",
"dev": true,
@@ -10891,6 +12163,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/object-keys": {
"version": "1.1.1",
"dev": true,
@@ -10899,6 +12184,19 @@
"node": ">= 0.4"
}
},
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/once": {
"version": "1.4.0",
"license": "ISC",
@@ -10929,13 +12227,72 @@
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"dev": true,
"dependencies": {
- "mimic-fn": "^2.1.0"
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/open": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
+ "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-lazy-prop": "^2.0.0",
+ "is-docker": "^2.1.1",
+ "is-wsl": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ora": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
+ "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.1.0",
+ "chalk": "^4.1.0",
+ "cli-cursor": "^3.1.0",
+ "cli-spinners": "^2.5.0",
+ "is-interactive": "^1.0.0",
+ "is-unicode-supported": "^0.1.0",
+ "log-symbols": "^4.1.0",
+ "strip-ansi": "^6.0.0",
+ "wcwidth": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ora/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
},
"engines": {
- "node": ">=6"
+ "node": ">=10"
},
"funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/os-browserify": {
@@ -10998,6 +12355,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/p-queue": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-5.0.0.tgz",
+ "integrity": "sha512-6QfeouDf236N+MAxHch0CVIy8o/KBnmhttKjxZoOkUlzqU+u9rZgEyXH3OdckhTgawbqf5rpzmyR+07+Lv0+zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -11196,6 +12566,16 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/path-browserify": {
"version": "0.0.1",
"dev": true,
@@ -11435,6 +12815,20 @@
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
},
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/proxy-agent": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.3.0.tgz",
@@ -11709,6 +13103,22 @@
"teleport": ">=0.2.0"
}
},
+ "node_modules/qs": {
+ "version": "6.15.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/query-selector-shadow-dom": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
@@ -11733,6 +13143,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/queue": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
+ "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "~2.0.3"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"dev": true,
@@ -11776,6 +13196,45 @@
"safe-buffer": "^5.1.0"
}
},
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/raw-body/node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/react-is": {
"version": "18.2.0",
"license": "MIT"
@@ -12110,6 +13569,27 @@
"fast-deep-equal": "^2.0.1"
}
},
+ "node_modules/restore-cursor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
+ "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "onetime": "^5.1.0",
+ "signal-exit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/restore-cursor/node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/ret": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
@@ -12330,6 +13810,38 @@
"node": ">=10"
}
},
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/serialize-error": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-11.0.3.tgz",
@@ -12362,11 +13874,34 @@
"randombytes": "^2.1.0"
}
},
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/sha.js": {
"version": "2.4.11",
"dev": true,
@@ -12421,6 +13956,82 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"license": "ISC",
@@ -12908,6 +14519,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/stream-browserify": {
"version": "2.0.2",
"dev": true,
@@ -13026,6 +14647,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",
@@ -13113,25 +14744,134 @@
"dev": true,
"license": "MIT",
"dependencies": {
- "acorn-node": "^1.2.0"
+ "acorn-node": "^1.2.0"
+ }
+ },
+ "node_modules/temp": {
+ "version": "0.8.3",
+ "engines": [
+ "node >=0.8.0"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "os-tmpdir": "^1.0.0",
+ "rimraf": "~2.2.6"
+ }
+ },
+ "node_modules/temp/node_modules/rimraf": {
+ "version": "2.2.8",
+ "license": "MIT",
+ "bin": {
+ "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.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "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.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
+ "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
}
},
- "node_modules/temp": {
- "version": "0.8.3",
- "engines": [
- "node >=0.8.0"
- ],
- "license": "MIT",
+ "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": {
- "os-tmpdir": "^1.0.0",
- "rimraf": "~2.2.6"
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/temp/node_modules/rimraf": {
- "version": "2.2.8",
- "license": "MIT",
- "bin": {
- "rimraf": "bin.js"
+ "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": {
@@ -13191,6 +14931,33 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tmp": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz",
+ "integrity": "sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "rimraf": "^2.6.3"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tmp/node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
"node_modules/to-arraybuffer": {
"version": "1.0.1",
"dev": true,
@@ -13207,6 +14974,16 @@
"node": ">=8.0"
}
},
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
"node_modules/tough-cookie": {
"version": "4.1.3",
"dev": true,
@@ -13374,6 +15151,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"
@@ -13402,6 +15194,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/typedarray": {
"version": "0.0.6",
"dev": true,
@@ -13437,6 +15243,8 @@
"url": "https://github.com/sponsors/faisalman"
}
],
+ "optional": true,
+ "peer": true,
"bin": {
"ua-parser-js": "script/cli.js"
},
@@ -13687,6 +15495,16 @@
"node": ">= 4.0.0"
}
},
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/untildify": {
"version": "4.0.0",
"dev": true,
@@ -13775,6 +15593,16 @@
"integrity": "sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==",
"dev": true
},
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@@ -13783,6 +15611,8 @@
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
+ "optional": true,
+ "peer": true,
"bin": {
"uuid": "dist/bin/uuid"
}
@@ -13792,6 +15622,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,
@@ -14362,6 +16225,16 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
+ "node_modules/wcwidth": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
+ "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "defaults": "^1.0.3"
+ }
+ },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -14605,6 +16478,16 @@
"node": "*"
}
},
+ "node_modules/yazl": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/yazl/-/yazl-3.3.1.tgz",
+ "integrity": "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "^1.0.0"
+ }
+ },
"node_modules/yn": {
"version": "3.1.1",
"dev": true,
@@ -14623,6 +16506,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/yoctocolors-cjs": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz",
+ "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/zip-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
@@ -14773,6 +16669,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
@@ -15408,6 +17310,12 @@
"@gemini-testing/commander": {
"version": "2.15.4"
},
+ "@gemini-testing/sql.js": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@gemini-testing/sql.js/-/sql.js-3.0.0.tgz",
+ "integrity": "sha512-q/g1XuUdTc5v/eBspc7KJYS3RHeyIPmGN5K9N9YZRO+Op01GF4kZIC5RpjYIE8g61JAc23zOLlYMVLJB7SuIKA==",
+ "dev": true
+ },
"@humanwhocodes/config-array": {
"version": "0.10.7",
"dev": true,
@@ -15446,6 +17354,64 @@
"version": "3.0.2",
"dev": true
},
+ "@inquirer/ansi": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
+ "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==",
+ "dev": true
+ },
+ "@inquirer/confirm": {
+ "version": "5.1.21",
+ "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz",
+ "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==",
+ "dev": true,
+ "requires": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10"
+ }
+ },
+ "@inquirer/core": {
+ "version": "10.3.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz",
+ "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==",
+ "dev": true,
+ "requires": {
+ "@inquirer/ansi": "^1.0.2",
+ "@inquirer/figures": "^1.0.15",
+ "@inquirer/type": "^3.0.10",
+ "cli-width": "^4.1.0",
+ "mute-stream": "^2.0.0",
+ "signal-exit": "^4.1.0",
+ "wrap-ansi": "^6.2.0",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "dependencies": {
+ "wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ }
+ }
+ }
+ },
+ "@inquirer/figures": {
+ "version": "1.0.15",
+ "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz",
+ "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==",
+ "dev": true
+ },
+ "@inquirer/type": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz",
+ "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==",
+ "dev": true,
+ "requires": {}
+ },
"@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -15504,6 +17470,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": {
@@ -15955,6 +17927,8 @@
"version": "8.32.5",
"resolved": "https://registry.npmjs.org/@testplane/devtools/-/devtools-8.32.5.tgz",
"integrity": "sha512-VpQIUDMgBB65iao2WgeuoqEPdiwek9bJEVTLtWZikxfDabkPEVwODelJmgh6Yy5lvEIDsHinsJVGvJhg2y+5fg==",
+ "optional": true,
+ "peer": true,
"requires": {
"@testplane/wdio-config": "9.5.4",
"@testplane/wdio-logger": "9.4.7",
@@ -15976,6 +17950,8 @@
"version": "9.4.7",
"resolved": "https://registry.npmjs.org/@testplane/wdio-logger/-/wdio-logger-9.4.7.tgz",
"integrity": "sha512-O7wuGM1S3BmptkyaFgozMPWaLJP9d3TGXH0ZwVAOji6UO02ROBSYCvFvt5yPWC93mN4/ocpApF0Oj0gv9dkaiQ==",
+ "optional": true,
+ "peer": true,
"requires": {
"chalk": "^4.1.2",
"loglevel": "^1.6.0",
@@ -15987,6 +17963,8 @@
"version": "9.5.4",
"resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.4.tgz",
"integrity": "sha512-r6M7+T9lSEAiuM79XN3tc1/ARMayEINmvjjgroRgOKGv9Eks3KlzqJIjqhhHZcms9MtTueYvhrL/qUcAn4t9rA==",
+ "optional": true,
+ "peer": true,
"requires": {
"@types/node": "^20.1.0"
}
@@ -15995,6 +17973,8 @@
"version": "20.19.42",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz",
"integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==",
+ "optional": true,
+ "peer": true,
"requires": {
"undici-types": "~6.21.0"
}
@@ -16003,6 +17983,8 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "optional": true,
+ "peer": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
@@ -16011,17 +17993,23 @@
"isexe": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
- "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="
+ "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+ "optional": true,
+ "peer": true
},
"undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "optional": true,
+ "peer": true
},
"which": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
"integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+ "optional": true,
+ "peer": true,
"requires": {
"isexe": "^3.1.1"
}
@@ -16123,6 +18111,8 @@
"version": "9.5.4",
"resolved": "https://registry.npmjs.org/@testplane/wdio-config/-/wdio-config-9.5.4.tgz",
"integrity": "sha512-GXZ/uSmTTFMTbmfhg6X34sSSzkxh+vx3mF/XPFoWshmEg5n/rIXwbbL5FOKoggrEmdgQJZ/0hTjxZJMlHyf5Kg==",
+ "optional": true,
+ "peer": true,
"requires": {
"@testplane/wdio-logger": "9.4.7",
"@testplane/wdio-types": "9.5.4",
@@ -16136,6 +18126,8 @@
"version": "9.4.7",
"resolved": "https://registry.npmjs.org/@testplane/wdio-logger/-/wdio-logger-9.4.7.tgz",
"integrity": "sha512-O7wuGM1S3BmptkyaFgozMPWaLJP9d3TGXH0ZwVAOji6UO02ROBSYCvFvt5yPWC93mN4/ocpApF0Oj0gv9dkaiQ==",
+ "optional": true,
+ "peer": true,
"requires": {
"chalk": "^4.1.2",
"loglevel": "^1.6.0",
@@ -16147,6 +18139,8 @@
"version": "9.5.4",
"resolved": "https://registry.npmjs.org/@testplane/wdio-types/-/wdio-types-9.5.4.tgz",
"integrity": "sha512-r6M7+T9lSEAiuM79XN3tc1/ARMayEINmvjjgroRgOKGv9Eks3KlzqJIjqhhHZcms9MtTueYvhrL/qUcAn4t9rA==",
+ "optional": true,
+ "peer": true,
"requires": {
"@types/node": "^20.1.0"
}
@@ -16155,6 +18149,8 @@
"version": "20.19.42",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz",
"integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==",
+ "optional": true,
+ "peer": true,
"requires": {
"undici-types": "~6.21.0"
}
@@ -16163,6 +18159,8 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "optional": true,
+ "peer": true,
"requires": {
"balanced-match": "^1.0.0"
}
@@ -16171,6 +18169,8 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "optional": true,
+ "peer": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
@@ -16180,6 +18180,8 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "optional": true,
+ "peer": true,
"requires": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
@@ -16193,6 +18195,8 @@
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "optional": true,
+ "peer": true,
"requires": {
"brace-expansion": "^2.0.1"
}
@@ -16200,7 +18204,9 @@
"undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "optional": true,
+ "peer": true
}
}
},
@@ -17266,6 +19272,12 @@
"ansi-colors": {
"version": "4.1.1"
},
+ "ansi-html-community": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz",
+ "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==",
+ "dev": true
+ },
"ansi-regex": {
"version": "5.0.1"
},
@@ -17447,6 +19459,12 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="
},
+ "array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "dev": true
+ },
"array-ify": {
"version": "1.0.0",
"dev": true
@@ -17505,6 +19523,17 @@
"version": "0.4.0",
"dev": true
},
+ "axios": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz",
+ "integrity": "sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww==",
+ "dev": true,
+ "requires": {
+ "follow-redirects": "^1.15.0",
+ "form-data": "^4.0.0",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
"b4a": {
"version": "1.6.4"
},
@@ -17576,8 +19605,34 @@
"binary-extensions": {
"version": "2.2.0"
},
+ "bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "dev": true,
+ "requires": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ },
+ "dependencies": {
+ "readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "dev": true,
+ "requires": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ }
+ }
+ }
+ },
"bluebird": {
- "version": "3.5.1",
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"dev": true
},
"bn.js": {
@@ -17586,6 +19641,37 @@
"integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==",
"dev": true
},
+ "body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "dev": true,
+ "requires": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "dependencies": {
+ "iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dev": true,
+ "requires": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ }
+ }
+ }
+ },
"brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -17830,6 +19916,93 @@
"version": "3.0.0",
"dev": true
},
+ "bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "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-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",
@@ -17860,6 +20033,26 @@
"version": "1.1.0",
"dev": true
},
+ "call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "requires": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ }
+ },
+ "call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "requires": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ }
+ },
"caller-path": {
"version": "0.1.0",
"requires": {
@@ -17982,6 +20175,8 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.1.2.tgz",
"integrity": "sha512-YclTJey34KUm5jB1aEJCq807bSievi7Nb/TU4Gu504fUYi3jw3KCIaH6L7nFWQhdEgH3V+wCh+kKD1P5cXnfxw==",
+ "optional": true,
+ "peer": true,
"requires": {
"@types/node": "*",
"escape-string-regexp": "^4.0.0",
@@ -17992,7 +20187,9 @@
"escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
- "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "optional": true,
+ "peer": true
}
}
},
@@ -18022,6 +20219,15 @@
"resolve-from": "^1.0.0"
}
},
+ "cli-cursor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
+ "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
+ "dev": true,
+ "requires": {
+ "restore-cursor": "^3.1.0"
+ }
+ },
"cli-progress": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
@@ -18030,6 +20236,18 @@
"string-width": "^4.2.3"
}
},
+ "cli-spinners": {
+ "version": "2.9.2",
+ "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
+ "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
+ "dev": true
+ },
+ "cli-width": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
+ "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
+ "dev": true
+ },
"cliui": {
"version": "7.0.4",
"requires": {
@@ -18038,6 +20256,12 @@
"wrap-ansi": "^7.0.0"
}
},
+ "clone": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
+ "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==",
+ "dev": true
+ },
"color-convert": {
"version": "2.0.1",
"requires": {
@@ -18231,6 +20455,29 @@
"version": "1.0.0",
"dev": true
},
+ "content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "5.2.1"
+ },
+ "dependencies": {
+ "safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true
+ }
+ }
+ },
+ "content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "dev": true
+ },
"conventional-changelog": {
"version": "3.1.25",
"dev": true,
@@ -18653,6 +20900,12 @@
"cookie": {
"version": "0.4.2"
},
+ "cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "dev": true
+ },
"copyfiles": {
"version": "2.4.1",
"dev": true,
@@ -18964,11 +21217,26 @@
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="
},
+ "defaults": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
+ "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==",
+ "dev": true,
+ "requires": {
+ "clone": "^1.0.2"
+ }
+ },
"defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="
},
+ "define-lazy-prop": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
+ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
+ "dev": true
+ },
"defined": {
"version": "1.0.0",
"dev": true
@@ -18987,6 +21255,12 @@
"version": "1.0.0",
"dev": true
},
+ "depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "dev": true
+ },
"deps-sort": {
"version": "2.0.1",
"dev": true,
@@ -19005,6 +21279,12 @@
"minimalistic-assert": "^1.0.0"
}
},
+ "destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "dev": true
+ },
"detect-indent": {
"version": "6.1.0",
"dev": true
@@ -19172,6 +21452,17 @@
}
}
},
+ "dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "requires": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ }
+ },
"duplexer2": {
"version": "0.1.4",
"dev": true,
@@ -19227,6 +21518,12 @@
}
}
},
+ "ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "dev": true
+ },
"elliptic": {
"version": "6.5.5",
"dev": true,
@@ -19251,6 +21548,12 @@
"emoji-regex": {
"version": "8.0.0"
},
+ "encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "dev": true
+ },
"end-of-stream": {
"version": "1.4.1",
"requires": {
@@ -19346,6 +21649,27 @@
"stackframe": "^1.3.4"
}
},
+ "es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true
+ },
+ "es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true
+ },
+ "es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "requires": {
+ "es-errors": "^1.3.0"
+ }
+ },
"esbuild": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
@@ -19382,6 +21706,12 @@
"escalade": {
"version": "3.1.1"
},
+ "escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "dev": true
+ },
"escape-string-regexp": {
"version": "1.0.5"
},
@@ -19629,11 +21959,29 @@
"esutils": {
"version": "2.0.2"
},
+ "etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "dev": true
+ },
"event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
},
+ "eventemitter2": {
+ "version": "6.4.7",
+ "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz",
+ "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==",
+ "dev": true
+ },
+ "eventemitter3": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
+ "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
+ "dev": true
+ },
"events": {
"version": "1.1.1",
"dev": true
@@ -19694,6 +22042,65 @@
"jest-matcher-utils": "^28.1.0"
}
},
+ "express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "dev": true,
+ "requires": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "dependencies": {
+ "cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "dev": true
+ },
+ "path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "dev": true
+ },
+ "safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true
+ }
+ }
+ },
"extend": {
"version": "3.0.2",
"dev": true
@@ -19841,6 +22248,12 @@
"flat-cache": "^3.0.4"
}
},
+ "filesize": {
+ "version": "8.0.7",
+ "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz",
+ "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==",
+ "dev": true
+ },
"fill-keys": {
"version": "1.0.2",
"dev": true,
@@ -19857,6 +22270,21 @@
"to-regex-range": "^5.0.1"
}
},
+ "finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "dev": true,
+ "requires": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ }
+ },
"find-up": {
"version": "4.1.0",
"dev": true,
@@ -19889,6 +22317,12 @@
"version": "3.2.7",
"dev": true
},
+ "follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "dev": true
+ },
"foreach": {
"version": "2.0.6",
"dev": true
@@ -19928,6 +22362,18 @@
"fetch-blob": "^3.1.2"
}
},
+ "forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "dev": true
+ },
+ "fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "dev": true
+ },
"fs-extra": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
@@ -20056,6 +22502,24 @@
"version": "2.0.2",
"dev": true
},
+ "get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "requires": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ }
+ },
"get-pkg-repo": {
"version": "4.2.1",
"dev": true,
@@ -20069,6 +22533,16 @@
"get-port": {
"version": "5.1.1"
},
+ "get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "requires": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ }
+ },
"get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@@ -20253,6 +22727,12 @@
"slash": "^3.0.0"
}
},
+ "gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true
+ },
"got": {
"version": "12.6.1",
"resolved": "https://registry.npmjs.org/got/-/got-12.6.1.tgz",
@@ -20326,6 +22806,12 @@
"has-flag": {
"version": "3.0.0"
},
+ "has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true
+ },
"hash-base": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz",
@@ -20385,6 +22871,80 @@
"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.9.3",
+ "resolved": "https://registry.npmjs.org/html-reporter/-/html-reporter-11.9.3.tgz",
+ "integrity": "sha512-PGqkeCwJg5m8byPt2Y5o/QjjrT//6V75c6GfbwtkT3NjqLIedKOCB6t5J/0AE2n46xLBXVMQSK2aSdaH4KCr0A==",
+ "dev": true,
+ "requires": {
+ "@gemini-testing/commander": "^2.15.3",
+ "@gemini-testing/sql.js": "^3.0.0",
+ "@inquirer/confirm": "^5.1.15",
+ "ansi-html-community": "^0.0.8",
+ "axios": "1.6.3",
+ "bluebird": "^3.5.3",
+ "body-parser": "^1.18.2",
+ "chalk": "^4.1.2",
+ "debug": "^4.1.1",
+ "escape-html": "^1.0.3",
+ "eventemitter2": "6.4.7",
+ "express": "^4.16.2",
+ "fast-glob": "^3.2.12",
+ "filesize": "^8.0.6",
+ "fs-extra": "^7.0.1",
+ "gemini-configparser": "^1.4.2",
+ "http-codes": "1.0.0",
+ "image-size": "^1.0.2",
+ "json-stringify-safe": "^5.0.1",
+ "lodash": "^4.17.4",
+ "looks-same": "^10.0.1",
+ "nested-error-stacks": "^2.1.0",
+ "npm-which": "^3.0.1",
+ "open": "^8.4.2",
+ "ora": "^5.4.1",
+ "p-queue": "^5.0.0",
+ "qs": "^6.9.1",
+ "signal-exit": "^4.1.0",
+ "strip-ansi": "^6.0.1",
+ "tmp": "^0.1.0",
+ "url-join": "^4.0.1",
+ "worker-farm": "^1.7.0",
+ "yazl": "^3.3.1"
+ },
+ "dependencies": {
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "requires": {
+ "ms": "^2.1.3"
+ }
+ },
+ "ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ }
+ }
+ },
"htmlescape": {
"version": "1.1.1",
"dev": true
@@ -20409,6 +22969,25 @@
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="
},
+ "http-codes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/http-codes/-/http-codes-1.0.0.tgz",
+ "integrity": "sha512-yPS0/Sp66fmRSpZ6rbeFinytku7KnBiLa/CaDxHbcr0gAZuQqeGjgyfElyQ5xPiK21e4oL4iqooHLRcr2s7LoA==",
+ "dev": true
+ },
+ "http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "dev": true,
+ "requires": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ }
+ },
"http-proxy-agent": {
"version": "7.0.2",
"requires": {
@@ -20508,6 +23087,15 @@
"version": "5.3.0",
"dev": true
},
+ "image-size": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
+ "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
+ "dev": true,
+ "requires": {
+ "queue": "6.0.2"
+ }
+ },
"immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -20596,6 +23184,12 @@
"sprintf-js": "^1.1.3"
}
},
+ "ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "dev": true
+ },
"is-alphabetical": {
"version": "1.0.4",
"dev": true
@@ -20638,7 +23232,8 @@
"is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
- "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "devOptional": true
},
"is-extglob": {
"version": "2.1.1"
@@ -20656,6 +23251,12 @@
"version": "1.0.4",
"dev": true
},
+ "is-interactive": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
+ "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==",
+ "dev": true
+ },
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -20696,6 +23297,7 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "devOptional": true,
"requires": {
"is-docker": "^2.0.0"
}
@@ -20706,6 +23308,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",
@@ -20889,6 +23518,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": {
@@ -20985,6 +23620,8 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.1.tgz",
"integrity": "sha512-ioBrW3s2i97noEmnXxmUq7cjIcVRjT5HBpAYy8zE11CxU9HqlWHHeRxfeN1tn8F7OEMVPIC9x1f8t3Z7US9ehQ==",
+ "optional": true,
+ "peer": true,
"requires": {
"debug": "^2.6.9",
"marky": "^1.2.2"
@@ -21015,10 +23652,6 @@
"pify": {
"version": "3.0.0",
"dev": true
- },
- "strip-bom": {
- "version": "3.0.0",
- "dev": true
}
}
},
@@ -21172,6 +23805,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
@@ -21190,7 +23832,15 @@
"marky": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz",
- "integrity": "sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q=="
+ "integrity": "sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==",
+ "optional": true,
+ "peer": true
+ },
+ "math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true
},
"md5.js": {
"version": "1.3.5",
@@ -21300,6 +23950,12 @@
"version": "2.0.0",
"dev": true
},
+ "media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "dev": true
+ },
"meow": {
"version": "8.1.2",
"dev": true,
@@ -21324,7 +23980,9 @@
}
},
"merge-descriptors": {
- "version": "1.0.1",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"dev": true
},
"merge-stream": {
@@ -21335,6 +23993,12 @@
"version": "1.4.1",
"dev": true
},
+ "methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "dev": true
+ },
"micromark": {
"version": "2.11.4",
"dev": true,
@@ -21441,6 +24105,12 @@
}
}
},
+ "mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "dev": true
+ },
"mime-db": {
"version": "1.52.0"
},
@@ -21673,6 +24343,12 @@
"ms": {
"version": "2.0.0"
},
+ "mute-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
+ "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==",
+ "dev": true
+ },
"nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -21780,6 +24456,26 @@
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz",
"integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ=="
},
+ "npm-path": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/npm-path/-/npm-path-2.0.4.tgz",
+ "integrity": "sha512-IFsj0R9C7ZdR5cP+ET342q77uSRdtWOlWpih5eC+lu29tIDbNEgDbzgVJ5UFvYHWhxDZ5TFkJafFioO0pPQjCw==",
+ "dev": true,
+ "requires": {
+ "which": "^1.2.10"
+ },
+ "dependencies": {
+ "which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "dev": true,
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ }
+ }
+ },
"npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
@@ -21789,6 +24485,34 @@
"path-key": "^3.0.0"
}
},
+ "npm-which": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/npm-which/-/npm-which-3.0.1.tgz",
+ "integrity": "sha512-CM8vMpeFQ7MAPin0U3wzDhSGV0hMHNwHU0wjo402IVizPDrs45jSfSuoC+wThevY88LQti8VvaAnqYAeVy3I1A==",
+ "dev": true,
+ "requires": {
+ "commander": "^2.9.0",
+ "npm-path": "^2.0.2",
+ "which": "^1.2.10"
+ },
+ "dependencies": {
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "dev": true,
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ }
+ }
+ },
"nwsapi": {
"version": "2.2.7",
"dev": true
@@ -21796,10 +24520,25 @@
"object-assign": {
"version": "4.1.1"
},
+ "object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true
+ },
"object-keys": {
"version": "1.1.1",
"dev": true
},
+ "on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dev": true,
+ "requires": {
+ "ee-first": "1.1.1"
+ }
+ },
"once": {
"version": "1.4.0",
"requires": {
@@ -21828,6 +24567,46 @@
"mimic-fn": "^2.1.0"
}
},
+ "open": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
+ "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
+ "dev": true,
+ "requires": {
+ "define-lazy-prop": "^2.0.0",
+ "is-docker": "^2.1.1",
+ "is-wsl": "^2.2.0"
+ }
+ },
+ "ora": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
+ "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==",
+ "dev": true,
+ "requires": {
+ "bl": "^4.1.0",
+ "chalk": "^4.1.0",
+ "cli-cursor": "^3.1.0",
+ "cli-spinners": "^2.5.0",
+ "is-interactive": "^1.0.0",
+ "is-unicode-supported": "^0.1.0",
+ "log-symbols": "^4.1.0",
+ "strip-ansi": "^6.0.0",
+ "wcwidth": "^1.0.1"
+ },
+ "dependencies": {
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ }
+ }
+ },
"os-browserify": {
"version": "0.1.2",
"dev": true
@@ -21866,6 +24645,15 @@
}
}
},
+ "p-queue": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-5.0.0.tgz",
+ "integrity": "sha512-6QfeouDf236N+MAxHch0CVIy8o/KBnmhttKjxZoOkUlzqU+u9rZgEyXH3OdckhTgawbqf5rpzmyR+07+Lv0+zg==",
+ "dev": true,
+ "requires": {
+ "eventemitter3": "^3.1.0"
+ }
+ },
"p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -22004,6 +24792,12 @@
}
}
},
+ "parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "dev": true
+ },
"path-browserify": {
"version": "0.0.1",
"dev": true
@@ -22149,6 +24943,16 @@
}
}
},
+ "proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dev": true,
+ "requires": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ }
+ },
"proxy-agent": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.3.0.tgz",
@@ -22350,6 +25154,15 @@
"version": "1.5.1",
"dev": true
},
+ "qs": {
+ "version": "6.15.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+ "dev": true,
+ "requires": {
+ "side-channel": "^1.1.0"
+ }
+ },
"query-selector-shadow-dom": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
@@ -22367,6 +25180,15 @@
"version": "2.2.0",
"dev": true
},
+ "queue": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
+ "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
+ "dev": true,
+ "requires": {
+ "inherits": "~2.0.3"
+ }
+ },
"queue-microtask": {
"version": "1.2.3",
"dev": true
@@ -22389,6 +25211,35 @@
"safe-buffer": "^5.1.0"
}
},
+ "range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "dev": true
+ },
+ "raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "dev": true,
+ "requires": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "dependencies": {
+ "iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dev": true,
+ "requires": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ }
+ }
+ }
+ },
"react-is": {
"version": "18.2.0"
},
@@ -22619,6 +25470,24 @@
"fast-deep-equal": "^2.0.1"
}
},
+ "restore-cursor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
+ "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
+ "dev": true,
+ "requires": {
+ "onetime": "^5.1.0",
+ "signal-exit": "^3.0.2"
+ },
+ "dependencies": {
+ "signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true
+ }
+ }
+ },
"ret": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
@@ -22761,6 +25630,35 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="
},
+ "send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "dev": true,
+ "requires": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "dependencies": {
+ "ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ }
+ }
+ },
"serialize-error": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-11.0.3.tgz",
@@ -22782,11 +25680,29 @@
"randombytes": "^2.1.0"
}
},
+ "serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "dev": true,
+ "requires": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ }
+ },
"setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
+ "setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "dev": true
+ },
"sha.js": {
"version": "2.4.11",
"dev": true,
@@ -22823,6 +25739,54 @@
"version": "1.8.1",
"dev": true
},
+ "side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "dev": true,
+ "requires": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ }
+ },
+ "side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "dev": true,
+ "requires": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ }
+ },
+ "side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
+ "requires": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ }
+ },
+ "side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
+ "requires": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ }
+ },
"signal-exit": {
"version": "4.1.0"
},
@@ -23135,6 +26099,12 @@
}
}
},
+ "statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "dev": true
+ },
"stream-browserify": {
"version": "2.0.2",
"dev": true,
@@ -23225,6 +26195,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",
@@ -23291,6 +26267,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.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "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.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
+ "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+ "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",
@@ -23335,6 +26388,26 @@
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="
},
+ "tmp": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz",
+ "integrity": "sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==",
+ "dev": true,
+ "requires": {
+ "rimraf": "^2.6.3"
+ },
+ "dependencies": {
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ }
+ }
+ },
"to-arraybuffer": {
"version": "1.0.1",
"dev": true
@@ -23347,6 +26420,12 @@
"is-number": "^7.0.0"
}
},
+ "toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "dev": true
+ },
"tough-cookie": {
"version": "4.1.3",
"dev": true,
@@ -23447,6 +26526,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"
},
@@ -23462,6 +26552,16 @@
"version": "3.11.1",
"dev": true
},
+ "type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "dev": true,
+ "requires": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ }
+ },
"typedarray": {
"version": "0.0.6",
"dev": true
@@ -23473,7 +26573,9 @@
"ua-parser-js": {
"version": "1.0.39",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz",
- "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw=="
+ "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==",
+ "optional": true,
+ "peer": true
},
"uglify-js": {
"version": "2.8.29",
@@ -23629,6 +26731,12 @@
"universalify": {
"version": "0.1.2"
},
+ "unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "dev": true
+ },
"untildify": {
"version": "4.0.0",
"dev": true
@@ -23704,15 +26812,52 @@
"util-deprecate": {
"version": "1.0.2"
},
+ "utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "dev": true
+ },
"uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
- "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "optional": true,
+ "peer": true
},
"v8-compile-cache-lib": {
"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,
@@ -23971,6 +27116,15 @@
}
}
},
+ "wcwidth": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
+ "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
+ "dev": true,
+ "requires": {
+ "defaults": "^1.0.3"
+ }
+ },
"web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -24120,6 +27274,15 @@
}
}
},
+ "yazl": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/yazl/-/yazl-3.3.1.tgz",
+ "integrity": "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==",
+ "dev": true,
+ "requires": {
+ "buffer-crc32": "^1.0.0"
+ }
+ },
"yn": {
"version": "3.1.1",
"dev": true
@@ -24127,6 +27290,12 @@
"yocto-queue": {
"version": "0.1.0"
},
+ "yoctocolors-cjs": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz",
+ "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==",
+ "dev": true
+ },
"zip-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
diff --git a/package.json b/package.json
index 560155159..d3e0155ad 100644
--- a/package.json
+++ b/package.json
@@ -18,22 +18,30 @@
}
},
"scripts": {
- "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",
+ "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",
+ "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": "_mocha \"test/!(integration|e2e)/**/*.js\"",
- "test-e2e": "_mocha \"test/e2e/**/*.test.js\" --ignore \"test/e2e/**/fixture-project/**\"",
+ "test-unit": "node scripts/run-node-without-type-stripping.js ./node_modules/mocha/bin/_mocha \"test/!(integration|e2e|browser-env|fixtures)/**/*.[jt]s\"",
+ "test-unit:coverage": "c8 --all --src=src --reporter=html --reporter=text-summary --exclude=\"build/**\" --exclude=\"test/**\" --exclude=\"**/*.d.ts\" node scripts/run-node-without-type-stripping.js ./node_modules/mocha/bin/_mocha \"test/!(integration|browser-env|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 generate",
"test": "npm run test-unit && npm run check-types && npm run lint",
- "test-integration": "mocha -r ts-node/register -r test/integration/*/**",
+ "test-integration": "TS_NODE_TRANSPILE_ONLY=1 node scripts/run-node-without-type-stripping.js ./node_modules/mocha/bin/_mocha -r ts-node/register test/integration/*/**",
+ "test-e2e": "npm run test-e2e-repl && npm run test-e2e:generate-fixtures && npm run test-e2e:run-tests",
+ "test-e2e-repl": "_mocha \"test/e2e/**/*.test.js\" --ignore \"test/e2e/**/fixture-project/**\"",
+ "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": "TS_NODE_PROJECT=./test/browser-env/tsconfig.json node bin/testplane -r tsconfig-paths/register --config test/browser-env/testplane.config.ts",
+ "test-browser-env:gui": "TS_NODE_PROJECT=./test/browser-env/tsconfig.json node bin/testplane gui -r tsconfig-paths/register --config test/browser-env/testplane.config.ts",
"toc": "doctoc docs --title '### Contents'",
"precommit": "npm run lint",
"prepack": "npm run clean && npm run build",
@@ -50,7 +58,7 @@
},
"homepage": "https://testplane.io/",
"engines": {
- "node": ">= 18.17.0"
+ "node": ">= 22"
},
"keywords": [
"testplane",
@@ -70,7 +78,6 @@
"@jspm/core": "2.0.1",
"@jsquash/png": "3.1.1",
"@puppeteer/browsers": "2.7.1",
- "@testplane/devtools": "8.32.5",
"@testplane/wdio-protocols": "9.4.7",
"@testplane/wdio-utils": "9.5.4",
"@testplane/webdriverio": "9.5.28",
@@ -155,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",
@@ -165,6 +173,7 @@
"eslint-config-prettier": "8.7.0",
"execa": "5.1.1",
"glob-extra": "5.0.2",
+ "html-reporter": "11.9.3",
"husky": "0.11.4",
"js-levenshtein": "1.1.6",
"jsdom": "^24.0.0",
@@ -177,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/scripts/create-client-scripts-symlinks.js b/scripts/create-client-scripts-symlinks.js
index bebb3d962..d52ca2b2c 100755
--- a/scripts/create-client-scripts-symlinks.js
+++ b/scripts/create-client-scripts-symlinks.js
@@ -23,6 +23,19 @@ files.forEach(file => {
console.log(`Created symlink: ${srcPath} -> ${relativePath}`);
} catch (error) {
- console.warn(`Failed to create symlink for ${file}: ${error.message}`);
+ if (error.code !== "EEXIST") {
+ console.warn(`Failed to create symlink for ${file}: ${error.message}`);
+ }
}
});
+
+// Create symlink for lib for tests
+try {
+ const libNativePath = path.join(srcDir, "lib.native.js");
+ const libPath = path.join(srcDir, "lib.js");
+ fs.symlinkSync(libNativePath, libPath);
+} catch (e) {
+ if (e.code !== "EEXIST") {
+ console.warn(`Failed to create symlink for lib: ${e.message}`);
+ }
+}
diff --git a/scripts/run-node-without-type-stripping.js b/scripts/run-node-without-type-stripping.js
new file mode 100644
index 000000000..2c536cf28
--- /dev/null
+++ b/scripts/run-node-without-type-stripping.js
@@ -0,0 +1,27 @@
+"use strict";
+
+const { spawnSync } = require("node:child_process");
+
+const DISABLE_TYPE_STRIPPING_FLAG = "--no-experimental-strip-types";
+
+const supportsDisableTypeStripping = () => {
+ const result = spawnSync(process.execPath, [DISABLE_TYPE_STRIPPING_FLAG, "-e", ""], {
+ stdio: "ignore",
+ });
+
+ return result.status === 0;
+};
+
+const nodeArgs = supportsDisableTypeStripping() ? [DISABLE_TYPE_STRIPPING_FLAG] : [];
+const commandArgs = process.argv.slice(2);
+
+const result = spawnSync(process.execPath, [...nodeArgs, ...commandArgs], {
+ stdio: "inherit",
+ env: process.env,
+});
+
+if (result.error) {
+ throw result.error;
+}
+
+process.exit(result.status ?? 1);
diff --git a/src/base-testplane.ts b/src/base-testplane.ts
index f4944119c..47bdd168b 100644
--- a/src/base-testplane.ts
+++ b/src/base-testplane.ts
@@ -17,24 +17,33 @@ import { ConfigInput } from "./config/types";
export abstract class BaseTestplane extends AsyncEmitter {
protected _interceptors: Interceptor[] = [];
- protected _config: Config;
+ protected _config!: Config;
protected _initEventEmited: boolean = false;
+ private _pendingConfig?: string | ConfigInput;
- static create(
+ static async create(
this: new (config?: string | ConfigInput) => T,
config?: string | ConfigInput,
- ): T {
- return new this(config);
+ ): Promise {
+ const instance = new this(config);
+
+ await instance._setup();
+
+ return instance;
}
protected constructor(config?: string | ConfigInput) {
super();
this._interceptors = [];
+ this._pendingConfig = config;
+ }
+ protected async _setup(): Promise {
registerReplModuleHooks();
registerTransformHook(this.isWorker());
- this._config = Config.create(config);
+ this._config = await Config.create(this._pendingConfig);
+ this._pendingConfig = undefined;
updateTransformHook(this._config);
this._setLogLevel();
diff --git a/src/browser/.gitignore b/src/browser/.gitignore
new file mode 100644
index 000000000..4302f01bc
--- /dev/null
+++ b/src/browser/.gitignore
@@ -0,0 +1 @@
+lib.js
diff --git a/src/browser/calibrator.ts b/src/browser/calibrator.ts
index 5234cfb6f..957707709 100644
--- a/src/browser/calibrator.ts
+++ b/src/browser/calibrator.ts
@@ -4,31 +4,25 @@ import looksSame from "looks-same";
import { CoreError } from "./core-error";
import { ExistingBrowser } from "./existing-browser";
import type { Image, RGB } from "../image";
+import { Coord, Length, Rect, Size, XBand, getHeight, getIntersection, getWidth } from "./isomorphic";
+import * as logger from "../utils/logger";
+import os from "node:os";
+import makeDebug from "debug";
-const DIRECTION = { FORWARD: "forward", REVERSE: "reverse" } as const;
+const debug = makeDebug("testplane:screenshots:calibrator");
interface BrowserFeatures {
needsCompatLib: boolean;
pixelRatio: number;
- innerWidth: number;
+ innerWidth: Length<"css", "x">;
}
export interface CalibrationResult extends BrowserFeatures {
- top: number;
- left: number;
+ viewportArea: Rect<"image", "device">;
+ screenshotSize: Size<"device">;
usePixelRatio: boolean;
}
-interface ViewportStart {
- x: number;
- y: number;
-}
-
-interface ImageAnalysisResult {
- viewportStart: ViewportStart;
- colorLength?: number;
-}
-
export class Calibrator {
private _cache: Record;
private _script: string;
@@ -43,19 +37,29 @@ export class Calibrator {
return this._cache[browser.id];
}
+ debug("calibrating browser %s", browser.id);
+
await browser.open("about:blank");
const features = await browser.evalScript(this._script);
+ debug("features: %O", features);
const image = await browser.captureViewportImage();
const { innerWidth, pixelRatio } = features;
const hasPixelRatio = Boolean(pixelRatio && pixelRatio > 1.0);
- const { width: imageWidth, height: imageHeight } = image.getSize();
- const searchColor = image.hasICCPChunk
- ? await image.getRGB(Math.floor(imageWidth / 2), Math.floor(imageHeight / 2))
+ const screenshotSize = image.getSize() as Size<"device">;
+ const searchColor: RGB = image.hasICCPChunk
+ ? await image.getRGB(Math.floor(screenshotSize.width / 2), Math.floor(screenshotSize.height / 2))
: { R: 148, G: 250, B: 0 };
- const imageFeatures = await this._analyzeImage(image, { calculateColorLength: hasPixelRatio, searchColor });
+ const imageFeatures = await this._findMarkerAreaInImage(image, searchColor);
if (!imageFeatures) {
+ const screenshotPath = path.join(os.tmpdir(), "testplane-calibration-page.png");
+ await image.save(screenshotPath);
+ logger.error(
+ "Could not calibrate, because marker area was not found. See calibration page screenshot for details: " +
+ screenshotPath,
+ );
+ await image.save(screenshotPath);
throw new CoreError(
"Could not calibrate. This could be due to calibration page has failed to open properly",
);
@@ -63,25 +67,48 @@ export class Calibrator {
const calibratedFeatures: CalibrationResult = {
...features,
- top: imageFeatures.viewportStart.y,
- left: imageFeatures.viewportStart.x,
- usePixelRatio: hasPixelRatio && imageFeatures.colorLength! > innerWidth,
+ viewportArea: imageFeatures,
+ screenshotSize,
+ usePixelRatio: hasPixelRatio && imageFeatures.width > innerWidth,
};
this._cache[browser.id] = calibratedFeatures;
return calibratedFeatures;
}
- private async _analyzeImage(
- image: Image,
- params: { calculateColorLength?: boolean; searchColor: RGB },
- ): Promise {
+ private async _findMarkerAreaInImage(image: Image, searchColor: RGB): Promise | null> {
const imageHeight = image.getSize().height;
- for (let y = 0; y < imageHeight; y++) {
- const result = await analyzeRow(y, image, params);
+ let topPart: Rect<"image", "device"> | null = null;
+
+ for (let y = 0 as Coord<"image", "device", "y">; y < imageHeight; y++) {
+ const result = await findMarkerXBandInRow(y, image, searchColor);
if (result) {
- return result;
+ topPart = {
+ top: y,
+ left: result.left,
+ width: result.width,
+ height: getHeight(y, imageHeight as Coord<"image", "device", "y">),
+ };
+ break;
+ }
+ }
+
+ if (topPart === null) {
+ return null;
+ }
+
+ for (let y = (imageHeight - 1) as Coord<"image", "device", "y">; y >= 0; y--) {
+ const result = await findMarkerXBandInRow(y, image, searchColor);
+ if (result) {
+ const bottomPart = {
+ top: 0,
+ left: result.left,
+ width: result.width,
+ height: getHeight(0 as Coord<"image", "device", "y">, (y + 1) as Coord<"image", "device", "y">),
+ };
+
+ return getIntersection(topPart, bottomPart);
}
}
@@ -89,63 +116,68 @@ export class Calibrator {
}
}
-async function analyzeRow(
- row: number,
+async function findMarkerXBandInRow(
+ row: Coord<"image", "device", "y">,
image: Image,
- params: { calculateColorLength?: boolean; searchColor: RGB },
-): Promise {
- const markerStart = await findMarkerInRow(row, image, DIRECTION.FORWARD, params.searchColor);
+ searchColor: RGB,
+): Promise | null> {
+ const markerStart = await findMarkerStartInRow(row, image, searchColor);
- if (markerStart === -1) {
+ if (markerStart === null) {
return null;
}
- const result: ImageAnalysisResult = { viewportStart: { x: markerStart, y: row } };
+ const markerEnd = await findMarkerEndInRow(row, image, searchColor);
- if (!params.calculateColorLength) {
- return result;
+ if (markerEnd === null) {
+ return null;
}
- const markerEnd = await findMarkerInRow(row, image, DIRECTION.REVERSE, params.searchColor);
- const colorLength = markerEnd - markerStart + 1;
+ return {
+ left: markerStart,
+ width: getWidth(markerStart, (markerEnd + 1) as Coord<"image", "device", "x">),
+ };
+}
+
+async function isMarkerColorAtPoint(
+ image: Image,
+ x: Coord<"image", "device", "x">,
+ y: Coord<"image", "device", "y">,
+ searchColor: RGB,
+): Promise {
+ const color = await image.getRGB(x, y);
- return { ...result, colorLength };
+ return looksSame.colors(color, searchColor);
}
-async function findMarkerInRow(
- row: number,
+async function findMarkerStartInRow(
+ row: Coord<"image", "device", "y">,
image: Image,
- searchDirection: "forward" | "reverse",
searchColor: RGB,
-): Promise {
+): Promise | null> {
const imageWidth = image.getSize().width;
- if (searchDirection === DIRECTION.REVERSE) {
- return searchReverse_();
- } else {
- return searchForward_();
- }
-
- async function searchForward_(): Promise {
- for (let x = 0; x < imageWidth; x++) {
- if (await compare_(x)) {
- return x;
- }
+ for (let x = 0 as Coord<"image", "device", "x">; x < imageWidth; x++) {
+ if (await isMarkerColorAtPoint(image, x, row, searchColor)) {
+ return x;
}
- return -1;
}
- async function searchReverse_(): Promise {
- for (let x = imageWidth - 1; x >= 0; x--) {
- if (await compare_(x)) {
- return x;
- }
+ return null;
+}
+
+async function findMarkerEndInRow(
+ row: Coord<"image", "device", "y">,
+ image: Image,
+ searchColor: RGB,
+): Promise | null> {
+ const imageWidth = image.getSize().width;
+
+ for (let x = (imageWidth - 1) as Coord<"image", "device", "x">; x >= 0; x--) {
+ if (await isMarkerColorAtPoint(image, x, row, searchColor)) {
+ return x;
}
- return -1;
}
- async function compare_(x: number): Promise {
- const color = await image.getRGB(x, row);
- return looksSame.colors(color, searchColor);
- }
+ return null;
}
diff --git a/src/browser/camera/index.ts b/src/browser/camera/index.ts
index 7d4d7ae39..72ed9f5a8 100644
--- a/src/browser/camera/index.ts
+++ b/src/browser/camera/index.ts
@@ -1,31 +1,43 @@
-import _ from "lodash";
+import os from "node:os";
+import path from "node:path";
+import makeDebug from "debug";
+
import { Image } from "../../image";
import * as utils from "./utils";
-
-export interface ImageArea {
- left: number;
- top: number;
- width: number;
- height: number;
-}
+import type { CropMargins } from "./utils";
+import * as logger from "../../utils/logger";
+import {
+ getIntersection,
+ type Coord,
+ type Point,
+ type Rect,
+ type Size,
+ prettyRect,
+ prettySize,
+ prettyPoint,
+} from "../isomorphic/geometry";
+import { NEW_ISSUE_LINK } from "../../constants/help";
+
+const debug = makeDebug("testplane:screenshots:camera");
export type ScreenshotMode = "fullpage" | "viewport" | "auto";
-
-export interface PageMeta {
- viewport: ImageArea;
- documentHeight: number;
- documentWidth: number;
-}
-
-interface Calibration {
- left: number;
- top: number;
+export type { CropMargins } from "./utils";
+
+export interface CaptureViewportImageOpts {
+ viewportOffset: Point<"page", "device">;
+ viewportSize: Size<"device">;
+ /** Delay before taking the screenshot, in milliseconds. */
+ screenshotDelay?: number;
+ /** Additional raw screenshot margins to crop, in physical pixels. */
+ cropMargins?: CropMargins;
}
export class Camera {
private _screenshotMode: ScreenshotMode;
private _takeScreenshot: () => Promise;
- private _calibration: Calibration | null;
+ private _calibratedArea: Rect<"image", "device"> | null;
+ private _calibrationScreenshotSize: Size<"device"> | null;
+ private _debugTmpDir: string | null = null;
static create(screenshotMode: ScreenshotMode, takeScreenshot: () => Promise): Camera {
return new this(screenshotMode, takeScreenshot);
@@ -34,22 +46,63 @@ export class Camera {
constructor(screenshotMode: ScreenshotMode, takeScreenshot: () => Promise) {
this._screenshotMode = screenshotMode;
this._takeScreenshot = takeScreenshot;
- this._calibration = null;
+ this._calibratedArea = null;
+ this._calibrationScreenshotSize = null;
+
+ if (process.env.TESTPLANE_DEBUG_SCREENSHOTS) {
+ this._debugTmpDir = path.join(
+ os.tmpdir(),
+ `testplane-camera-viewports-${Math.random().toString(36).slice(2)}`,
+ );
+ console.log("Debug camera images will be saved to: ", this._debugTmpDir);
+ }
}
- calibrate(calibration: Calibration): void {
- this._calibration = calibration;
+ calibrate(calibratedArea: Rect<"image", "device">, screenshotSize: Size<"device">): void {
+ debug("Setting calibrated area: %O for screenshot size: %O", calibratedArea, screenshotSize ?? null);
+ this._calibratedArea = calibratedArea;
+ this._calibrationScreenshotSize = screenshotSize;
}
- async captureViewportImage(page?: PageMeta): Promise {
+ async captureViewportImage(opts?: CaptureViewportImageOpts): Promise {
+ if (opts?.screenshotDelay) {
+ await new Promise(resolve => setTimeout(resolve, opts.screenshotDelay));
+ }
+
const base64 = await this._takeScreenshot();
const image = Image.fromBase64(base64);
- const { width, height } = image.getSize();
- const imageArea: ImageArea = { left: 0, top: 0, width, height };
+ const { width, height } = image.getSize() as Size<"device">;
+ const imageArea: Rect<"image", "device"> = {
+ left: 0 as Coord<"image", "device", "x">,
+ top: 0 as Coord<"image", "device", "y">,
+ width,
+ height,
+ };
+
+ const shouldApplyCalibration =
+ this._calibrationScreenshotSize !== null &&
+ this._calibrationScreenshotSize.width === width &&
+ this._calibrationScreenshotSize.height === height;
+ const calibrationArea = shouldApplyCalibration ? this._calibratedArea : null;
+
+ const calibratedImageArea = this._cropAreaToIntersection(imageArea, calibrationArea);
+ const cropMarginsArea = utils.cropMarginsToRect(imageArea, opts?.cropMargins);
+ const croppedImageArea = getIntersection(calibratedImageArea, cropMarginsArea);
+ if (croppedImageArea === null) {
+ throw new Error(
+ `Invalid cropMargins option: resulting screenshot crop area is empty. ` +
+ `imageSize: ${prettySize(imageArea)}, cropMargins: ${JSON.stringify(opts?.cropMargins)}`,
+ );
+ }
- const calibratedArea = this._calibrateArea(imageArea);
- const viewportCroppedArea = this._cropAreaToViewport(calibratedArea, page);
+ const viewportCroppedArea = this._cropAreaToViewport(
+ croppedImageArea,
+ { width, height },
+ croppedImageArea,
+ opts,
+ );
+ await utils.saveViewportImageForDebugIfNeeded(image, croppedImageArea, this._debugTmpDir);
if (viewportCroppedArea.width !== width || viewportCroppedArea.height !== height) {
await image.crop(viewportCroppedArea);
@@ -58,33 +111,75 @@ export class Camera {
return image;
}
- private _calibrateArea(imageArea: ImageArea): ImageArea {
- if (!this._calibration) {
+ private _cropAreaToIntersection(
+ imageArea: Rect<"image", "device">,
+ cropArea: Rect<"image", "device"> | null,
+ ): Rect<"image", "device"> {
+ if (!cropArea) {
return imageArea;
}
- const { left, top } = this._calibration;
+ const intersection = getIntersection(imageArea, cropArea);
+ if (intersection === null) {
+ logger.warn(
+ `No intersection found between image area and crop area, falling back to original image area.\n` +
+ `imageArea: ${prettyRect(imageArea)}, cropArea: ${prettyRect(cropArea)}\n` +
+ `This likely means Testplane incorrectly determined area free of system UI elements. You can let us know at ${NEW_ISSUE_LINK}, providing this log and browser used.`,
+ );
- return { left, top, width: imageArea.width - left, height: imageArea.height - top };
+ return imageArea;
+ }
+
+ return intersection;
}
- private _cropAreaToViewport(imageArea: ImageArea, page?: PageMeta): ImageArea {
- if (!page) {
- return imageArea;
+ /* On some browsers, e.g. older firefox versions, the screenshot returned by the browser can be the whole page
+ (even beyond the viewport, potentially spanning thousands of pixels down).
+ This function is used to detect such cases and crop the image to the viewport, always. */
+ private _cropAreaToViewport(
+ imageAreaToCrop: Rect<"image", "device">,
+ originalImageSize: Size<"device">,
+ calibrationArea: Rect<"image", "device"> | null,
+ opts?: CaptureViewportImageOpts,
+ ): Rect<"image", "device"> {
+ if (!opts?.viewportSize || !opts?.viewportOffset) {
+ return imageAreaToCrop;
}
- const isFullPage = utils.isFullPage(imageArea, page, this._screenshotMode);
- const cropArea = _.clone(page.viewport);
+ const isFullPage = utils.isFullPage(
+ imageAreaToCrop,
+ originalImageSize,
+ calibrationArea ?? imageAreaToCrop,
+ this._screenshotMode,
+ );
+ const cropArea = { ...opts.viewportSize, ...opts.viewportOffset };
if (!isFullPage) {
- _.extend(cropArea, { top: 0, left: 0 });
+ return imageAreaToCrop;
+ }
+ debug(
+ "cropping area to viewport.\n imageArea: %O\n viewportSize: %O\n viewportOffset: %O\n cropArea: %O\n isFullPage: %s\n screenshotMode: %s\n documentSize: %O",
+ imageAreaToCrop,
+ opts.viewportSize,
+ opts.viewportOffset,
+ cropArea,
+ isFullPage,
+ this._screenshotMode,
+ );
+
+ const result = getIntersection(imageAreaToCrop, cropArea);
+ if (result === null) {
+ logger.warn(
+ `No intersection found between image area and viewport area, falling back to original image area.\n` +
+ `imageArea: ${prettyRect(imageAreaToCrop)},\n` +
+ `viewportSize: ${prettySize(opts.viewportSize)},\n` +
+ `viewportOffset: ${prettyPoint(opts.viewportOffset)}\n` +
+ `This likely means Testplane incorrectly determined whether returned image is full page and viewport state. You can let us know at ${NEW_ISSUE_LINK}, providing this log and browser used.`,
+ );
+
+ return imageAreaToCrop;
}
- return {
- left: imageArea.left + cropArea.left,
- top: imageArea.top + cropArea.top,
- width: Math.min(imageArea.width - cropArea.left, cropArea.width),
- height: Math.min(imageArea.height - cropArea.top, cropArea.height),
- };
+ return result;
}
}
diff --git a/src/browser/camera/utils.ts b/src/browser/camera/utils.ts
index 36fa31303..76ccfe6ea 100644
--- a/src/browser/camera/utils.ts
+++ b/src/browser/camera/utils.ts
@@ -1,16 +1,99 @@
-import { ImageArea, PageMeta, ScreenshotMode } from ".";
+import path from "path";
+import fs from "fs";
+import type { ScreenshotMode } from ".";
+import { Image } from "../../image";
+import { Rect, Size, getBottom } from "../isomorphic/geometry";
+import { saveViewportImageWithDebugRects } from "../screen-shooter/composite-image/debug-utils";
+
+export interface CropMargins {
+ top?: number;
+ right?: number;
+ bottom?: number;
+ left?: number;
+}
+
+type NormalizedCropMargins = Required;
+
+export const isFullPage = (
+ imageSize: Rect<"image", "device">,
+ viewportSize: Size<"device">,
+ calibratedArea: Rect<"image", "device">,
+ screenshotMode: ScreenshotMode,
+): boolean => {
+ // "system ui" is something like status bar on safari mobile, or address bar at the bottom
+ const systemUiHeight = calibratedArea.top + (imageSize.height - getBottom(calibratedArea));
-export const isFullPage = (imageArea: ImageArea, page: PageMeta, screenshotMode: ScreenshotMode): boolean => {
switch (screenshotMode) {
case "fullpage":
return true;
case "viewport":
return false;
case "auto":
- return compareDimensions(imageArea, page);
+ return imageSize.height > viewportSize.height + systemUiHeight;
}
};
-function compareDimensions(imageArea: ImageArea, page: PageMeta): boolean {
- return imageArea.height >= page.documentHeight && imageArea.width >= page.documentWidth;
+export const normalizeCropMargins = (cropMargins?: CropMargins): NormalizedCropMargins => {
+ const result = {
+ top: cropMargins?.top ?? 0,
+ right: cropMargins?.right ?? 0,
+ bottom: cropMargins?.bottom ?? 0,
+ left: cropMargins?.left ?? 0,
+ };
+
+ for (const side of ["top", "right", "bottom", "left"] as const) {
+ const value = result[side];
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || Math.floor(value) !== value) {
+ throw new Error(
+ `Invalid cropMargins.${side} option: expected a non-negative integer, got ${String(value)}`,
+ );
+ }
+ }
+
+ return result;
+};
+
+export const cropMarginsToRect = (
+ imageArea: Rect<"image", "device">,
+ cropMargins?: CropMargins,
+): Rect<"image", "device"> => {
+ const margins = normalizeCropMargins(cropMargins);
+
+ return {
+ top: margins.top,
+ left: margins.left,
+ width: imageArea.width - margins.left - margins.right,
+ height: imageArea.height - margins.top - margins.bottom,
+ } as Rect<"image", "device">;
+};
+
+export async function saveViewportImageForDebugIfNeeded(
+ viewportImage: Image,
+ viewportCroppedArea: Rect<"image", "device">,
+ debugDir: string | null,
+): Promise {
+ if (!process.env.TESTPLANE_DEBUG_SCREENSHOTS || !debugDir) {
+ return;
+ }
+
+ try {
+ fs.mkdirSync(debugDir, { recursive: true });
+
+ const timestamp = String(Date.now()).padStart(13, "0");
+ const randomId = Math.random().toString(36).substring(2, 8);
+ const outputPath = path.join(debugDir, `viewport-${timestamp}-${randomId}.png`);
+
+ await saveViewportImageWithDebugRects(
+ viewportImage,
+ [
+ {
+ rect: viewportCroppedArea as unknown as Rect<"viewport", "device">,
+ color: { r: 0, g: 255, b: 0, a: 255 },
+ },
+ ],
+ outputPath,
+ );
+ } catch (error) {
+ console.warn("Failed to save camera viewport debug image: %O", error);
+ }
}
diff --git a/src/browser/client-bridge/client-bridge.ts b/src/browser/client-bridge/client-bridge.ts
deleted file mode 100644
index 5b9febd26..000000000
--- a/src/browser/client-bridge/client-bridge.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { ClientBridgeError } from "./error";
-import { ExistingBrowser } from "../existing-browser";
-
-export class ClientBridge {
- private _browser: ExistingBrowser;
- private _script: string;
-
- static create(browser: ExistingBrowser, script: string): ClientBridge {
- return new ClientBridge(browser, script);
- }
-
- constructor(browser: ExistingBrowser, script: string) {
- this._browser = browser;
- this._script = script;
- }
-
- async call(name: string, args: unknown[] = []): Promise {
- return this._callCommand(this._clientMethodCommand(name, args), true);
- }
-
- private async _callCommand(command: string, injectAllowed: boolean): Promise {
- try {
- const result = await this._browser.evalScript<{ isClientScriptNotInjected?: boolean }>(command);
-
- if (!result || !result.isClientScriptNotInjected) {
- return result as T;
- }
-
- if (injectAllowed) {
- await this._inject();
- return this._callCommand(command, false);
- }
-
- throw new ClientBridgeError("Unable to inject client script");
- } catch (e) {
- throw new ClientBridgeError((e as Error).message);
- }
- }
-
- private _clientMethodCommand(name: string, args: unknown[]): string {
- const params = args.map(arg => JSON.stringify(arg)).join(", ");
- const call = `__geminiCore.${name}(${params})`;
- return this._guardClientCall(call);
- }
-
- private _guardClientCall(call: string): string {
- return `typeof __geminiCore !== "undefined" ? ${call} : {isClientScriptNotInjected: true}`;
- }
-
- private async _inject(): Promise {
- await this._browser.injectScript(this._script);
- }
-}
diff --git a/src/browser/client-bridge/error.ts b/src/browser/client-bridge/error.ts
index 170e46585..dc7138aa3 100644
--- a/src/browser/client-bridge/error.ts
+++ b/src/browser/client-bridge/error.ts
@@ -2,8 +2,8 @@
* @category Errors
*/
export class ClientBridgeError extends Error {
- constructor(message: string) {
- super(message);
+ constructor(message: string, options?: ErrorOptions) {
+ super(message, options);
this.name = this.constructor.name;
}
}
diff --git a/src/browser/client-bridge/index.ts b/src/browser/client-bridge/index.ts
index d89d29ae8..135d3754c 100644
--- a/src/browser/client-bridge/index.ts
+++ b/src/browser/client-bridge/index.ts
@@ -1,26 +1,98 @@
+import { inspect } from "node:util";
import path from "path";
import fs from "fs";
-import { ClientBridge } from "./client-bridge";
-import { ExistingBrowser } from "../existing-browser";
+import makeDebug from "debug";
+import { ClientBridgeError } from "./error";
+
+const debug = makeDebug("testplane:client-bridge");
const bundlesCache: Record = {};
-export { ClientBridge };
+interface Browser {
+ execute: (command: string, ...args: unknown[]) => Promise;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export class ClientBridge any>> {
+ private _browser: Browser;
+ private _script: string;
+ private _namespace: string;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ static async create any>>(
+ browser: Browser,
+ namespace: string,
+ opts: { needsCompatLib?: boolean },
+ ): Promise> {
+ const needsCompatLib = opts?.needsCompatLib ?? false;
+ const scriptFileName = needsCompatLib ? "bundle.compat.js" : "bundle.native.js";
+ const scriptFilePath = path.join(__dirname, "..", "client-scripts", namespace, "build", scriptFileName);
+
+ if (bundlesCache[scriptFilePath]) {
+ return new ClientBridge(browser, bundlesCache[scriptFilePath], namespace);
+ }
+
+ const bundle = await fs.promises.readFile(scriptFilePath, { encoding: "utf8" });
+ bundlesCache[scriptFilePath] = bundle;
-export const build = async (
- browser: ExistingBrowser,
- opts: { calibration?: { needsCompatLib?: boolean } } = {},
-): Promise => {
- const needsCompatLib = opts.calibration?.needsCompatLib ?? false;
- const scriptFileName = needsCompatLib ? "bundle.compat.js" : "bundle.native.js";
+ return new this(browser, bundle, namespace);
+ }
+
+ constructor(browser: Browser, script: string, namespace: string) {
+ this._browser = browser;
+ this._script = script;
+ this._namespace = namespace;
+ }
- if (bundlesCache[scriptFileName]) {
- return ClientBridge.create(browser, bundlesCache[scriptFileName]);
+ async call(name: K, args: Parameters): Promise> {
+ return this._callCommand(this._clientMethodCommand(name, args), true) as ReturnType;
}
- const scriptFilePath = path.join(__dirname, "..", "client-scripts", scriptFileName);
- const bundle = await fs.promises.readFile(scriptFilePath, { encoding: "utf8" });
- bundlesCache[scriptFileName] = bundle;
+ private async _callCommand(command: string, shouldInjectScriptBeforeCall: boolean): Promise {
+ try {
+ if (debug.enabled) {
+ debug(` > calling command ${command}`);
+ }
+
+ const result = await this._browser.execute<{ isClientScriptNotInjected?: boolean }>(command);
+
+ if (debug.enabled) {
+ debug(` < result for command ${command.slice(0, 256)}: ${inspect(result, { depth: null })}`);
+ }
- return ClientBridge.create(browser, bundle);
-};
+ if (!result || !result.isClientScriptNotInjected) {
+ return result as T;
+ }
+
+ if (shouldInjectScriptBeforeCall) {
+ await this._inject();
+ return this._callCommand(command, false);
+ }
+
+ throw new ClientBridgeError("Unable to inject client script");
+ } catch (e) {
+ throw new ClientBridgeError(
+ `Failed to call command ${command} due to error: ${(e as Error)?.message ?? "Unknown error"}`,
+ { cause: e },
+ );
+ }
+ }
+
+ private _clientMethodCommand(name: K, args: Parameters): string {
+ const params = args.map(arg => (arg !== undefined ? JSON.stringify(arg) : "undefined")).join(", ");
+ const call = `__geminiCore['${this._namespace}'].${String(name)}(${params})`;
+ return this._guardClientCall(call);
+ }
+
+ private _guardClientCall(call: string): string {
+ return `return (typeof __geminiCore !== "undefined" && typeof __geminiCore['${this._namespace}'] !== "undefined") ? ${call} : {isClientScriptNotInjected: true}`;
+ }
+
+ private async _inject(): Promise {
+ debug(` > injecting script into namespace ${this._namespace}`);
+ if (debug.enabled) {
+ console.log(this._script);
+ }
+ await this._browser.execute(this._script, this._namespace);
+ }
+}
diff --git a/src/browser/client-scripts/.eslintrc.js b/src/browser/client-scripts/.eslintrc.js
deleted file mode 100644
index adc5b8914..000000000
--- a/src/browser/client-scripts/.eslintrc.js
+++ /dev/null
@@ -1,3 +0,0 @@
-module.exports = {
- extends: "gemini-testing/browser"
-};
diff --git a/src/browser/client-scripts/browser-utils/implementation.ts b/src/browser/client-scripts/browser-utils/implementation.ts
new file mode 100644
index 000000000..7054e2856
--- /dev/null
+++ b/src/browser/client-scripts/browser-utils/implementation.ts
@@ -0,0 +1,13 @@
+import * as lib from "@lib";
+
+export function resetZoom(): void {
+ let meta = lib.queryFirst('meta[name="viewport"]') as HTMLMetaElement;
+ if (!meta) {
+ meta = document.createElement("meta");
+ meta.name = "viewport";
+
+ const head = lib.queryFirst("head");
+ head && head.appendChild(meta);
+ }
+ meta.content = "width=device-width,initial-scale=1.0,user-scalable=no";
+}
diff --git a/src/browser/client-scripts/browser-utils/inject.ts b/src/browser/client-scripts/browser-utils/inject.ts
new file mode 100644
index 000000000..bea736c29
--- /dev/null
+++ b/src/browser/client-scripts/browser-utils/inject.ts
@@ -0,0 +1,15 @@
+import * as implementation from "./implementation";
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __geminiCore: Record | undefined;
+ // eslint-disable-next-line no-var
+ var __geminiNamespace: string;
+}
+
+const globalObj = typeof window === "undefined" ? globalThis : window;
+
+if (!globalObj.__geminiCore) {
+ globalObj.__geminiCore = {};
+}
+globalObj.__geminiCore[__geminiNamespace] = implementation;
diff --git a/src/browser/client-scripts/browser-utils/tsconfig.compat.json b/src/browser/client-scripts/browser-utils/tsconfig.compat.json
new file mode 100644
index 000000000..23e862b4b
--- /dev/null
+++ b/src/browser/client-scripts/browser-utils/tsconfig.compat.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../tsconfig.compat.common.json",
+ "include": [".", "../../isomorphic", "../shared/"],
+ "exclude": ["./tsc-out", "../shared/lib.native.ts"],
+ "compilerOptions": {
+ "outDir": "./tsc-out"
+ }
+}
diff --git a/src/browser/client-scripts/browser-utils/tsconfig.json b/src/browser/client-scripts/browser-utils/tsconfig.json
new file mode 100644
index 000000000..efdd433a9
--- /dev/null
+++ b/src/browser/client-scripts/browser-utils/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../tsconfig.native.common.json",
+ "include": [".", "../../isomorphic", "../shared"],
+ "exclude": ["./tsc-out"],
+ "compilerOptions": {
+ "outDir": "./tsc-out"
+ }
+}
diff --git a/src/browser/client-scripts/build.js b/src/browser/client-scripts/build.js
index 65f3104b8..4962dd326 100644
--- a/src/browser/client-scripts/build.js
+++ b/src/browser/client-scripts/build.js
@@ -1,20 +1,36 @@
-/* global process, __dirname */
const path = require("path");
+const childProcess = require("node:child_process");
const browserify = require("browserify");
const uglifyify = require("uglifyify");
const aliasify = require("aliasify");
const fs = require("fs-extra");
+const compileTypescript = async (targetDir, tsConfigName = "tsconfig.json") => {
+ const tsConfigPath = path.join(targetDir, tsConfigName);
+
+ if (!(await fs.pathExists(tsConfigPath))) {
+ throw new Error(`Could not find tsconfig: ${tsConfigPath}`);
+ }
+
+ childProcess.spawnSync(process.execPath, [require.resolve("typescript/bin/tsc"), "--project", tsConfigPath], {
+ cwd: targetDir,
+ stdio: "inherit"
+ });
+};
+
/**
* @param {object} opts
* @param {boolean} opts.needsCompatLib
+ * @param {string} opts.entryFilePath
+ * @param {string} opts.libPath
* @returns {Promise}
*/
const bundleScript = async opts => {
- const lib = opts.needsCompatLib ? "./lib.compat.js" : "./lib.native.js";
+ const basedir = path.dirname(opts.entryFilePath);
+
const script = browserify({
- entries: "./index.js",
- basedir: __dirname
+ entries: [opts.entryFilePath],
+ basedir
});
script.transform(
@@ -31,7 +47,8 @@ const bundleScript = async opts => {
script.transform(
{
aliases: {
- "./lib": { relative: lib }
+ "@lib": opts.libPath,
+ "@isomorphic": opts.isomorphicPath
},
verbose: false
},
@@ -41,26 +58,46 @@ const bundleScript = async opts => {
return new Promise((resolve, reject) => {
script.bundle((err, buffer) => {
if (err) {
+ console.error(err);
reject(err);
}
- resolve(buffer);
+ const resultingScript = `(function (__geminiNamespace) { ${buffer.toString()} })(arguments[0])`;
+
+ resolve(resultingScript);
});
});
};
async function main() {
- const targetDir = path.join("build", path.relative(process.cwd(), __dirname));
+ const targetDir = path.resolve(process.argv[2]);
+
+ if (!(await fs.pathExists(targetDir))) {
+ throw new Error(`Target directory does not exist: ${targetDir}`);
+ }
+
+ const tscOutDir = path.join(targetDir, "tsc-out");
- await fs.ensureDir(targetDir);
+ const compatLibPath =
+ "./" + path.relative(process.cwd(), path.join(tscOutDir, "client-scripts", "shared", "lib.compat.js"));
+ const nativeLibPath =
+ "./" + path.relative(process.cwd(), path.join(tscOutDir, "client-scripts", "shared", "lib.native.js"));
await Promise.all(
[
- { needsCompatLib: true, fileName: "bundle.compat.js" },
- { needsCompatLib: false, fileName: "bundle.native.js" }
- ].map(async ({ needsCompatLib, fileName }) => {
- const buffer = await bundleScript({ needsCompatLib });
- const filePath = path.join(targetDir, fileName);
+ { needsCompatLib: true, fileName: "bundle.compat.js", libPath: compatLibPath },
+ { needsCompatLib: false, fileName: "bundle.native.js", libPath: nativeLibPath }
+ ].map(async ({ needsCompatLib, fileName, libPath }) => {
+ await compileTypescript(targetDir, needsCompatLib ? "tsconfig.compat.json" : "tsconfig.json");
+
+ const projectDirName = path.basename(targetDir);
+ const entryFilePath = path.join(tscOutDir, "client-scripts", projectDirName, "inject.js");
+ const isomorphicPath = path.join(tscOutDir, "isomorphic", "index.js");
+ const buffer = await bundleScript({ needsCompatLib, entryFilePath, libPath, isomorphicPath });
+
+ const buildDir = path.join(targetDir, "build");
+ await fs.ensureDir(buildDir);
+ const filePath = path.join(buildDir, fileName);
await fs.writeFile(filePath, buffer);
})
diff --git a/src/browser/client-scripts/calibrate.js b/src/browser/client-scripts/calibrate.js
index 5646953df..23ddef3c2 100644
--- a/src/browser/client-scripts/calibrate.js
+++ b/src/browser/client-scripts/calibrate.js
@@ -8,6 +8,7 @@
// which is in quirks mode.
// Needs to find a proper way to open calibration
// page in standards mode.
+ /* global navigator, document, window */
function needsResetBorder() {
return !/MSIE 8\.0/.test(navigator.userAgent);
}
@@ -28,7 +29,18 @@
bodyStyle.border = 0;
}
- bodyStyle.backgroundColor = "#96fa00";
+ // For example of how this looks, see https://github.com/gemini-testing/testplane/pull/1239
+ bodyStyle.backgroundColor = "#ff0000";
+
+ var fullPageElement = document.createElement("div");
+ fullPageElement.style.width = "100vw";
+ fullPageElement.style.height = "100vh";
+ fullPageElement.style.position = "fixed";
+ fullPageElement.style.top = "0";
+ fullPageElement.style.left = "0";
+ fullPageElement.style.zIndex = "999999";
+ fullPageElement.style.backgroundColor = "#96fa00";
+ document.body.appendChild(fullPageElement);
}
function hasCSS3Selectors() {
diff --git a/src/browser/client-scripts/ignore-areas.js b/src/browser/client-scripts/ignore-areas.js
deleted file mode 100644
index ac2db1755..000000000
--- a/src/browser/client-scripts/ignore-areas.js
+++ /dev/null
@@ -1,7 +0,0 @@
-"use strict";
-
-var lib = require("./lib");
-
-module.exports = function queryIgnoreAreas(selector) {
- return lib.queryAll(selector);
-};
diff --git a/src/browser/client-scripts/index.js b/src/browser/client-scripts/index.js
deleted file mode 100644
index 8b0ae80a6..000000000
--- a/src/browser/client-scripts/index.js
+++ /dev/null
@@ -1,432 +0,0 @@
-/*jshint browserify:true*/
-"use strict";
-
-var util = require("./util"),
- rect = require("./rect"),
- lib = require("./lib"),
- queryIgnoreAreas = require("./ignore-areas"),
- Rect = rect.Rect;
-
-if (typeof window === "undefined") {
- global.__geminiCore = exports;
-} else {
- window.__geminiCore = exports;
-}
-
-exports.queryFirst = lib.queryFirst;
-
-// Terminology
-// - clientRect - the result of calling getBoundingClientRect of the element
-// - extRect - clientRect + outline + box shadow
-// - elementCaptureRect - sum of extRects of the element and its pseudo-elements
-// - captureRect - sum of all elementCaptureRect for each captureSelectors
-
-exports.prepareScreenshot = function prepareScreenshot(areas, opts) {
- opts = opts || {};
- try {
- return prepareScreenshotUnsafe(areas, opts);
- } catch (e) {
- return {
- error: "JS",
- message: e.stack || e.message
- };
- }
-};
-
-exports.disableFrameAnimations = function disableFrameAnimations() {
- try {
- return disableFrameAnimationsUnsafe();
- } catch (e) {
- return {
- error: "JS",
- message: e.stack || e.message
- };
- }
-};
-
-exports.cleanupFrameAnimations = function cleanupFrameAnimations() {
- if (window.__cleanupAnimation) {
- window.__cleanupAnimation();
- }
-};
-
-function prepareScreenshotUnsafe(areas, opts) {
- var allowViewportOverflow = opts.allowViewportOverflow;
- var captureElementFromTop = opts.captureElementFromTop;
- var disableAnimation = opts.disableAnimation;
- var scrollElem = window;
-
- if (opts.selectorToScroll) {
- scrollElem = document.querySelector(opts.selectorToScroll);
-
- if (!scrollElem) {
- return {
- error: "NOTFOUND",
- message:
- 'Could not find element with css selector specified in "selectorToScroll" option: ' +
- opts.selectorToScroll,
- selector: opts.selectorToScroll
- };
- }
- }
-
- var mainDocumentElem = util.getMainDocumentElem(),
- viewportWidth = mainDocumentElem.clientWidth,
- viewportHeight = mainDocumentElem.clientHeight,
- documentWidth = mainDocumentElem.scrollWidth,
- documentHeight = mainDocumentElem.scrollHeight,
- viewPort = new Rect({
- left: util.getScrollLeft(scrollElem),
- top: util.getScrollTop(scrollElem),
- width: viewportWidth,
- height: viewportHeight
- }),
- pixelRatio = configurePixelRatio(opts.usePixelRatio),
- rect,
- selectors = [];
-
- areas.forEach(function (area) {
- if (Rect.isRect(area)) {
- rect = rect ? rect.merge(new Rect(area)) : new Rect(area);
- } else {
- selectors.push(area);
- }
- });
-
- rect = getCaptureRect(selectors, {
- initialRect: rect,
- allowViewportOverflow: allowViewportOverflow,
- scrollElem: scrollElem,
- viewportWidth: viewportWidth,
- documentHeight: documentHeight
- });
-
- if (rect.error) {
- return rect;
- }
-
- if (captureElementFromTop && !viewPort.rectInside(rect)) {
- util.isSafariMobile()
- ? scrollToCaptureAreaInSafari(viewPort, rect, scrollElem)
- : scrollElem.scrollTo(rect.left, rect.top);
- } else if (allowViewportOverflow && viewPort.rectIntersects(rect)) {
- rect.overflowsTopBound(viewPort) && rect.recalculateHeight(viewPort);
- rect.overflowsLeftBound(viewPort) && rect.recalculateWidth(viewPort);
- } else if (!captureElementFromTop && !allowViewportOverflow && !viewPort.rectIntersects(rect)) {
- return {
- error: "OUTSIDE_OF_VIEWPORT",
- message:
- "Can not capture element, because it is outside of viewport. " +
- 'Try to set "captureElementFromTop=true" to scroll to it before capture' +
- ' or to set "allowViewportOverflow=true" to ignore viewport overflow error.'
- };
- }
-
- if (disableAnimation) {
- disableFrameAnimationsUnsafe();
- }
-
- return {
- captureArea: rect.scale(pixelRatio).serialize(),
- ignoreAreas: findIgnoreAreas(opts.ignoreSelectors, {
- scrollElem: scrollElem,
- pixelRatio: pixelRatio,
- viewportWidth: viewportWidth,
- documentHeight: documentHeight
- }),
- viewport: new Rect({
- left: util.getScrollLeft(scrollElem),
- top: util.getScrollTop(scrollElem),
- width: viewportWidth,
- height: viewportHeight
- })
- .scale(pixelRatio)
- .serialize(),
- documentHeight: Math.ceil(documentHeight * pixelRatio),
- documentWidth: Math.ceil(documentWidth * pixelRatio),
- canHaveCaret: isEditable(document.activeElement),
- pixelRatio: pixelRatio
- };
-}
-
-function disableFrameAnimationsUnsafe() {
- var everyElementSelector = "*:not(#testplane-q.testplane-w.testplane-e.testplane-r.testplane-t.testplane-y)";
- var everythingSelector = ["", "::before", "::after"]
- .map(function (pseudo) {
- return everyElementSelector + pseudo;
- })
- .join(", ");
-
- var styleElements = [];
-
- function appendDisableAnimationStyleElement(root) {
- var styleElement = document.createElement("style");
- styleElement.innerHTML =
- everythingSelector +
- [
- "{",
- " animation-delay: 0ms !important;",
- " animation-duration: 0ms !important;",
- " animation-timing-function: step-start !important;",
- " transition-timing-function: step-start !important;",
- " scroll-behavior: auto !important;",
- " transition: none !important;",
- "}"
- ].join("\n");
-
- root.appendChild(styleElement);
- styleElements.push(styleElement);
- }
-
- function createDefaultTrustedTypesPolicy() {
- if (window.trustedTypes && window.trustedTypes.createPolicy) {
- window.trustedTypes.createPolicy("default", {
- createHTML: function (string) {
- return string;
- }
- });
- }
- }
-
- util.forEachRoot(function (root) {
- try {
- appendDisableAnimationStyleElement(root);
- } catch (err) {
- if (err && err.message && err.message.includes("This document requires 'TrustedHTML' assignment")) {
- createDefaultTrustedTypesPolicy();
-
- appendDisableAnimationStyleElement(root);
- } else {
- throw err;
- }
- }
- });
-
- window.__cleanupAnimation = function () {
- for (var i = 0; i < styleElements.length; i++) {
- // IE11 doesn't have remove() on node
- styleElements[i].parentNode.removeChild(styleElements[i]);
- }
-
- delete window.__cleanupAnimation;
- };
-}
-
-exports.resetZoom = function () {
- var meta = lib.queryFirst('meta[name="viewport"]');
- if (!meta) {
- meta = document.createElement("meta");
- meta.name = "viewport";
- var head = lib.queryFirst("head");
- head && head.appendChild(meta);
- }
- meta.content = "width=device-width,initial-scale=1.0,user-scalable=no";
-};
-
-function getCaptureRect(selectors, opts) {
- var element,
- elementRect,
- rect = opts.initialRect;
- for (var i = 0; i < selectors.length; i++) {
- element = lib.queryFirst(selectors[i]);
- if (!element) {
- return {
- error: "NOTFOUND",
- message: "Could not find element with css selector specified in setCaptureElements: " + selectors[i],
- selector: selectors[i]
- };
- }
-
- elementRect = getElementCaptureRect(element, opts);
- if (elementRect) {
- rect = rect ? rect.merge(elementRect) : elementRect;
- }
- }
-
- return rect
- ? rect.round()
- : {
- error: "HIDDEN",
- message: "Area with css selector : " + selectors + " is hidden",
- selector: selectors
- };
-}
-
-function configurePixelRatio(usePixelRatio) {
- if (usePixelRatio === false) {
- return 1;
- }
-
- if (window.devicePixelRatio) {
- return window.devicePixelRatio;
- }
-
- // for ie6-ie10 (https://developer.mozilla.org/ru/docs/Web/API/Window/devicePixelRatio)
- return window.screen.deviceXDPI / window.screen.logicalXDPI || 1;
-}
-
-function findIgnoreAreas(selectors, opts) {
- var result = [];
- util.each(selectors, function (selector) {
- var elements = queryIgnoreAreas(selector);
-
- util.each(elements, function (elem) {
- return addIgnoreArea.call(result, elem, opts);
- });
- });
-
- return result;
-}
-
-function addIgnoreArea(element, opts) {
- var rect = element && getElementCaptureRect(element, opts);
-
- if (!rect) {
- return;
- }
-
- var ignoreArea = rect.round().scale(opts.pixelRatio).serialize();
-
- this.push(ignoreArea);
-}
-
-function isHidden(css, clientRect) {
- return (
- css.display === "none" ||
- css.visibility === "hidden" ||
- css.opacity < 0.0001 ||
- clientRect.width < 0.0001 ||
- clientRect.height < 0.0001
- );
-}
-
-function getElementCaptureRect(element, opts) {
- var pseudo = [":before", ":after"],
- css = lib.getComputedStyle(element),
- clientRect = rect.getAbsoluteClientRect(element, opts);
-
- if (isHidden(css, clientRect)) {
- return null;
- }
-
- var elementRect = getExtRect(css, clientRect, opts.allowViewportOverflow);
-
- util.each(pseudo, function (pseudoEl) {
- css = lib.getComputedStyle(element, pseudoEl);
- elementRect = elementRect.merge(getExtRect(css, clientRect, opts.allowViewportOverflow));
- });
-
- return elementRect;
-}
-
-function getExtRect(css, clientRect, allowViewportOverflow) {
- var shadows = parseBoxShadow(css.boxShadow),
- outlineWidth = parseInt(css.outlineWidth, 10),
- outlineStyle = css.outlineStyle;
-
- if (isNaN(outlineWidth) || outlineStyle === "none") {
- outlineWidth = 0;
- }
-
- return adjustRect(clientRect, shadows, outlineWidth, allowViewportOverflow);
-}
-
-function parseBoxShadow(value) {
- value = value || "";
- var regex = /[-+]?\d*\.?\d+px/g,
- values = value.split(","),
- results = [],
- match;
-
- util.each(values, function (value) {
- if ((match = value.match(regex))) {
- results.push({
- offsetX: parseFloat(match[0]),
- offsetY: parseFloat(match[1]) || 0,
- blurRadius: parseFloat(match[2]) || 0,
- spreadRadius: parseFloat(match[3]) || 0,
- inset: value.indexOf("inset") !== -1
- });
- }
- });
- return results;
-}
-
-function adjustRect(rect, shadows, outline, allowViewportOverflow) {
- var shadowRect = calculateShadowRect(rect, shadows, allowViewportOverflow),
- outlineRect = calculateOutlineRect(rect, outline, allowViewportOverflow);
- return shadowRect.merge(outlineRect);
-}
-
-function calculateOutlineRect(rect, outline, allowViewportOverflow) {
- var top = rect.top - outline,
- left = rect.left - outline;
-
- return new Rect({
- top: allowViewportOverflow ? top : Math.max(0, top),
- left: allowViewportOverflow ? left : Math.max(0, left),
- bottom: rect.bottom + outline,
- right: rect.right + outline
- });
-}
-
-function calculateShadowRect(rect, shadows, allowViewportOverflow) {
- var extent = calculateShadowExtent(shadows),
- left = rect.left + extent.left,
- top = rect.top + extent.top;
-
- return new Rect({
- left: allowViewportOverflow ? left : Math.max(0, left),
- top: allowViewportOverflow ? top : Math.max(0, top),
- width: rect.width - extent.left + extent.right,
- height: rect.height - extent.top + extent.bottom
- });
-}
-
-function calculateShadowExtent(shadows) {
- var result = { top: 0, left: 0, right: 0, bottom: 0 };
-
- util.each(shadows, function (shadow) {
- if (shadow.inset) {
- //skip inset shadows
- return;
- }
-
- var blurAndSpread = shadow.spreadRadius + shadow.blurRadius;
- result.left = Math.min(shadow.offsetX - blurAndSpread, result.left);
- result.right = Math.max(shadow.offsetX + blurAndSpread, result.right);
- result.top = Math.min(shadow.offsetY - blurAndSpread, result.top);
- result.bottom = Math.max(shadow.offsetY + blurAndSpread, result.bottom);
- });
- return result;
-}
-
-function isEditable(element) {
- if (!element) {
- return false;
- }
- return /^(input|textarea)$/i.test(element.tagName) || element.isContentEditable;
-}
-
-function scrollToCaptureAreaInSafari(viewportCurr, captureArea, scrollElem) {
- var mainDocumentElem = util.getMainDocumentElem();
- var documentHeight = Math.round(mainDocumentElem.scrollHeight);
- var viewportHeight = Math.round(mainDocumentElem.clientHeight);
- var maxScrollByY = documentHeight - viewportHeight;
-
- scrollElem.scrollTo(viewportCurr.left, Math.min(captureArea.top, maxScrollByY));
-
- // TODO: uncomment after fix bug in safari - https://bugs.webkit.org/show_bug.cgi?id=179735
- /*
- var viewportAfterScroll = new Rect({
- left: util.getScrollLeft(scrollElem),
- top: util.getScrollTop(scrollElem),
- width: viewportCurr.width,
- height: viewportCurr.height
- });
-
- if (!viewportAfterScroll.rectInside(captureArea)) {
- scrollElem.scrollTo(captureArea.left, captureArea.top);
- }
- */
-}
diff --git a/src/browser/client-scripts/lib.native.js b/src/browser/client-scripts/lib.native.js
deleted file mode 100644
index c070d8c07..000000000
--- a/src/browser/client-scripts/lib.native.js
+++ /dev/null
@@ -1,28 +0,0 @@
-"use strict";
-var xpath = require("./xpath");
-
-exports.queryFirst = function (selector) {
- if (xpath.isXpathSelector(selector)) {
- return xpath.queryFirst(selector);
- }
- return document.querySelector(selector);
-};
-
-exports.queryAll = function (selector) {
- if (xpath.isXpathSelector(selector)) {
- return xpath.queryAll(selector);
- }
- return document.querySelectorAll(selector);
-};
-
-exports.getComputedStyle = function (element, pseudoElement) {
- return getComputedStyle(element, pseudoElement);
-};
-
-exports.matchMedia = function (mediaQuery) {
- return matchMedia(mediaQuery);
-};
-
-exports.trim = function (str) {
- return str.trim();
-};
diff --git a/src/browser/client-scripts/rect.js b/src/browser/client-scripts/rect.js
deleted file mode 100644
index a9679de34..000000000
--- a/src/browser/client-scripts/rect.js
+++ /dev/null
@@ -1,239 +0,0 @@
-"use strict";
-
-var util = require("./util");
-
-function Rect(data) {
- this.top = data.top;
- this.left = data.left;
-
- if ("width" in data && "height" in data) {
- this.width = data.width;
- this.height = data.height;
- this.bottom = data.bottom || this.top + this.height;
- this.right = data.right || this.left + this.width;
- } else if ("bottom" in data && "right" in data) {
- this.bottom = data.bottom;
- this.right = data.right;
- this.width = data.right - Math.max(0, data.left);
- this.height = data.bottom - Math.max(0, data.top);
- } else {
- throw new Error("Not enough data for the rect construction");
- }
-}
-
-Rect.isRect = function (data) {
- if (typeof data !== "object" || data === null || Array.isArray(data)) {
- return false;
- }
-
- return (
- "left" in data &&
- "top" in data &&
- (("width" in data && "height" in data) || ("right" in data && "bottom" in data))
- );
-};
-
-Rect.prototype = {
- constructor: Rect,
- merge: function (otherRect) {
- return new Rect({
- left: Math.min(this.left, otherRect.left),
- top: Math.min(this.top, otherRect.top),
- bottom: Math.max(this.bottom, otherRect.bottom),
- right: Math.max(this.right, otherRect.right)
- });
- },
-
- translate: function (x, y) {
- return new Rect({
- left: this.left + x,
- top: this.top + y,
- width: this.width,
- height: this.height
- });
- },
-
- pointInside: function (x, y) {
- return x >= this.left && x <= this.right && y >= this.top && y <= this.bottom;
- },
-
- rectInside: function (rect) {
- return util.every(
- rect._keyPoints(),
- function (point) {
- return this.pointInside(point[0], point[1]);
- },
- this
- );
- },
-
- rectIntersects: function (other) {
- var isOtherOutside =
- other.right <= this.left ||
- other.bottom <= this.top ||
- other.left >= this.right ||
- other.top >= this.bottom;
-
- return !isOtherOutside;
- },
-
- round: function () {
- return new Rect({
- top: Math.floor(this.top),
- left: Math.floor(this.left),
- right: Math.ceil(this.right),
- bottom: Math.ceil(this.bottom)
- });
- },
-
- scale: function (scaleFactor) {
- var rect = new Rect({
- top: this.top * scaleFactor,
- left: this.left * scaleFactor,
- right: this.right * scaleFactor,
- bottom: this.bottom * scaleFactor
- });
-
- return util.isInteger(scaleFactor) ? rect : rect.round();
- },
-
- serialize: function () {
- return {
- left: this.left,
- top: this.top,
- width: this.width,
- height: this.height
- };
- },
-
- overflowsTopBound: function (rect) {
- return this._overflowsBound(rect, "top");
- },
-
- overflowsLeftBound: function (rect) {
- return this._overflowsBound(rect, "left");
- },
-
- /** @type Function */
- recalculateHeight: function (rect) {
- this.height = this.height - (rect.top - Math.max(0, this.top));
- },
-
- /** @type Function */
- recalculateWidth: function (rect) {
- this.width = this.width - (rect.left - Math.max(0, this.left));
- },
-
- _overflowsBound: function (rect, prop) {
- return Math.max(0, this[prop]) < rect[prop];
- },
-
- _anyPointInside: function (points) {
- return util.some(
- points,
- function (point) {
- return this.pointInside(point[0], point[1]);
- },
- this
- );
- },
-
- _keyPoints: function () {
- return [
- [this.left, this.top],
- [this.left, this.bottom],
- [this.right, this.top],
- [this.right, this.bottom]
- ];
- }
-};
-
-exports.Rect = Rect;
-exports.getAbsoluteClientRect = function getAbsoluteClientRect(element, opts) {
- var coords = getNestedBoundingClientRect(element, window);
- var widthRatio = coords.width % opts.viewportWidth;
- var heightRatio = coords.height % opts.documentHeight;
-
- var clientRect = new Rect({
- left: coords.left,
- top: coords.top,
- // to correctly calculate "width" and "height" in devices with fractional pixelRatio
- width: widthRatio > 0 && widthRatio < 1 ? opts.viewportWidth : coords.width,
- height: heightRatio > 0 && heightRatio < 1 ? opts.documentHeight : coords.height
- });
-
- return clientRect.translate(util.getScrollLeft(opts.scrollElem), util.getScrollTop(opts.scrollElem));
-};
-
-function getNestedBoundingClientRect(node, boundaryWindow) {
- var ownerIframe = util.getOwnerIframe(node);
- if (ownerIframe === null || util.getOwnerWindow(ownerIframe) === boundaryWindow) {
- return node.getBoundingClientRect();
- }
-
- var rects = [node.getBoundingClientRect()];
- var currentIframe = ownerIframe;
-
- while (currentIframe) {
- var rect = getBoundingClientRectWithBorderOffset(currentIframe);
- rects.push(rect);
-
- currentIframe = util.getOwnerIframe(currentIframe);
- if (currentIframe && util.getOwnerWindow(currentIframe) === boundaryWindow) {
- rect = getBoundingClientRectWithBorderOffset(currentIframe);
- rects.push(rect);
- break;
- }
- }
-
- return mergeRectOffsets(rects);
-}
-
-function getBoundingClientRectWithBorderOffset(node) {
- var dimensions = getElementDimensions(node);
-
- return mergeRectOffsets([
- node.getBoundingClientRect(),
- {
- top: dimensions.borderTop,
- left: dimensions.borderLeft,
- bottom: dimensions.borderBottom,
- right: dimensions.borderRight,
- x: dimensions.borderLeft,
- y: dimensions.borderTop
- }
- ]);
-}
-
-function getElementDimensions(element) {
- var calculatedStyle = util.getOwnerWindow(element).getComputedStyle(element);
-
- return {
- borderLeft: parseFloat(calculatedStyle.borderLeftWidth),
- borderRight: parseFloat(calculatedStyle.borderRightWidth),
- borderTop: parseFloat(calculatedStyle.borderTopWidth),
- borderBottom: parseFloat(calculatedStyle.borderBottomWidth)
- };
-}
-
-function mergeRectOffsets(rects) {
- return rects.reduce(function (previousRect, rect) {
- if (previousRect === null) {
- return rect;
- }
-
- var nextTop = previousRect.top + rect.top;
- var nextLeft = previousRect.left + rect.left;
-
- return {
- top: nextTop,
- left: nextLeft,
- width: previousRect.width,
- height: previousRect.height,
- bottom: nextTop + previousRect.height,
- right: nextLeft + previousRect.width,
- x: nextLeft,
- y: nextTop
- };
- });
-}
diff --git a/src/browser/client-scripts/screen-shooter/errors/outside-of-viewport.ts b/src/browser/client-scripts/screen-shooter/errors/outside-of-viewport.ts
new file mode 100644
index 000000000..d7c7a37d6
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/errors/outside-of-viewport.ts
@@ -0,0 +1,16 @@
+import { BrowserSideErrorCode } from "@isomorphic";
+
+export class OutsideOfViewportError extends Error {
+ errorCode: BrowserSideErrorCode;
+ debugLog?: string;
+
+ constructor(debugLog?: string) {
+ super(
+ "Can not capture element, because it is completely outside of viewport with no intersection. " +
+ 'Try to set "captureElementFromTop=true" assertView option to scroll to it before capture.'
+ );
+ this.name = "OutsideOfViewportError";
+ this.errorCode = BrowserSideErrorCode.OUTSIDE_OF_VIEWPORT;
+ this.debugLog = debugLog;
+ }
+}
diff --git a/src/browser/client-scripts/screen-shooter/implementation.ts b/src/browser/client-scripts/screen-shooter/implementation.ts
new file mode 100644
index 000000000..52f1351c5
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/implementation.ts
@@ -0,0 +1,545 @@
+import {
+ BrowserSideError,
+ BrowserSideErrorCode,
+ Coord,
+ DisableHoverMode,
+ Length,
+ ceilCoords,
+ floorCoords,
+ fromBcrToRect,
+ fromCssToDevice,
+ fromCssToDeviceNumber,
+ fromDeviceToCssNumber,
+ getBottom,
+ getCoveringRect,
+ getIntersection,
+ roundCoords
+} from "@isomorphic";
+import {
+ PrepareScreenshotOptions,
+ PrepareScreenshotResult,
+ PrepareScreenshotSuccess,
+ PrepareFullPageScreenshotResult,
+ PrepareViewportScreenshotResult,
+ ScrollFullPageResult,
+ ScrollResult,
+ GetCaptureStateResult,
+ TrackedElementData,
+ ElementPositionsProbe
+} from "./types";
+import { createDebugLogger } from "../shared/logger";
+import {
+ scrollToCaptureAreaIfNeeded,
+ disableAnimations,
+ computeCaptureSpecs,
+ computeIgnoreAreas,
+ computeViewportSize,
+ computeViewportOffset,
+ computeSafeArea,
+ computeDocumentSize,
+ computeCanHaveCaret,
+ computePixelRatio,
+ disablePointerEvents as disablePointerEventsUnsafe,
+ computeElementPositionsProbe,
+ saveScrollPositions,
+ prepareFullPageScrollCleanup,
+ cleanupSavedScrolls,
+ computeScrollOffset
+} from "./operations";
+import { getReadableElementDescriptor } from "./utils/descriptions";
+import { getScreenshooterNamespaceData } from "./utils/dom";
+import { getCommonScrollParent, scrollElementBy, scrollElementToOffset } from "./utils/scroll";
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __cleanupAnimation: undefined | (() => void);
+}
+
+const MIN_ANCHOR_SAMPLE_SIZE = 3;
+const MAX_ANCHOR_TRACKED_ELEMENTS = 500;
+/** Tolerance in CSS pixels for binning observed viewport shift deltas */
+const ANCHOR_SHIFT_TOLERANCE_CSS = 1.5;
+
+function sampleRandom(items: T[], maxCount: number): T[] {
+ if (items.length <= maxCount) return items.slice();
+ const arr = items.slice();
+ for (let i = 0; i < maxCount; i++) {
+ const j = i + Math.floor(Math.random() * (arr.length - i));
+ const tmp = arr[i];
+ arr[i] = arr[j];
+ arr[j] = tmp;
+ }
+ return arr.slice(0, maxCount);
+}
+
+/** Finds the densest tolerance-wide window and returns its median value. */
+function computeShiftMode(deltas: number[], tolerance: number): number | null {
+ if (deltas.length === 0) {
+ return null;
+ }
+
+ const sortedDeltas = deltas.slice().sort((a, b) => a - b);
+ let bestWindowStartIndex = 0;
+ let bestWindowEndIndex = 0;
+
+ for (let startIndex = 0, endIndex = 0; startIndex < sortedDeltas.length; startIndex++) {
+ while (
+ endIndex + 1 < sortedDeltas.length &&
+ sortedDeltas[endIndex + 1] - sortedDeltas[startIndex] <= tolerance
+ ) {
+ endIndex++;
+ }
+
+ const currentWindowSize = endIndex - startIndex + 1;
+ const bestWindowSize = bestWindowEndIndex - bestWindowStartIndex + 1;
+ const shouldPreferCurrentWindow = currentWindowSize > bestWindowSize;
+
+ if (shouldPreferCurrentWindow) {
+ bestWindowStartIndex = startIndex;
+ bestWindowEndIndex = endIndex;
+ }
+ }
+
+ const dominantValues = sortedDeltas.slice(bestWindowStartIndex, bestWindowEndIndex + 1);
+ const middleIndex = Math.floor(dominantValues.length / 2);
+
+ if (dominantValues.length % 2 === 1) {
+ return dominantValues[middleIndex];
+ }
+
+ return (dominantValues[middleIndex - 1] + dominantValues[middleIndex]) / 2;
+}
+
+/** This function is useful to understand what actually is going on when capture area unexpectedly changes size/top position.
+ * It returns the actual shift of the capture area compared to the baseline.
+ * This shift can then be compared to the shift of the whole capture area to compute correction delta. */
+function computeActualShift(): Length<"css", "y"> | null {
+ const { trackedElementsData } = getScreenshooterNamespaceData();
+ if (!trackedElementsData || trackedElementsData.length === 0) {
+ return null;
+ }
+
+ const verticalDeltas: number[] = [];
+ for (const trackedElementData of trackedElementsData) {
+ if (!trackedElementData.element.isConnected) {
+ continue;
+ }
+
+ const currentRect = trackedElementData.element.getBoundingClientRect();
+ const baselineRect = trackedElementData.rect;
+
+ if (currentRect.width <= 0 || currentRect.height <= 0) {
+ continue;
+ }
+
+ if (
+ Math.abs(currentRect.width - baselineRect.width) > 1 ||
+ Math.abs(currentRect.height - baselineRect.height) > 1
+ ) {
+ continue;
+ }
+
+ verticalDeltas.push(currentRect.top - baselineRect.top);
+ }
+ if (verticalDeltas.length < MIN_ANCHOR_SAMPLE_SIZE) {
+ return null;
+ }
+
+ const shiftCss = computeShiftMode(verticalDeltas, ANCHOR_SHIFT_TOLERANCE_CSS);
+
+ return shiftCss === null ? null : (shiftCss as Length<"css", "y">);
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function safeCall any>(
+ callback: T,
+ ...args: Parameters
+): ReturnType | BrowserSideError {
+ try {
+ return callback(...args) as ReturnType;
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ return {
+ errorCode: BrowserSideErrorCode.JS,
+ message: e.stack || e.message
+ };
+ }
+ return {
+ errorCode: BrowserSideErrorCode.JS,
+ message: "Unknown error: " + String(e)
+ };
+ }
+}
+
+export function prepareElementsScreenshot(
+ selectorsToCapture: string[],
+ opts: PrepareScreenshotOptions
+): PrepareScreenshotResult {
+ return safeCall(prepareElementsScreenshotUnsafe, selectorsToCapture, opts);
+}
+
+export function scrollBy(
+ selectorsToCapture: string[],
+ scrollDelta: Length<"device", "y"> | Coord<"page", "device", "y">,
+ selectorToScroll?: string | null,
+ debug?: string[]
+): ScrollResult {
+ return safeCall((): ScrollResult => {
+ const logger = createDebugLogger({ debug }, "scrollBy");
+ const pixelRatio = computePixelRatio();
+ const scrollTarget = selectorToScroll ? document.querySelector(selectorToScroll) : null;
+ const scrollElement = scrollTarget ?? getCommonScrollParent(selectorsToCapture);
+
+ const readableAutoScrollElementDescr = getReadableElementDescriptor(scrollElement);
+ const readableSelectorToScrollDescr = selectorToScroll
+ ? scrollTarget
+ ? `${selectorToScroll} (${readableAutoScrollElementDescr})`
+ : `${selectorToScroll} (not found, auto-detected ${readableAutoScrollElementDescr})`
+ : `auto-detected ${readableAutoScrollElementDescr}`;
+
+ const scrollHeightCss = fromDeviceToCssNumber(scrollDelta as Coord<"page", "device", "y">, pixelRatio);
+ scrollElementBy(scrollElement, scrollHeightCss);
+
+ return {
+ readableSelectorToScrollDescr,
+ debugLog: logger()
+ };
+ });
+}
+
+export function scrollTo(
+ selectorsToCapture: string[],
+ scrollOffset: Length<"device", "y"> | Coord<"page", "device", "y">,
+ selectorToScroll?: string | null,
+ debug?: string[]
+): ScrollResult {
+ return safeCall((): ScrollResult => {
+ const logger = createDebugLogger({ debug }, "scrollTo");
+ logger(
+ "Asked to scroll to with params: selectorsToCapture:",
+ selectorsToCapture,
+ "scrollOffset:",
+ scrollOffset,
+ "selectorToScroll:",
+ selectorToScroll
+ );
+ const pixelRatio = computePixelRatio();
+ const scrollTarget = selectorToScroll ? document.querySelector(selectorToScroll) : null;
+ const scrollElement = scrollTarget ?? getCommonScrollParent(selectorsToCapture);
+
+ const readableAutoScrollElementDescr = getReadableElementDescriptor(scrollElement);
+ const readableSelectorToScrollDescr = selectorToScroll
+ ? scrollTarget
+ ? `${selectorToScroll} (${readableAutoScrollElementDescr})`
+ : `${selectorToScroll} (not found, auto-detected ${readableAutoScrollElementDescr})`
+ : `auto-detected ${readableAutoScrollElementDescr}`;
+
+ const scrollOffsetCss = fromDeviceToCssNumber(
+ scrollOffset as Coord<"page", "device", "y">,
+ pixelRatio
+ ) as Coord<"page", "css", "y">;
+ scrollElementToOffset(scrollElement, scrollOffsetCss);
+
+ return {
+ readableSelectorToScrollDescr,
+ debugLog: logger()
+ };
+ });
+}
+
+/** Returns current state: positions of elements to capture, ignore areas, safe area, scroll offset */
+export function getCaptureState(
+ selectorsToCapture: string[],
+ selectorsToIgnore: string[],
+ selectorToScroll?: string | null,
+ debug?: string[]
+): GetCaptureStateResult {
+ return safeCall((): GetCaptureStateResult => {
+ const logger = createDebugLogger({ debug }, "getCaptureState");
+ const pixelRatio = computePixelRatio();
+ const scrollTarget = selectorToScroll ? document.querySelector(selectorToScroll) : null;
+ const scrollElement = scrollTarget ?? getCommonScrollParent(selectorsToCapture);
+ const readableAutoScrollElementDescr = getReadableElementDescriptor(scrollElement);
+ const readableSelectorToScrollDescr = selectorToScroll
+ ? scrollTarget
+ ? `${selectorToScroll} (${readableAutoScrollElementDescr})`
+ : `${selectorToScroll} (not found, auto-detected ${readableAutoScrollElementDescr})`
+ : `auto-detected ${readableAutoScrollElementDescr}`;
+ const ignoreAreas = computeIgnoreAreas(selectorsToIgnore);
+ const safeArea = computeSafeArea(selectorsToCapture, scrollElement, logger);
+ const captureSpecsAfterCss = computeCaptureSpecs(selectorsToCapture, logger);
+ const captureSpecs = captureSpecsAfterCss.map(spec => ({
+ full: fromCssToDevice(roundCoords(spec.full), pixelRatio),
+ clip: fromCssToDevice(roundCoords(spec.clip), pixelRatio),
+ visible: fromCssToDevice(roundCoords(spec.visible), pixelRatio)
+ }));
+ const scrollOffset = computeScrollOffset(scrollElement);
+ const viewportOffset = computeViewportOffset();
+
+ const anchorShift = computeActualShift();
+ const anchorShiftDevice = anchorShift === null ? null : fromCssToDeviceNumber(anchorShift, pixelRatio);
+
+ logger("scrollOffset:", scrollOffset);
+
+ return {
+ captureSpecs,
+ ignoreAreas: ignoreAreas.map(area => fromCssToDevice(roundCoords(area), pixelRatio)),
+ safeArea: fromCssToDevice(roundCoords(safeArea), pixelRatio),
+ scrollOffset: fromCssToDeviceNumber(scrollOffset, pixelRatio),
+ viewportOffset: fromCssToDevice(floorCoords(viewportOffset), pixelRatio),
+ anchorShift: anchorShiftDevice,
+ readableSelectorToScrollDescr,
+ debugLog: logger()
+ };
+ });
+}
+
+export function prepareFullPageScreenshot(
+ opts: { usePixelRatio?: boolean; disableAnimation?: boolean; disableHover?: DisableHoverMode } = {}
+): PrepareFullPageScreenshotResult {
+ return safeCall((): PrepareFullPageScreenshotResult => {
+ prepareFullPageScrollCleanup();
+
+ const pixelRatio = computePixelRatio(opts.usePixelRatio);
+
+ window.scrollTo(0, 0);
+
+ const documentSize = computeDocumentSize();
+ const viewportSize = computeViewportSize();
+ const viewportOffset = computeViewportOffset();
+ const safeArea = computeSafeArea(["body"], document.documentElement);
+
+ if (opts.disableAnimation) {
+ disableAnimations();
+ }
+
+ let pointerEventsDisabled = false;
+ if (opts.disableHover === DisableHoverMode.Always) {
+ disablePointerEventsUnsafe();
+ pointerEventsDisabled = true;
+ } else if (opts.disableHover === DisableHoverMode.WhenScrollingNeeded) {
+ const needsScrolling = documentSize.height > viewportSize.height;
+
+ if (needsScrolling) {
+ disablePointerEventsUnsafe();
+ pointerEventsDisabled = true;
+ }
+ }
+
+ const elementPositionsProbe: ElementPositionsProbe<"device">[] = computeElementPositionsProbe().map(
+ (rect: ElementPositionsProbe<"css">): ElementPositionsProbe<"device"> =>
+ rect ? { ...fromCssToDevice(roundCoords(rect), pixelRatio), elementDescr: rect.elementDescr } : null
+ );
+
+ return {
+ documentSize: ceilCoords(fromCssToDevice(documentSize, pixelRatio)),
+ viewportSize: fromCssToDevice(viewportSize, pixelRatio),
+ viewportOffset: fromCssToDevice(floorCoords(viewportOffset), pixelRatio),
+ safeArea: fromCssToDevice(roundCoords(safeArea), pixelRatio),
+ elementPositionsProbe,
+ pixelRatio,
+ pointerEventsDisabled
+ };
+ });
+}
+
+export function scrollFullPage(
+ scrollHeight: Length<"device", "y"> | Coord<"page", "device", "y">,
+ opts: { usePixelRatio?: boolean } = {}
+): ScrollFullPageResult {
+ return safeCall((): ScrollFullPageResult => {
+ const pixelRatio = computePixelRatio(opts.usePixelRatio);
+ const scrollHeightCss = (fromDeviceToCssNumber(scrollHeight as Coord<"page", "device", "y">, pixelRatio) -
+ 1) as Coord<"page", "css", "y">;
+
+ scrollElementBy(document.documentElement, scrollHeightCss);
+
+ const viewportOffset = computeViewportOffset();
+ const elementPositionsProbe: ElementPositionsProbe<"device">[] = computeElementPositionsProbe().map(
+ (rect: ElementPositionsProbe<"css">): ElementPositionsProbe<"device"> =>
+ rect ? { ...fromCssToDevice(roundCoords(rect), pixelRatio), elementDescr: rect.elementDescr } : null
+ );
+
+ return {
+ viewportOffset: fromCssToDevice(floorCoords(viewportOffset), pixelRatio),
+ elementPositionsProbe
+ };
+ });
+}
+
+export function prepareViewportScreenshot(
+ opts: { usePixelRatio?: boolean; disableAnimation?: boolean; disableHover?: DisableHoverMode } = {}
+): PrepareViewportScreenshotResult {
+ return safeCall((): PrepareViewportScreenshotResult => {
+ const pixelRatio = computePixelRatio(opts.usePixelRatio);
+ const viewportSize = computeViewportSize();
+ const viewportOffset = computeViewportOffset();
+ const documentSize = computeDocumentSize();
+ const canHaveCaret = computeCanHaveCaret();
+
+ if (opts.disableAnimation) {
+ disableAnimations();
+ }
+
+ let pointerEventsDisabled = false;
+ if (opts.disableHover === DisableHoverMode.Always) {
+ disablePointerEventsUnsafe();
+ pointerEventsDisabled = true;
+ }
+
+ return {
+ viewportSize: fromCssToDevice(viewportSize, pixelRatio),
+ viewportOffset: fromCssToDevice(floorCoords(viewportOffset), pixelRatio),
+ documentSize: ceilCoords(fromCssToDevice(documentSize, pixelRatio)),
+ canHaveCaret,
+ pixelRatio,
+ pointerEventsDisabled
+ };
+ });
+}
+
+export function disableFrameAnimations(): void | BrowserSideError {
+ return safeCall(disableAnimations);
+}
+
+export function cleanupFrameAnimations(): void {
+ if (window.__cleanupAnimation) {
+ window.__cleanupAnimation();
+ }
+}
+
+export function disablePointerEvents(): void | BrowserSideError {
+ return safeCall(disablePointerEventsUnsafe);
+}
+
+export function cleanupPointerEvents(): void {
+ const screenshooterNamespaceData = getScreenshooterNamespaceData();
+ if (screenshooterNamespaceData.cleanupPointerEventsCb) {
+ screenshooterNamespaceData.cleanupPointerEventsCb();
+ }
+}
+
+export function cleanupScrolls(): void {
+ getScreenshooterNamespaceData().trackedElementsData = [];
+ cleanupSavedScrolls();
+}
+
+/**
+ * Records up to 500 random non-degenerate descendants of the capture elements as anchor baselines.
+ * Must be called once before the best-effort capture pass; getCaptureState will then return anchorShift.
+ */
+export function captureAnchorBaseline(selectorsToCapture: string[]): void | BrowserSideError {
+ return safeCall((): void => {
+ const captureSpecs = computeCaptureSpecs(selectorsToCapture);
+ const captureArea = captureSpecs.length > 0 ? getCoveringRect(captureSpecs.map(spec => spec.full)) : null;
+
+ const allDescendants: Element[] = [];
+ for (let si = 0; si < selectorsToCapture.length; si++) {
+ const el = document.querySelector(selectorsToCapture[si]);
+ if (!el) continue;
+ allDescendants.push(el);
+ const nodes = el.querySelectorAll("*");
+ for (let ni = 0; ni < nodes.length; ni++) allDescendants.push(nodes[ni]);
+ }
+
+ const nonDegenerate = allDescendants.filter(el => {
+ const r = el.getBoundingClientRect();
+ if (r.width <= 0 || r.height <= 0) {
+ return false;
+ }
+
+ if (!captureArea) {
+ return true;
+ }
+
+ const rect = fromBcrToRect(r);
+
+ return Boolean(getIntersection(captureArea, rect));
+ });
+
+ const sampled = sampleRandom(nonDegenerate, MAX_ANCHOR_TRACKED_ELEMENTS);
+ getScreenshooterNamespaceData().trackedElementsData = sampled.map((el): TrackedElementData => {
+ const r = el.getBoundingClientRect();
+ return {
+ element: el,
+ rect: fromBcrToRect(r)
+ };
+ });
+ });
+}
+
+function prepareElementsScreenshotUnsafe(
+ selectorsToCapture: string[],
+ opts: PrepareScreenshotOptions
+): PrepareScreenshotResult {
+ const logger = createDebugLogger(opts, "prepareElementsScreenshot");
+
+ saveScrollPositions(selectorsToCapture, opts.selectorToScroll);
+
+ const { readableSelectorToScrollDescr } = scrollToCaptureAreaIfNeeded(
+ selectorsToCapture,
+ opts.captureElementFromTop,
+ opts.allowViewportOverflow,
+ opts.selectorToScroll,
+ logger
+ );
+
+ if (opts.disableAnimation) {
+ disableAnimations();
+ }
+
+ const pixelRatio = computePixelRatio(opts.usePixelRatio);
+ const scrollTarget = opts.selectorToScroll ? document.querySelector(opts.selectorToScroll) : null;
+ const scrollElement = scrollTarget ?? getCommonScrollParent(selectorsToCapture);
+
+ const ignoreAreas = computeIgnoreAreas(opts.ignoreSelectors);
+ const captureSpecs = computeCaptureSpecs(selectorsToCapture, logger);
+ const viewportSize = computeViewportSize();
+ const viewportOffset = computeViewportOffset();
+ const safeArea = computeSafeArea(selectorsToCapture, scrollElement, logger);
+ const scrollOffset = computeScrollOffset(scrollElement);
+
+ const documentSize = computeDocumentSize();
+ const canHaveCaret = computeCanHaveCaret();
+
+ let pointerEventsDisabled = false;
+ if (opts.disableHover === DisableHoverMode.Always) {
+ disablePointerEventsUnsafe();
+ pointerEventsDisabled = true;
+ } else if (opts.disableHover === DisableHoverMode.WhenScrollingNeeded && opts.compositeImage) {
+ const captureArea = getCoveringRect(captureSpecs.map(s => s.full));
+ const needsScrolling = getBottom(captureArea) > getBottom(safeArea);
+
+ if (needsScrolling) {
+ logger(
+ "adding stylesheet with pointer-events: none on all elements (composite capture needs scrolling). captureArea:",
+ captureArea,
+ "safeArea:",
+ safeArea
+ );
+ disablePointerEventsUnsafe();
+ pointerEventsDisabled = true;
+ }
+ }
+
+ logger("scrollOffset:", scrollOffset);
+
+ return {
+ ignoreAreas: ignoreAreas.map(area => fromCssToDevice(roundCoords(area), pixelRatio)),
+ captureSpecs: captureSpecs.map(s => ({
+ full: fromCssToDevice(roundCoords(s.full), pixelRatio),
+ clip: fromCssToDevice(roundCoords(s.clip), pixelRatio),
+ visible: fromCssToDevice(roundCoords(s.visible), pixelRatio)
+ })),
+ viewportSize: fromCssToDevice(viewportSize, pixelRatio),
+ viewportOffset: fromCssToDevice(floorCoords(viewportOffset), pixelRatio),
+ safeArea: fromCssToDevice(roundCoords(safeArea), pixelRatio),
+ documentSize: ceilCoords(fromCssToDevice(documentSize, pixelRatio)),
+ canHaveCaret,
+ pixelRatio: pixelRatio,
+ pointerEventsDisabled: pointerEventsDisabled,
+ debugLog: logger(),
+ readableSelectorToScrollDescr,
+ scrollOffset: fromCssToDeviceNumber(scrollOffset, pixelRatio)
+ } satisfies PrepareScreenshotSuccess;
+}
diff --git a/src/browser/client-scripts/screen-shooter/inject.ts b/src/browser/client-scripts/screen-shooter/inject.ts
new file mode 100644
index 000000000..bea736c29
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/inject.ts
@@ -0,0 +1,15 @@
+import * as implementation from "./implementation";
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __geminiCore: Record | undefined;
+ // eslint-disable-next-line no-var
+ var __geminiNamespace: string;
+}
+
+const globalObj = typeof window === "undefined" ? globalThis : window;
+
+if (!globalObj.__geminiCore) {
+ globalObj.__geminiCore = {};
+}
+globalObj.__geminiCore[__geminiNamespace] = implementation;
diff --git a/src/browser/client-scripts/screen-shooter/operations.ts b/src/browser/client-scripts/screen-shooter/operations.ts
new file mode 100644
index 000000000..4f1486969
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/operations.ts
@@ -0,0 +1,768 @@
+import {
+ Coord,
+ Length,
+ Point,
+ Rect,
+ Size,
+ YBand,
+ fromBcrToRect,
+ getBottom,
+ getCoveringRect,
+ getHeight,
+ getIntersection
+} from "@isomorphic";
+import { OutsideOfViewportError } from "./errors/outside-of-viewport";
+import { CaptureSpec, SavedScrollPosition as ElementScrollPosition, ScrollToCaptureSpecResult } from "./types";
+import { getReadableElementDescriptor } from "./utils/descriptions";
+import {
+ findContainingBlock,
+ findFixedPositionedParent,
+ forEachRoot,
+ getMainDocumentElem,
+ getScreenshooterNamespaceData
+} from "./utils/dom";
+import {
+ domRectToViewportCss,
+ getBoundingClientContentRect,
+ getElementCaptureRect,
+ getExtRect,
+ getPseudoElementCaptureRect,
+ getVerticalRadiusInsets
+} from "./utils/element-rect";
+import { getClipRect } from "./utils/clip-rect";
+import {
+ getCommonScrollParent,
+ getScrollParent,
+ getScrollParentsChain,
+ isRootLikeElement,
+ performScrollFixForSafariIfNeeded,
+ scrollElementBy
+} from "./utils/scroll";
+import { createDefaultTrustedTypesPolicy } from "./utils/trusted-types";
+import { buildZChain, isChainBehind } from "./utils/z-index";
+import { parseCaptureSelector, PseudoElementSelector } from "./utils/pseudo-element-rect";
+import { isSafariMobile } from "./utils/user-agent";
+
+export function computeScrollOffset(element: Element): Coord<"page", "css", "y"> {
+ return (isRootLikeElement(element) ? window.scrollY : element.scrollTop) as Coord<"page", "css", "y">;
+}
+
+export function computeViewportSize(): Size<"css"> {
+ return {
+ width: window.innerWidth as Length<"css", "x">,
+ height: window.innerHeight as Length<"css", "y">
+ };
+}
+
+export function computeViewportOffset(): Point<"page", "css"> {
+ return {
+ left: window.scrollX as Coord<"page", "css", "x">,
+ top: window.scrollY as Coord<"page", "css", "y">
+ };
+}
+
+const ELEMENT_POSITIONS_PROBE_GRID_SIZE = 5;
+
+function getProbeAxisCoordinates(length: number, gridSize: number): number[] {
+ const safeLength = Math.max(1, Math.floor(length));
+ if (gridSize <= 1) {
+ return [0];
+ }
+
+ const maxCoord = safeLength - 1;
+ const step = safeLength / (gridSize - 1);
+ const coordinates: number[] = [];
+
+ for (let i = 0; i < gridSize; i++) {
+ coordinates.push(Math.min(maxCoord, Math.round(i * step)));
+ }
+
+ return coordinates;
+}
+
+export function computeElementPositionsProbe(
+ gridSize = ELEMENT_POSITIONS_PROBE_GRID_SIZE
+): Array<(Rect<"viewport", "css"> & { elementDescr?: string }) | null> {
+ const viewportSize = computeViewportSize();
+ const xCoordinates = getProbeAxisCoordinates(viewportSize.width as number, gridSize);
+ const yCoordinates = getProbeAxisCoordinates(viewportSize.height as number, gridSize);
+ const probe: Array<(Rect<"viewport", "css"> & { elementDescr?: string }) | null> = [];
+
+ for (let yIndex = 0; yIndex < yCoordinates.length; yIndex++) {
+ for (let xIndex = 0; xIndex < xCoordinates.length; xIndex++) {
+ const x = xCoordinates[xIndex];
+ const y = yCoordinates[yIndex];
+ const element = document.elementFromPoint(x, y);
+ if (!element) {
+ probe.push(null);
+ continue;
+ }
+
+ probe.push({
+ ...fromBcrToRect(element.getBoundingClientRect()),
+ elementDescr: getReadableElementDescriptor(element)
+ });
+ }
+ }
+
+ return probe;
+}
+
+export function computeCaptureSpecs(
+ selectors: string[],
+ logger?: (...args: unknown[]) => unknown
+): CaptureSpec<"viewport", "css">[] {
+ if (selectors.length === 0) {
+ throw new Error("No selectors to compute capture area");
+ }
+ logger?.("========== ==========");
+ logger?.("selectors:", selectors);
+ const startTime = performance.now();
+
+ const elements: Array<{ element: Element; pseudoElement: PseudoElementSelector | null }> = [];
+ for (let i = 0; i < selectors.length; i++) {
+ const parsedSelector = parseCaptureSelector(selectors[i]);
+ const element = document.querySelector(parsedSelector.elementSelector);
+ if (element) {
+ elements.push({ element, pseudoElement: parsedSelector.pseudoElement });
+ }
+ }
+
+ function getCaptureSpec(
+ element: Element,
+ pseudoElement: PseudoElementSelector | null
+ ): CaptureSpec<"viewport", "css"> | null {
+ const full = pseudoElement
+ ? getPseudoElementCaptureRect(element, pseudoElement)
+ : getElementCaptureRect(element, logger);
+ if (!full) return null;
+ const clip = getClipRect(element, logger);
+ const visible = getIntersection(full, clip) ?? {
+ top: full.top,
+ left: full.left,
+ width: 0 as typeof full.width,
+ height: 0 as typeof full.height
+ };
+
+ return { full, visible, clip };
+ }
+
+ const captureSpecs: CaptureSpec<"viewport", "css">[] = [];
+
+ for (const { element, pseudoElement } of elements) {
+ const captureSpec = getCaptureSpec(element, pseudoElement);
+ if (captureSpec) {
+ captureSpecs.push(captureSpec);
+ }
+ }
+
+ logger?.("captureSpecs:", captureSpecs);
+
+ logger?.("computeCaptureSpecs time taken:", (performance.now() - startTime).toFixed(1) + "ms");
+ logger?.("========== ==========");
+
+ return captureSpecs;
+}
+
+export function computeIgnoreAreas(selectors: string[] = []): Rect<"viewport", "css">[] {
+ const ignoreAreas: Rect<"viewport", "css">[] = [];
+
+ for (let s = 0; s < selectors.length; s++) {
+ const parsedSelector = parseCaptureSelector(selectors[s]);
+ const nodeList = document.querySelectorAll(parsedSelector.elementSelector);
+ for (let i = 0; i < nodeList.length; i++) {
+ const rect = parsedSelector.pseudoElement
+ ? getPseudoElementCaptureRect(nodeList[i], parsedSelector.pseudoElement)
+ : getElementCaptureRect(nodeList[i]);
+ if (rect !== null) {
+ ignoreAreas.push(rect);
+ }
+ }
+ }
+
+ return ignoreAreas;
+}
+
+export function computeSafeArea(
+ selectorsToCapture: string[],
+ scrollElement?: Element,
+ logger?: (...args: unknown[]) => unknown
+): YBand<"viewport", "css"> {
+ logger?.("========== ==========");
+ const startTime = performance.now();
+
+ const viewportSize = computeViewportSize();
+ const viewportRect: Rect<"viewport", "css"> = {
+ left: 0 as Coord<"viewport", "css", "x">,
+ top: 0 as Coord<"viewport", "css", "y">,
+ width: viewportSize.width as Length<"css", "x">,
+ height: viewportSize.height as Length<"css", "y">
+ };
+ const captureElements = selectorsToCapture
+ .map(s => document.querySelector(parseCaptureSelector(s).elementSelector))
+ .filter((e): e is NonNullable => e !== null);
+ const captureSpecs = computeCaptureSpecs(selectorsToCapture).map(s => s.full);
+
+ if (captureSpecs.length === 0) {
+ return { top: viewportRect.top, height: viewportRect.height };
+ }
+
+ const scrollEl = scrollElement ?? document.documentElement;
+
+ // 1. Base safe area equals the visible rectangle of the scroll container
+ let safeArea: Rect<"viewport", "css">;
+ if (scrollEl === document.documentElement) {
+ logger?.("setting base safe area to viewport rect");
+ safeArea = { ...viewportRect };
+ } else {
+ const contentRect = getBoundingClientContentRect(scrollEl);
+ logger?.(
+ "setting base safe area to visible part of scroll container:",
+ getReadableElementDescriptor(scrollEl),
+ "contentRect:",
+ contentRect
+ );
+ safeArea = getIntersection(contentRect, viewportRect) ?? { ...viewportRect };
+
+ const { top: topRadiusInset, bottom: bottomRadiusInset } = getVerticalRadiusInsets(scrollEl);
+ if (topRadiusInset > 0 || bottomRadiusInset > 0) {
+ const safeAreaHeight = safeArea.height as number;
+ const topInset = Math.min(topRadiusInset, safeAreaHeight);
+ const bottomInset = Math.min(bottomRadiusInset, safeAreaHeight - topInset);
+
+ logger?.("applying radius insets to safe area:", { topInset, bottomInset });
+ safeArea = {
+ ...safeArea,
+ top: ((safeArea.top as number) + topInset) as Coord<"viewport", "css", "y">,
+ height: (safeAreaHeight - topInset - bottomInset) as Length<"css", "y">
+ };
+ }
+ }
+
+ const originalSafeArea = { ...safeArea };
+
+ // 1.1 If all capture elements are fixed or inside non-scrollable fixed elements, scrolling will not move them,
+ // so no point in computing safe area
+ if (
+ captureElements.every(
+ el =>
+ getComputedStyle(el).position === "fixed" ||
+ (findFixedPositionedParent(el) !== null && getScrollParent(el) === null)
+ )
+ ) {
+ logger?.("all capture elements are fixed or inside non-scrollable fixed subtree, skipping interferences");
+ return { top: originalSafeArea.top, height: originalSafeArea.height };
+ }
+
+ const captureArea = getCoveringRect(captureSpecs);
+
+ // 2. Build z-index chains for all capture elements
+ // One z-chain is a list of objects: { stacking context, z-index } -> { stacking context, z-index } -> ...
+ // It is used to determine which element is on top of the other
+ const targetChains = captureElements.map(el => buildZChain(el));
+
+ const captureLeft = captureArea.left as number;
+ const captureRight = captureLeft + (captureArea.width as number);
+
+ // 3. Detect interfering elements
+ const interferences: { element: Element; rect: Rect<"viewport", "css"> }[] = [];
+ const allElements = document.documentElement.querySelectorAll("*");
+ const singleCaptureElement = captureElements.length === 1 ? captureElements[0] : null;
+
+ for (let idx = 0; idx < allElements.length; idx++) {
+ const el = allElements[idx];
+
+ if (scrollEl === el || el.contains(scrollEl)) {
+ continue;
+ }
+
+ if (singleCaptureElement && (el === singleCaptureElement || el.contains(singleCaptureElement))) {
+ continue;
+ }
+
+ const computedStyle = getComputedStyle(el);
+ const position = computedStyle.position;
+ const bcr = el.getBoundingClientRect();
+
+ // Skip invisible elements
+ if (
+ bcr.width < 1 ||
+ bcr.height < 1 ||
+ (bcr.width === 1 && bcr.height === 1) ||
+ computedStyle.visibility === "hidden" ||
+ computedStyle.display === "none" ||
+ parseFloat(computedStyle.opacity) < 0.0001 ||
+ /opacity\(\s*0(?:\.0+)?%?\s*\)/.test(computedStyle.filter)
+ )
+ continue;
+ // Skip elements that don't horizontally intersect with capture area
+ if (bcr.right <= captureLeft || bcr.left >= captureRight) continue;
+ // Skip elements that are outside of viewport
+ if (getIntersection(fromBcrToRect(bcr), viewportRect) === null) continue;
+
+ let likelyInterferes = false;
+ let shouldSkipZIndexCheck = false;
+ let adjustedRect: Rect<"viewport", "css"> = domRectToViewportCss(bcr);
+
+ const fixedPositionedParent = findFixedPositionedParent(el);
+ const isInsideScrollContextFixedParent =
+ fixedPositionedParent !== null &&
+ (fixedPositionedParent === scrollEl || fixedPositionedParent.contains(scrollEl));
+
+ if (position === "fixed" || (fixedPositionedParent && !isInsideScrollContextFixedParent)) {
+ likelyInterferes = true;
+ // If the fixed element is inside a capture element, it's almost certainly interfering
+ shouldSkipZIndexCheck = captureElements.some(capEl => capEl.contains(el));
+ } else if (position === "absolute") {
+ // Skip absolutely positioned elements that are inside capture elements
+ if (captureElements.some(capEl => capEl.contains(el))) continue;
+
+ // Absolute elements interfere only if positioned relative to ancestor outside scroll container
+ const containingBlock = findContainingBlock(el);
+ // scrollElem may be window, in which case it doesn't have a contains method
+ if (scrollEl !== document.documentElement && !scrollEl.contains(containingBlock)) {
+ likelyInterferes = true;
+ }
+ } else if (position === "sticky") {
+ // Sticky elements interfere based on their top/bottom values
+ let topValue = parseFloat(computedStyle.top);
+ const bottomValue = parseFloat(computedStyle.bottom);
+
+ const scrollParent = getScrollParent(el) ?? document.documentElement;
+ logger?.("scrollParent:", getReadableElementDescriptor(scrollParent));
+ const scrollParentBcr = scrollParent.getBoundingClientRect();
+ topValue += isRootLikeElement(scrollParent) ? 0 : scrollParentBcr.top;
+ shouldSkipZIndexCheck =
+ scrollParent === scrollEl || (isRootLikeElement(scrollParent) && isRootLikeElement(scrollEl));
+
+ if (!isNaN(topValue)) {
+ adjustedRect = {
+ left: bcr.left as Coord<"viewport", "css", "x">,
+ top: topValue as Coord<"viewport", "css", "y">,
+ width: bcr.width as Length<"css", "x">,
+ height: bcr.height as Length<"css", "y">
+ };
+ likelyInterferes = true;
+ } else if (!isNaN(bottomValue)) {
+ const isRoot = scrollEl === document.documentElement;
+ const viewportBottom = isRoot
+ ? (viewportRect.height as number)
+ : (safeArea.top as number) + (safeArea.height as number);
+ adjustedRect = {
+ left: bcr.left as Coord<"viewport", "css", "x">,
+ top: (viewportBottom - bottomValue - bcr.height) as Coord<"viewport", "css", "y">,
+ width: bcr.width as Length<"css", "x">,
+ height: bcr.height as Length<"css", "y">
+ };
+ likelyInterferes = true;
+ }
+ }
+
+ if (!likelyInterferes) continue;
+
+ const candChain = buildZChain(el);
+ const behindAll = !shouldSkipZIndexCheck && targetChains.every(tChain => isChainBehind(candChain, tChain));
+
+ if (!behindAll) {
+ const extRect = getExtRect(computedStyle, adjustedRect);
+ const extLeft = extRect.left as number;
+ const extRight = extLeft + (extRect.width as number);
+
+ if (extRight <= captureLeft || extLeft >= captureRight) continue;
+
+ interferences.push({ element: el, rect: extRect });
+ }
+ }
+
+ let safeTop = safeArea.top as Coord<"viewport", "css", "y">;
+ let safeHeight = safeArea.height as Length<"css", "y">;
+ const origHeight = originalSafeArea.height as number;
+
+ // 4. Shrink safe area according to interfering elements
+ for (const interference of interferences) {
+ logger?.("processing interference:", {
+ element: getReadableElementDescriptor(interference.element),
+ rect: interference.rect
+ });
+
+ const br = interference.rect;
+ const safeBottom = getBottom({ top: safeTop, height: safeHeight });
+ const brBottom = getBottom(br);
+ const shrinkTop = brBottom - safeTop;
+ const shrinkBottom = safeBottom - br.top;
+
+ let resultingTop = safeTop;
+ let resultingHeight = safeHeight;
+
+ if (shrinkTop < shrinkBottom) {
+ resultingTop = Math.max(brBottom, safeTop) as Coord<"viewport", "css", "y">;
+ resultingHeight = getHeight(safeBottom, resultingTop);
+ logger?.("decided to shrink top");
+ } else if (shrinkBottom) {
+ resultingHeight = Math.min(safeHeight, br.top - safeTop) as Length<"css", "y">;
+ logger?.("decided to shrink bottom");
+ }
+
+ // We don't want to shrink the safe area less than 30% of original height
+ if (resultingHeight < origHeight * 0.3) {
+ logger?.("decided to skip, because shrinking is too large");
+ continue;
+ }
+
+ logger?.("resulting safe area top:", resultingTop, "resulting safe area height:", resultingHeight);
+
+ safeTop = resultingTop;
+ safeHeight = resultingHeight;
+ }
+
+ // 5. Ensure we didn't shrink below 30% of original height
+ if (safeHeight < origHeight * 0.3) {
+ safeTop = originalSafeArea.top;
+ safeHeight = originalSafeArea.height;
+ }
+
+ // Safari on iOS 26 has a large blur at the bottom that interferes with scrolling, so
+ // if safe area ends too low, we shrink it by 40px which is enough to avoid the blur.
+ if (isSafariMobile() && viewportSize.height - (safeTop + safeHeight) < 40) {
+ safeHeight = (safeHeight - 40) as Length<"css", "y">;
+ }
+
+ const finalSafeArea = {
+ top: safeTop,
+ height: safeHeight
+ };
+
+ logger?.("final safe area:", finalSafeArea);
+ logger?.("computeSafeArea time taken:", (performance.now() - startTime).toFixed(1) + "ms");
+ logger?.("========== ==========");
+
+ return finalSafeArea;
+}
+
+export function computeDocumentSize(): Size<"css"> {
+ const mainDocumentElem = getMainDocumentElem();
+ return {
+ width: mainDocumentElem.scrollWidth as Length<"css", "x">,
+ height: mainDocumentElem.scrollHeight as Length<"css", "y">
+ };
+}
+
+export function computeCanHaveCaret(): boolean {
+ const el = document.activeElement;
+ const canHaveCaret = el instanceof HTMLElement && (/^(input|textarea)$/i.test(el.tagName) || el.isContentEditable);
+
+ return canHaveCaret;
+}
+
+export function computePixelRatio(usePixelRatio: boolean = true): number {
+ if (usePixelRatio === false) {
+ return 1;
+ }
+
+ if (window.devicePixelRatio) {
+ return window.devicePixelRatio;
+ }
+
+ // for ie6-ie10 (https://developer.mozilla.org/ru/docs/Web/API/Window/devicePixelRatio)
+ // @ts-expect-error - IE hack
+ return window.screen.deviceXDPI / window.screen.logicalXDPI || 1;
+}
+
+export function scrollToCaptureAreaIfNeeded(
+ selectorsToCapture: string[],
+ captureElementFromTop?: boolean,
+ allowViewportOverflow?: boolean,
+ selectorToScroll?: string,
+ logger?: (...args: unknown[]) => unknown
+): ScrollToCaptureSpecResult {
+ const viewportSize = computeViewportSize();
+ const viewport = {
+ top: 0 as Coord<"viewport", "css", "y">,
+ left: 0 as Coord<"viewport", "css", "x">,
+ ...viewportSize
+ };
+
+ const captureSpecsResult = computeCaptureSpecs(selectorsToCapture);
+ if (!captureSpecsResult) return {};
+
+ const scrollTarget = selectorToScroll ? document.querySelector(selectorToScroll) : null;
+ const selectorsForScrollParentSearch = selectorsToCapture.map(
+ selector => parseCaptureSelector(selector).elementSelector
+ );
+ const initialScrollElem = scrollTarget ?? getCommonScrollParent(selectorsForScrollParentSearch);
+ const readableSelectorToScrollDescr = selectorToScroll ?? getReadableElementDescriptor(initialScrollElem);
+
+ const captureArea = getCoveringRect(captureSpecsResult.map(s => s.full));
+ const safeArea = computeSafeArea(selectorsToCapture, initialScrollElem, logger);
+
+ const captureAndSafeAreasIntersection = getIntersection(captureArea, safeArea);
+ const captureAndViewportIntersection = getIntersection(captureArea, viewport);
+ const expectedVisibleHeight = Math.min(captureArea.height, safeArea.height);
+ const isIntersectionWithSafeAreaTooSmall =
+ !captureAndSafeAreasIntersection || captureAndSafeAreasIntersection.height < expectedVisibleHeight / 2;
+ const isCaptureAreaStartVisible = captureArea.top >= safeArea.top;
+ logger?.("scrollToCaptureAreaIfNeeded: intersection check", {
+ captureArea,
+ safeArea,
+ viewport,
+ hasViewportIntersection: Boolean(captureAndViewportIntersection),
+ hasSafeAreaIntersection: Boolean(captureAndSafeAreasIntersection),
+ isIntersectionWithSafeAreaTooSmall,
+ captureElementFromTop: Boolean(captureElementFromTop)
+ });
+
+ if (!captureElementFromTop && !captureAndViewportIntersection) {
+ logger?.(
+ "scrollToCaptureAreaIfNeeded: throwing OutsideOfViewportError because captureElementFromTop is disabled and target is outside viewport"
+ );
+ throw new OutsideOfViewportError();
+ }
+
+ if ((!captureElementFromTop || !isIntersectionWithSafeAreaTooSmall) && isCaptureAreaStartVisible) {
+ logger?.("scrollToCaptureAreaIfNeeded: skipping scroll", {
+ reason: !captureElementFromTop
+ ? "captureElementFromTop is disabled"
+ : "target already has enough safe area visibility"
+ });
+ return {};
+ }
+
+ if (!captureElementFromTop && allowViewportOverflow) {
+ logger?.(
+ "scrollToCaptureAreaIfNeeded: skipping scroll because allowViewportOverflow is true and captureElementFromTop is false"
+ );
+ return {};
+ }
+
+ logger?.("scrollToCaptureAreaIfNeeded: scrolling is required", {
+ scrollElement: readableSelectorToScrollDescr,
+ requestedSelectorToScroll: selectorToScroll ?? null,
+ selectorMatched: Boolean(scrollTarget)
+ });
+
+ const scrollChain = [...getScrollParentsChain(initialScrollElem)];
+ if (scrollChain[scrollChain.length - 1] !== initialScrollElem) {
+ scrollChain.push(initialScrollElem);
+ }
+
+ for (let i = 1; i < scrollChain.length; i++) {
+ const currentSafeArea = computeSafeArea(selectorsToCapture, scrollChain[i - 1]);
+ const childTop = scrollChain[i].getBoundingClientRect().top;
+ const scrollDelta = childTop - currentSafeArea.top;
+ logger?.("scrollToCaptureAreaIfNeeded: scrolling chain element", {
+ scrollElement: getReadableElementDescriptor(scrollChain[i - 1]),
+ childElement: getReadableElementDescriptor(scrollChain[i]),
+ scrollDelta
+ });
+ scrollElementBy(scrollChain[i - 1], scrollDelta as Coord<"page", "css", "y">, logger);
+ }
+
+ const finalCaptureArea = getCoveringRect(computeCaptureSpecs(selectorsToCapture).map(s => s.full));
+ if (!finalCaptureArea) return {};
+
+ const finalSafeArea = computeSafeArea(selectorsToCapture, initialScrollElem);
+ const finalScrollDelta = finalCaptureArea.top - finalSafeArea.top;
+ logger?.("scrollToCaptureAreaIfNeeded: final alignment scroll", {
+ scrollElement: readableSelectorToScrollDescr,
+ finalScrollDelta
+ });
+ scrollElementBy(initialScrollElem, finalScrollDelta as Coord<"page", "css", "y">, logger);
+
+ return {
+ readableSelectorToScrollDescr
+ };
+}
+
+function saveElementScrollPosition(element: Element): void {
+ const namespaceData = getScreenshooterNamespaceData();
+ if (!namespaceData.savedScrollPositions) {
+ namespaceData.savedScrollPositions = [];
+ }
+
+ if (namespaceData.savedScrollPositions.some(saved => saved.element === element)) {
+ return;
+ }
+
+ const savedPosition: ElementScrollPosition = isRootLikeElement(element)
+ ? {
+ element,
+ left: window.scrollX,
+ top: window.scrollY
+ }
+ : {
+ element,
+ left: (element as HTMLElement).scrollLeft,
+ top: (element as HTMLElement).scrollTop
+ };
+
+ namespaceData.savedScrollPositions.push(savedPosition);
+}
+
+export function saveScrollPositions(selectorsToCapture: string[], selectorToScroll?: string): void {
+ getScreenshooterNamespaceData().savedScrollPositions = [];
+
+ const scrollTarget = selectorToScroll ? document.querySelector(selectorToScroll) : null;
+ const selectorsForScrollParentSearch = selectorsToCapture.map(
+ selector => parseCaptureSelector(selector).elementSelector
+ );
+ const initialScrollElement = scrollTarget ?? getCommonScrollParent(selectorsForScrollParentSearch);
+ const scrollChain = [...getScrollParentsChain(initialScrollElement)];
+
+ if (scrollChain[scrollChain.length - 1] !== initialScrollElement) {
+ scrollChain.push(initialScrollElement);
+ }
+
+ for (const scrollElement of scrollChain) {
+ saveElementScrollPosition(scrollElement);
+ }
+}
+
+export function prepareFullPageScrollCleanup(): void {
+ getScreenshooterNamespaceData().savedScrollPositions = [];
+ saveElementScrollPosition(document.documentElement);
+}
+
+function restoreScrollPosition(savedPosition: ElementScrollPosition): void {
+ if (isRootLikeElement(savedPosition.element)) {
+ performScrollFixForSafariIfNeeded(savedPosition.top);
+ window.scrollTo(savedPosition.left, savedPosition.top);
+ return;
+ }
+
+ const scrollElement = savedPosition.element as Element & {
+ scrollTo?: (left: number, top: number) => void;
+ scrollLeft?: number;
+ scrollTop?: number;
+ };
+
+ if (typeof scrollElement.scrollTo === "function") {
+ scrollElement.scrollTo(savedPosition.left, savedPosition.top);
+ return;
+ }
+
+ if (typeof scrollElement.scrollLeft === "number") {
+ scrollElement.scrollLeft = savedPosition.left;
+ }
+ if (typeof scrollElement.scrollTop === "number") {
+ scrollElement.scrollTop = savedPosition.top;
+ }
+}
+
+export function cleanupSavedScrolls(): void {
+ try {
+ const namespaceData = getScreenshooterNamespaceData();
+ const savedScrollPositions = namespaceData.savedScrollPositions ?? [];
+ namespaceData.savedScrollPositions = [];
+
+ for (const savedScrollPosition of savedScrollPositions) {
+ try {
+ restoreScrollPosition(savedScrollPosition);
+ } catch (error) {
+ void error;
+ }
+ }
+ } catch (error) {
+ void error;
+ }
+}
+
+export function disableAnimations(): void {
+ const everyElementSelector = "*:not(#testplane-q.testplane-w.testplane-e.testplane-r.testplane-t.testplane-y)";
+ const everythingSelector = ["", "::before", "::after"]
+ .map(function (pseudo) {
+ return everyElementSelector + pseudo;
+ })
+ .join(", ");
+
+ const styleElements: HTMLStyleElement[] = [];
+
+ function appendDisableAnimationStyleElement(root: Element | ShadowRoot): void {
+ const styleElement = document.createElement("style");
+ styleElement.innerHTML =
+ everythingSelector +
+ [
+ "{",
+ " animation-delay: 0ms !important;",
+ " animation-duration: 0ms !important;",
+ " animation-timing-function: step-start !important;",
+ " transition-timing-function: step-start !important;",
+ " scroll-behavior: auto !important;",
+ " transition: none !important;",
+ "}"
+ ].join("\n");
+
+ root.appendChild(styleElement);
+ styleElements.push(styleElement);
+ }
+
+ forEachRoot(function (root) {
+ try {
+ appendDisableAnimationStyleElement(root);
+ } catch (err: unknown) {
+ if (
+ err &&
+ (err as Error).message &&
+ (err as Error).message.indexOf("This document requires 'TrustedHTML' assignment") !== -1
+ ) {
+ createDefaultTrustedTypesPolicy();
+
+ appendDisableAnimationStyleElement(root);
+ } else {
+ throw err;
+ }
+ }
+ });
+
+ window.__cleanupAnimation = function (): void {
+ for (let i = 0; i < styleElements.length; i++) {
+ // IE11 doesn't have remove() on node
+ styleElements[i].parentNode!.removeChild(styleElements[i]);
+ }
+
+ delete window.__cleanupAnimation;
+ };
+}
+
+export function disablePointerEvents(): void {
+ const everyElementSelector = "*:not(#testplane-q.testplane-w.testplane-e.testplane-r.testplane-t.testplane-y)";
+ const everythingSelector = ["", "::before", "::after"]
+ .map(function (pseudo) {
+ return everyElementSelector + pseudo;
+ })
+ .join(", ");
+
+ const styleElements: HTMLStyleElement[] = [];
+
+ function appendDisablePointerEventsStyleElement(root: Element | ShadowRoot): void {
+ const styleElement = document.createElement("style");
+ styleElement.innerHTML = everythingSelector + ["{", " pointer-events: none !important;", "}"].join("\n");
+
+ root.appendChild(styleElement);
+ styleElements.push(styleElement);
+ }
+
+ forEachRoot(function (root) {
+ try {
+ appendDisablePointerEventsStyleElement(root);
+ } catch (err) {
+ if (
+ err &&
+ (err as Error).message &&
+ (err as Error).message.indexOf("This document requires 'TrustedHTML' assignment") !== -1
+ ) {
+ createDefaultTrustedTypesPolicy();
+
+ appendDisablePointerEventsStyleElement(root);
+ } else {
+ throw err;
+ }
+ }
+ });
+
+ const namespaceData = getScreenshooterNamespaceData();
+ namespaceData.cleanupPointerEventsCb = function (): void {
+ for (let i = 0; i < styleElements.length; i++) {
+ styleElements[i].parentNode!.removeChild(styleElements[i]);
+ }
+ };
+}
diff --git a/src/browser/client-scripts/screen-shooter/tsconfig.compat.json b/src/browser/client-scripts/screen-shooter/tsconfig.compat.json
new file mode 100644
index 000000000..02d24e982
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/tsconfig.compat.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../tsconfig.compat.common.json",
+ "include": [".", "../../isomorphic", "../shared"],
+ "exclude": ["./tsc-out", "../shared/lib.native.ts"],
+ "compilerOptions": {
+ "outDir": "./tsc-out"
+ }
+}
diff --git a/src/browser/client-scripts/screen-shooter/tsconfig.json b/src/browser/client-scripts/screen-shooter/tsconfig.json
new file mode 100644
index 000000000..27be579ad
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../tsconfig.native.common.json",
+ "include": [".", "../shared", "../../isomorphic"],
+ "exclude": ["./tsc-out"],
+ "compilerOptions": {
+ "outDir": "./tsc-out"
+ }
+}
diff --git a/src/browser/client-scripts/screen-shooter/types.ts b/src/browser/client-scripts/screen-shooter/types.ts
new file mode 100644
index 000000000..84ee7f861
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/types.ts
@@ -0,0 +1,130 @@
+import { BrowserSideError, Coord, DisableHoverMode, Point, Rect, Size, Space, Unit, YBand } from "@isomorphic";
+
+export interface CaptureSpec {
+ /** Full element rect, unconstrained by ancestor overflow clipping */
+ full: Rect;
+ /** Clip rect used to compute visible portion */
+ clip: Rect;
+ /** Visible portion: full rect intersected with all ancestor overflow clip boundaries */
+ visible: Rect;
+}
+
+export interface TrackedElementData {
+ element: Element;
+ /** baseline element rect in viewport CSS coordinates */
+ rect: Rect<"viewport", "css">;
+}
+
+export interface CaptureState {
+ scrollOffset: Coord<"page", "device", "y">;
+ viewportOffset: Point<"page", "device">;
+ captureSpecs: CaptureSpec<"viewport", "device">[];
+ ignoreAreas: Rect<"viewport", "device">[];
+ safeArea: YBand<"viewport", "device">;
+ /** Observed viewport-space vertical movement of tracked elements vs baseline, in device px. */
+ anchorShift: number | null;
+}
+
+export interface SavedScrollPosition {
+ element: Element;
+ left: number;
+ top: number;
+}
+
+export interface ScreenshooterNamespaceData {
+ cleanupPointerEventsCb?: () => void;
+ savedScrollPositions?: SavedScrollPosition[];
+ trackedElementsData?: TrackedElementData[];
+}
+
+export interface PrepareScreenshotOptions {
+ ignoreSelectors?: string[];
+ allowViewportOverflow?: boolean;
+ captureElementFromTop?: boolean;
+ selectorToScroll?: string;
+ disableAnimation?: boolean;
+ disableHover?: DisableHoverMode;
+ compositeImage?: boolean;
+ debug?: string[];
+ usePixelRatio?: boolean;
+}
+
+export interface PrepareScreenshotSuccess {
+ // Area free of sticky elements, inside which it's safe to capture element that's interesting to us
+ // Measured relative to browser viewport (not the whole page!)
+ safeArea: YBand<"viewport", "device">;
+ // Boundaries of elements that we should ignore when comparing screenshots (these areas will be painted in black)
+ ignoreAreas: Rect<"viewport", "device">[];
+ // Element capture areas with full (unconstrained) and visible (clipped by ancestor overflow) rects
+ captureSpecs: CaptureSpec<"viewport", "device">[];
+ // Viewport size
+ viewportSize: Size<"device">;
+ // Viewport scroll offsets, window.scrollX / window.scrollY respectively
+ viewportOffset: Point<"page", "device">;
+ // Total height of the document, may be larger than viewport
+ documentSize: Size<"device">;
+ // Whether the document.activeElement is likely editable (e.g. input, textarea, etc.)
+ canHaveCaret: boolean;
+ // Pixel ratio: window.devicePixelRatio or 1 if usePixelRatio was set to false
+ pixelRatio: number;
+ // Whether pointer-events were disabled during prepareScreenshot. Useful for "when-scrolling-needed", because in that case it's determined on browser side
+ pointerEventsDisabled?: boolean;
+ // Debug log, returned only if DEBUG env includes scope "testplane:screenshots:browser:prepareScreenshot"
+ debugLog?: string;
+ // Description of the element that is being scrolled, used for human-readable errors
+ readableSelectorToScrollDescr?: string;
+ // Current vertical scroll offset of the resolved scroll element (or window/document root)
+ scrollOffset: Coord<"page", "device", "y">;
+}
+
+export type PrepareScreenshotResult = PrepareScreenshotSuccess | BrowserSideError;
+
+export interface ScrollToCaptureSpecResult {
+ readableSelectorToScrollDescr?: string;
+}
+
+export type ElementPositionsProbe = (Rect<"viewport", U> & { elementDescr?: string }) | null;
+
+export interface PrepareFullPageScreenshotSuccess {
+ documentSize: Size<"device">;
+ viewportSize: Size<"device">;
+ viewportOffset: Point<"page", "device">;
+ safeArea: YBand<"viewport", "device">;
+ elementPositionsProbe: ElementPositionsProbe<"device">[];
+ pixelRatio: number;
+ pointerEventsDisabled?: boolean;
+}
+
+export type PrepareFullPageScreenshotResult = PrepareFullPageScreenshotSuccess | BrowserSideError;
+
+export interface ScrollFullPageSuccess {
+ viewportOffset: Point<"page", "device">;
+ elementPositionsProbe: ElementPositionsProbe<"device">[];
+}
+
+export interface PrepareViewportScreenshotSuccess {
+ viewportSize: Size<"device">;
+ viewportOffset: Point<"page", "device">;
+ documentSize: Size<"device">;
+ canHaveCaret: boolean;
+ pixelRatio: number;
+ pointerEventsDisabled?: boolean;
+}
+
+export type PrepareViewportScreenshotResult = PrepareViewportScreenshotSuccess | BrowserSideError;
+
+export type ScrollFullPageResult = ScrollFullPageSuccess | BrowserSideError;
+
+export type ScrollResult =
+ | {
+ readableSelectorToScrollDescr?: string;
+ debugLog?: string;
+ }
+ | BrowserSideError;
+
+export type GetCaptureStateResult =
+ | (CaptureState & {
+ readableSelectorToScrollDescr?: string;
+ debugLog?: string;
+ })
+ | BrowserSideError;
diff --git a/src/browser/client-scripts/screen-shooter/utils/clip-rect.ts b/src/browser/client-scripts/screen-shooter/utils/clip-rect.ts
new file mode 100644
index 000000000..b7d24aae6
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/utils/clip-rect.ts
@@ -0,0 +1,136 @@
+import { Rect, Coord, Length, getIntersection } from "@isomorphic";
+import { getBoundingClientContentRect } from "./element-rect";
+import { isRootLikeElement } from "./scroll";
+import { findContainingBlock } from "./dom";
+import { getReadableElementDescriptor } from "./descriptions";
+
+function getViewportRect(): Rect<"viewport", "css"> {
+ return {
+ top: 0 as Coord<"viewport", "css", "y">,
+ left: 0 as Coord<"viewport", "css", "x">,
+ width: window.innerWidth as Length<"css", "x">,
+ height: window.innerHeight as Length<"css", "y">
+ };
+}
+
+function hasOverflowClipping(style: CSSStyleDeclaration): boolean {
+ return style.overflow !== "visible" || style.overflowX !== "visible" || style.overflowY !== "visible";
+}
+
+type AbsoluteContainingBlock = {
+ /** Absolute ancestor of element is the element itself or one of its parents that has position: absolute */
+ absoluteAncestor: Element;
+ /** Containing block of absoluteAncestor is an element relative to which it's positioned, e.g. parent having position: relative */
+ containingBlock: Element;
+};
+
+function getAbsoluteContainingBlocks(element: Element): AbsoluteContainingBlock[] {
+ const absoluteContainingBlocks: AbsoluteContainingBlock[] = [];
+ let current: Element | null = element;
+
+ while (current && !isRootLikeElement(current)) {
+ const style = getComputedStyle(current);
+
+ if (style.position === "absolute") {
+ const containingBlock = findContainingBlock(current);
+ absoluteContainingBlocks.push({ absoluteAncestor: current, containingBlock });
+ }
+
+ current = current.parentElement;
+ }
+
+ return absoluteContainingBlocks;
+}
+
+function getFixedAncestors(element: Element): Element[] {
+ const fixedAncestors: Element[] = [];
+ let current: Element | null = element;
+
+ while (current && !isRootLikeElement(current)) {
+ if (getComputedStyle(current).position === "fixed") {
+ fixedAncestors.push(current);
+ }
+
+ current = current.parentElement;
+ }
+
+ return fixedAncestors;
+}
+
+/** Absolute-positioned descendants may escape clipping when their containing block is outside clippingElement */
+function escapesOverflowClippingViaAbsoluteContainingBlocks(
+ clippingElement: Element,
+ absoluteContainingBlocks: AbsoluteContainingBlock[]
+): boolean {
+ return absoluteContainingBlocks.some(({ absoluteAncestor, containingBlock }) => {
+ if (absoluteAncestor === clippingElement || !clippingElement.contains(absoluteAncestor)) {
+ return false;
+ }
+
+ return containingBlock !== clippingElement && containingBlock.contains(clippingElement);
+ });
+}
+
+/** Fixed-position descendants escape clipping of ancestors above the fixed ancestor */
+function escapesOverflowClippingViaFixedAncestors(clippingElement: Element, fixedAncestors: Element[]): boolean {
+ return fixedAncestors.some(
+ fixedAncestor => fixedAncestor !== clippingElement && clippingElement.contains(fixedAncestor)
+ );
+}
+
+/**
+ * Computes the clip rect for an element by intersecting the content boxes
+ * of all ancestor elements with overflow clipping, starting from the viewport.
+ *
+ * Elements with `position: fixed` are only clipped by the viewport,
+ * since they are positioned relative to the viewport and escape all
+ * ancestor overflow clipping.
+ */
+export function getClipRect(element: Element, logger?: (...args: unknown[]) => unknown): Rect<"viewport", "css"> {
+ const viewportRect = getViewportRect();
+
+ const absoluteContainingBlocks = getAbsoluteContainingBlocks(element);
+ const fixedAncestors = getFixedAncestors(element);
+
+ let clipRect: Rect<"viewport", "css"> = viewportRect;
+ let current: Element | null = element.parentElement;
+
+ while (current) {
+ if (isRootLikeElement(current)) {
+ break;
+ }
+ const style = getComputedStyle(current);
+
+ if (hasOverflowClipping(style)) {
+ const isEscapingCurrentOverflowClipping =
+ escapesOverflowClippingViaAbsoluteContainingBlocks(current, absoluteContainingBlocks) ||
+ escapesOverflowClippingViaFixedAncestors(current, fixedAncestors);
+ if (isEscapingCurrentOverflowClipping) {
+ current = current.parentElement;
+ continue;
+ }
+
+ const contentBox = getBoundingClientContentRect(current);
+ logger?.("intersecting with:", getReadableElementDescriptor(current), "contentBox:", contentBox);
+ const intersection = getIntersection(clipRect, contentBox);
+
+ if (!intersection) {
+ logger?.("no intersection found for:", getReadableElementDescriptor(current));
+
+ // Element is fully clipped — return zero-sized rect at clip origin
+ return {
+ top: clipRect.top,
+ left: clipRect.left,
+ width: 0 as Length<"css", "x">,
+ height: 0 as Length<"css", "y">
+ };
+ }
+
+ clipRect = intersection;
+ }
+
+ current = current.parentElement;
+ }
+
+ return clipRect;
+}
diff --git a/src/browser/client-scripts/screen-shooter/utils/descriptions.ts b/src/browser/client-scripts/screen-shooter/utils/descriptions.ts
new file mode 100644
index 000000000..8bf9c5e67
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/utils/descriptions.ts
@@ -0,0 +1,19 @@
+export function getReadableElementDescriptor(element: Element): string {
+ if (element === document.documentElement) return "html";
+
+ const tag = element.tagName.toLowerCase();
+
+ if (element.id) return `${tag}#${element.id}`;
+
+ if (element.classList.length) {
+ const classes: string[] = [];
+
+ for (let i = 0; i < element.classList.length; i++) {
+ classes.push(element.classList[i]);
+ }
+
+ return tag + "." + classes.join(".");
+ }
+
+ return tag;
+}
diff --git a/src/browser/client-scripts/screen-shooter/utils/dom.ts b/src/browser/client-scripts/screen-shooter/utils/dom.ts
new file mode 100644
index 000000000..54f958317
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/utils/dom.ts
@@ -0,0 +1,113 @@
+import { ScreenshooterNamespaceData } from "../types";
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __geminiCore: Record | undefined;
+ // eslint-disable-next-line no-var
+ var __geminiNamespace: string;
+}
+
+const FALLBACK_SCREENSHOOTER_NAMESPACE = "__testplane_screenshooter__";
+
+export function getOwnerWindow(node: Node): Window | null {
+ if (!node.ownerDocument) {
+ return null;
+ }
+ return node.ownerDocument.defaultView;
+}
+
+export function getOwnerIframe(node: Node): Element | null {
+ const nodeWindow = getOwnerWindow(node);
+ if (nodeWindow) {
+ return nodeWindow.frameElement;
+ }
+ return null;
+}
+
+export function getMainDocumentElem(currDocumentElem?: HTMLElement): HTMLElement {
+ if (!currDocumentElem) {
+ currDocumentElem = document.documentElement;
+ }
+
+ const currIframe = getOwnerIframe(currDocumentElem);
+ if (!currIframe) {
+ return currDocumentElem;
+ }
+
+ const currWindow = getOwnerWindow(currIframe);
+ if (!currWindow) {
+ return currDocumentElem;
+ }
+
+ return getMainDocumentElem(currWindow.document.documentElement);
+}
+
+export function forEachRoot(cb: (root: Element | ShadowRoot) => void): void {
+ function traverseRoots(root: Element | ShadowRoot): void {
+ cb(root);
+ // @ts-expect-error - IE11 requires the third and fourth arguments
+ const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false);
+ for (let node: Node | null = treeWalker.currentNode; node !== null; node = treeWalker.nextNode()) {
+ if (node instanceof Element && node.shadowRoot) {
+ traverseRoots(node.shadowRoot);
+ }
+ }
+ }
+ traverseRoots(document.documentElement);
+}
+
+export function getParentElement(node: Node): Element | null {
+ if (node instanceof ShadowRoot) return node.host;
+ if (node instanceof Element) {
+ const root = node.getRootNode();
+ return node.parentElement || (root instanceof ShadowRoot ? root.host : null);
+ }
+ return node.parentNode instanceof Element ? node.parentNode : null;
+}
+
+export function findFixedPositionedParent(element: Element): Element | null {
+ let parent = element.parentElement;
+ while (parent) {
+ if (getComputedStyle(parent).position === "fixed") {
+ return parent;
+ }
+ parent = parent.parentElement;
+ }
+ return null;
+}
+
+export function findContainingBlock(element: Element): Element {
+ let parent = element.parentElement;
+ while (parent) {
+ const style = getComputedStyle(parent);
+ if (
+ style.position === "relative" ||
+ style.position === "absolute" ||
+ style.position === "fixed" ||
+ style.position === "sticky" ||
+ style.transform !== "none" ||
+ style.perspective !== "none"
+ ) {
+ return parent;
+ }
+ parent = parent.parentElement;
+ }
+ return document.documentElement;
+}
+
+export function getScreenshooterNamespaceData(): ScreenshooterNamespaceData {
+ if (!window.__geminiCore) {
+ window.__geminiCore = {};
+ }
+
+ const namespace =
+ typeof __geminiNamespace === "string" && __geminiNamespace
+ ? __geminiNamespace
+ : FALLBACK_SCREENSHOOTER_NAMESPACE;
+
+ if (!window.__geminiCore[namespace]) {
+ window.__geminiCore[namespace] = {};
+ }
+
+ return window.__geminiCore[namespace] as ScreenshooterNamespaceData;
+}
diff --git a/src/browser/client-scripts/screen-shooter/utils/element-rect.ts b/src/browser/client-scripts/screen-shooter/utils/element-rect.ts
new file mode 100644
index 000000000..1cac482e5
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/utils/element-rect.ts
@@ -0,0 +1,230 @@
+import { Rect, Coord, Length, getCoveringRect } from "@isomorphic";
+import { getOwnerWindow, getOwnerIframe } from "./dom";
+import { PSEUDO_ELEMENTS, PseudoElementSelector, getPseudoElementRect } from "./pseudo-element-rect";
+
+interface BoxShadow {
+ offsetX: number;
+ offsetY: number;
+ blurRadius: number;
+ spreadRadius: number;
+ inset: boolean;
+}
+
+export function domRectToViewportCss(domRect: DOMRect): Rect<"viewport", "css"> {
+ return {
+ top: domRect.top as Coord<"viewport", "css", "y">,
+ left: domRect.left as Coord<"viewport", "css", "x">,
+ width: domRect.width as Length<"css", "x">,
+ height: domRect.height as Length<"css", "y">
+ };
+}
+
+function getElementBorderWidths(element: Element): { top: number; left: number } {
+ const ownerWindow = getOwnerWindow(element) || window;
+ const style = ownerWindow.getComputedStyle(element);
+
+ return {
+ top: parseFloat(style.borderTopWidth),
+ left: parseFloat(style.borderLeftWidth)
+ };
+}
+
+function getIframeContentOrigin(node: Element): Rect<"viewport", "css"> {
+ const border = getElementBorderWidths(node);
+ const bcr = node.getBoundingClientRect();
+
+ return {
+ top: (bcr.top + border.top) as Coord<"viewport", "css", "y">,
+ left: (bcr.left + border.left) as Coord<"viewport", "css", "x">,
+ width: bcr.width as Length<"css", "x">,
+ height: bcr.height as Length<"css", "y">
+ };
+}
+
+function getNestedBoundingClientRect(node: Element, logger?: (...args: unknown[]) => unknown): Rect<"viewport", "css"> {
+ const ownerIframe = getOwnerIframe(node);
+
+ if (ownerIframe === null || getOwnerWindow(ownerIframe) === window) {
+ logger?.("getNestedBoundingClientRect ownerIframe is null or window, returning bounding rect untouched");
+ return domRectToViewportCss(node.getBoundingClientRect());
+ }
+
+ logger?.(
+ "getNestedBoundingClientRect ownerIframe is not null or window, returning bounding rect with iframe origin"
+ );
+
+ const elementRect = domRectToViewportCss(node.getBoundingClientRect());
+ let top = elementRect.top as number;
+ let left = elementRect.left as number;
+
+ let currentIframe: Element | null = ownerIframe;
+ while (currentIframe) {
+ const iframeOrigin = getIframeContentOrigin(currentIframe);
+ top += iframeOrigin.top as number;
+ left += iframeOrigin.left as number;
+
+ currentIframe = getOwnerIframe(currentIframe);
+ if (currentIframe && getOwnerWindow(currentIframe) === window) {
+ const outerOrigin = getIframeContentOrigin(currentIframe);
+ top += outerOrigin.top as number;
+ left += outerOrigin.left as number;
+ break;
+ }
+ }
+
+ return {
+ top: top as Coord<"viewport", "css", "y">,
+ left: left as Coord<"viewport", "css", "x">,
+ width: elementRect.width,
+ height: elementRect.height
+ };
+}
+
+function parseBoxShadow(value: string): BoxShadow[] {
+ const regex = /[-+]?\d*\.?\d+px/g;
+ const results: BoxShadow[] = [];
+
+ for (const part of (value || "").split(",")) {
+ const match = part.match(regex);
+ if (match) {
+ results.push({
+ offsetX: parseFloat(match[0]),
+ offsetY: parseFloat(match[1]) || 0,
+ blurRadius: parseFloat(match[2]) || 0,
+ spreadRadius: parseFloat(match[3]) || 0,
+ inset: part.indexOf("inset") !== -1
+ });
+ }
+ }
+
+ return results;
+}
+
+function calculateShadowExtent(shadows: BoxShadow[]): { top: number; left: number; right: number; bottom: number } {
+ const result = { top: 0, left: 0, right: 0, bottom: 0 };
+
+ for (const shadow of shadows) {
+ if (shadow.inset) continue;
+ const blurAndSpread = shadow.spreadRadius + shadow.blurRadius;
+ result.left = Math.min(shadow.offsetX - blurAndSpread, result.left);
+ result.right = Math.max(shadow.offsetX + blurAndSpread, result.right);
+ result.top = Math.min(shadow.offsetY - blurAndSpread, result.top);
+ result.bottom = Math.max(shadow.offsetY + blurAndSpread, result.bottom);
+ }
+
+ return result;
+}
+
+function calculateShadowRect(clientRect: Rect<"viewport", "css">, shadows: BoxShadow[]): Rect<"viewport", "css"> {
+ const extent = calculateShadowExtent(shadows);
+
+ return {
+ left: ((clientRect.left as number) + extent.left) as Coord<"viewport", "css", "x">,
+ top: ((clientRect.top as number) + extent.top) as Coord<"viewport", "css", "y">,
+ width: ((clientRect.width as number) - extent.left + extent.right) as Length<"css", "x">,
+ height: ((clientRect.height as number) - extent.top + extent.bottom) as Length<"css", "y">
+ };
+}
+
+function calculateOutlineRect(clientRect: Rect<"viewport", "css">, outline: number): Rect<"viewport", "css"> {
+ return {
+ top: ((clientRect.top as number) - outline) as Coord<"viewport", "css", "y">,
+ left: ((clientRect.left as number) - outline) as Coord<"viewport", "css", "x">,
+ width: ((clientRect.width as number) + outline * 2) as Length<"css", "x">,
+ height: ((clientRect.height as number) + outline * 2) as Length<"css", "y">
+ };
+}
+
+export function getExtRect(css: CSSStyleDeclaration, clientRect: Rect<"viewport", "css">): Rect<"viewport", "css"> {
+ const shadows = parseBoxShadow(css.boxShadow);
+ const outlineWidth = parseInt(css.outlineWidth, 10);
+ const outline = !isNaN(outlineWidth) && css.outlineStyle !== "none" ? outlineWidth : 0;
+
+ return getCoveringRect([calculateShadowRect(clientRect, shadows), calculateOutlineRect(clientRect, outline)])!;
+}
+
+function isHidden(css: CSSStyleDeclaration, rect: Rect<"viewport", "css">): boolean {
+ return (
+ css.display === "none" ||
+ css.visibility === "hidden" ||
+ parseFloat(css.opacity) < 0.0001 ||
+ rect.width < 0.0001 ||
+ rect.height < 0.0001
+ );
+}
+
+export function getBoundingClientContentRect(element: Element): Rect<"viewport", "css"> {
+ const style = getComputedStyle(element);
+ const bcr = element.getBoundingClientRect();
+ const borderLeft = parseFloat(style.borderLeftWidth) || 0;
+ const borderTop = parseFloat(style.borderTopWidth) || 0;
+ const borderRight = parseFloat(style.borderRightWidth) || 0;
+ const borderBottom = parseFloat(style.borderBottomWidth) || 0;
+
+ return {
+ left: (bcr.left + borderLeft) as Coord<"viewport", "css", "x">,
+ top: (bcr.top + borderTop) as Coord<"viewport", "css", "y">,
+ width: (bcr.width - borderLeft - borderRight) as Length<"css", "x">,
+ height: (bcr.height - borderTop - borderBottom) as Length<"css", "y">
+ };
+}
+
+export function getElementCaptureRect(
+ element: Element,
+ logger?: (...args: unknown[]) => unknown
+): Rect<"viewport", "css"> | null {
+ const css = getComputedStyle(element);
+ const clientRect = getNestedBoundingClientRect(element, logger);
+ logger?.("getElementCaptureRect clientRect:", clientRect);
+
+ if (isHidden(css, clientRect)) {
+ return null;
+ }
+
+ let elementRect = getExtRect(css, clientRect);
+
+ for (const pseudoElement of PSEUDO_ELEMENTS) {
+ const pseudoCss = getComputedStyle(element, pseudoElement);
+ elementRect = getCoveringRect([elementRect, getExtRect(pseudoCss, clientRect)])!;
+ }
+
+ return elementRect;
+}
+
+export function getPseudoElementCaptureRect(
+ element: Element,
+ pseudo: PseudoElementSelector
+): Rect<"viewport", "css"> | null {
+ const css = getComputedStyle(element);
+ const clientRect = getNestedBoundingClientRect(element);
+
+ if (isHidden(css, clientRect)) {
+ return null;
+ }
+
+ const pseudoRect = getPseudoElementRect(element, pseudo, clientRect);
+
+ if (!pseudoRect) {
+ return null;
+ }
+
+ return getExtRect(getComputedStyle(element, pseudo), pseudoRect);
+}
+
+function parseBorderRadius(value: string): number {
+ const [first, second] = value.trim().split(/\s+/);
+ const parsed = parseFloat(second ?? first);
+ return isNaN(parsed) ? 0 : parsed;
+}
+
+export function getVerticalRadiusInsets(element: Element): { top: number; bottom: number } {
+ const style = getComputedStyle(element);
+
+ return {
+ top: Math.max(parseBorderRadius(style.borderTopLeftRadius), parseBorderRadius(style.borderTopRightRadius)),
+ bottom: Math.max(
+ parseBorderRadius(style.borderBottomLeftRadius),
+ parseBorderRadius(style.borderBottomRightRadius)
+ )
+ };
+}
diff --git a/src/browser/client-scripts/screen-shooter/utils/pseudo-element-rect.ts b/src/browser/client-scripts/screen-shooter/utils/pseudo-element-rect.ts
new file mode 100644
index 000000000..8b47463ea
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/utils/pseudo-element-rect.ts
@@ -0,0 +1,272 @@
+import { Rect, Coord, Length } from "@isomorphic";
+
+interface TransformMatrix {
+ a: number;
+ b: number;
+ c: number;
+ d: number;
+ tx: number;
+ ty: number;
+}
+
+export type PseudoElementSelector = "::before" | "::after";
+
+export const PSEUDO_ELEMENTS: PseudoElementSelector[] = ["::before", "::after"];
+
+const PSEUDO_SELECTOR_REGEXP = /(.*?)(::before|::after)\s*$/i;
+
+interface ParsedCaptureSelector {
+ elementSelector: string;
+ pseudoElement: PseudoElementSelector | null;
+}
+
+export function parseCaptureSelector(selector: string): ParsedCaptureSelector {
+ const match = selector.match(PSEUDO_SELECTOR_REGEXP);
+
+ if (!match) {
+ return { elementSelector: selector, pseudoElement: null };
+ }
+
+ const elementSelector = match[1].trim();
+
+ if (!elementSelector) {
+ return { elementSelector: selector, pseudoElement: null };
+ }
+
+ return {
+ elementSelector,
+ pseudoElement: match[2].toLowerCase() as PseudoElementSelector
+ };
+}
+
+function getElementBorderWidths(element: Element): { top: number; left: number } {
+ const style = getComputedStyle(element);
+
+ return {
+ top: parseFloat(style.borderTopWidth),
+ left: parseFloat(style.borderLeftWidth)
+ };
+}
+
+function parseLengthOrZero(value: string): number {
+ const parsed = parseFloat(value);
+ return isNaN(parsed) ? 0 : parsed;
+}
+
+function getElementContentBottom(element: Element): number | null {
+ const range = document.createRange();
+
+ try {
+ range.selectNodeContents(element);
+ const rects = range.getClientRects();
+ let bottom = -Infinity;
+
+ for (let i = 0; i < rects.length; i++) {
+ const rect = rects[i];
+ if (rect.width < 0.0001 && rect.height < 0.0001) {
+ continue;
+ }
+ bottom = Math.max(bottom, rect.bottom);
+ }
+
+ return bottom !== -Infinity ? bottom : null;
+ } catch {
+ return null;
+ } finally {
+ if (typeof range.detach === "function") {
+ range.detach();
+ }
+ }
+}
+
+function getContainingBlockPaddingEdge(element: Element): { top: number; left: number } | null {
+ let current: Element | null = element.parentElement;
+
+ while (current) {
+ const pos = getComputedStyle(current).position;
+ if (pos !== "static") {
+ const bcr = current.getBoundingClientRect();
+ const borders = getElementBorderWidths(current);
+ return {
+ top: bcr.top + borders.top,
+ left: bcr.left + borders.left
+ };
+ }
+ current = current.parentElement;
+ }
+
+ return null; // initial containing block = viewport
+}
+
+function parseTransformMatrix(transform: string): TransformMatrix | null {
+ if (!transform || transform === "none") return null;
+
+ const match2d = transform.match(/matrix\(([^)]+)\)/);
+ if (match2d) {
+ const v = match2d[1].split(",").map(s => parseFloat(s.trim()));
+ return { a: v[0], b: v[1], c: v[2], d: v[3], tx: v[4], ty: v[5] };
+ }
+
+ const match3d = transform.match(/matrix3d\(([^)]+)\)/);
+ if (match3d) {
+ const v = match3d[1].split(",").map(s => parseFloat(s.trim()));
+ return { a: v[0], b: v[1], c: v[4], d: v[5], tx: v[12], ty: v[13] };
+ }
+
+ return null;
+}
+
+function resolveTransformOrigin(originStr: string, width: number, height: number): { x: number; y: number } {
+ const parts = originStr.split(/\s+/);
+
+ function resolve(val: string, size: number): number {
+ if (val.slice(-1) === "%") {
+ return (parseFloat(val) / 100) * size;
+ }
+ return parseFloat(val) || 0;
+ }
+
+ return {
+ x: resolve(parts[0], width),
+ y: resolve(parts[1] || parts[0], height)
+ };
+}
+
+function applyTransformToRect(rect: Rect<"viewport", "css">, css: CSSStyleDeclaration): Rect<"viewport", "css"> {
+ const matrix = parseTransformMatrix(css.transform);
+ if (!matrix) return rect;
+
+ const transformOrigin = resolveTransformOrigin(css.transformOrigin, rect.width, rect.height);
+ const originX = rect.left + transformOrigin.x;
+ const originY = rect.top + transformOrigin.y;
+
+ const corners = [
+ [rect.left, rect.top],
+ [rect.left + rect.width, rect.top],
+ [rect.left, rect.top + rect.height],
+ [rect.left + rect.width, rect.top + rect.height]
+ ];
+
+ let minX = Infinity,
+ minY = Infinity,
+ maxX = -Infinity,
+ maxY = -Infinity;
+ for (const [cornerX, cornerY] of corners) {
+ const relativeX = cornerX - originX,
+ relativeY = cornerY - originY;
+ const transformedX = matrix.a * relativeX + matrix.c * relativeY + matrix.tx + originX;
+ const transformedY = matrix.b * relativeX + matrix.d * relativeY + matrix.ty + originY;
+ minX = Math.min(minX, transformedX);
+ maxX = Math.max(maxX, transformedX);
+ minY = Math.min(minY, transformedY);
+ maxY = Math.max(maxY, transformedY);
+ }
+
+ return {
+ left: minX as Coord<"viewport", "css", "x">,
+ top: minY as Coord<"viewport", "css", "y">,
+ width: (maxX - minX) as Length<"css", "x">,
+ height: (maxY - minY) as Length<"css", "y">
+ };
+}
+
+function computeBaseRect(
+ element: Element,
+ pseudo: PseudoElementSelector,
+ css: CSSStyleDeclaration,
+ elementRect: Rect<"viewport", "css">
+): Rect<"viewport", "css"> | null {
+ const width = parseFloat(css.width);
+ const height = parseFloat(css.height);
+
+ if (isNaN(width) || isNaN(height) || width < 0.0001 || height < 0.0001) {
+ return null;
+ }
+
+ const position = css.position;
+
+ if (position === "fixed") {
+ const top = parseFloat(css.top);
+ const left = parseFloat(css.left);
+
+ return {
+ top: (isNaN(top) ? 0 : top) as Coord<"viewport", "css", "y">,
+ left: (isNaN(left) ? 0 : left) as Coord<"viewport", "css", "x">,
+ width: width as Length<"css", "x">,
+ height: height as Length<"css", "y">
+ };
+ }
+
+ if (position === "absolute") {
+ const elementCss = getComputedStyle(element);
+ let originTop: number;
+ let originLeft: number;
+
+ if (elementCss.position !== "static") {
+ const borders = getElementBorderWidths(element);
+ originTop = (elementRect.top as number) + borders.top;
+ originLeft = (elementRect.left as number) + borders.left;
+ } else {
+ const containingBlock = getContainingBlockPaddingEdge(element);
+ originTop = containingBlock ? containingBlock.top : 0;
+ originLeft = containingBlock ? containingBlock.left : 0;
+ }
+
+ const top = parseFloat(css.top);
+ const left = parseFloat(css.left);
+
+ return {
+ top: (originTop + (isNaN(top) ? 0 : top)) as Coord<"viewport", "css", "y">,
+ left: (originLeft + (isNaN(left) ? 0 : left)) as Coord<"viewport", "css", "x">,
+ width: width as Length<"css", "x">,
+ height: height as Length<"css", "y">
+ };
+ }
+
+ const elementCss = getComputedStyle(element);
+ const borderTop = parseLengthOrZero(elementCss.borderTopWidth);
+ const borderLeft = parseLengthOrZero(elementCss.borderLeftWidth);
+ const paddingTop = parseLengthOrZero(elementCss.paddingTop);
+ const paddingLeft = parseLengthOrZero(elementCss.paddingLeft);
+ const marginTop = parseLengthOrZero(css.marginTop);
+ const marginLeft = parseLengthOrZero(css.marginLeft);
+ const relativeTop = position === "relative" ? parseLengthOrZero(css.top) : 0;
+ const relativeLeft = position === "relative" ? parseLengthOrZero(css.left) : 0;
+ let flowTop = (elementRect.top as number) + borderTop + paddingTop;
+
+ if (pseudo === "::after" && css.display === "block") {
+ const contentBottom = getElementContentBottom(element);
+ if (contentBottom !== null) {
+ flowTop = Math.max(flowTop, contentBottom);
+ }
+ }
+
+ // In-flow pseudo-element: anchor at the element content box and apply pseudo margins
+ return {
+ top: (flowTop + marginTop + relativeTop) as Coord<"viewport", "css", "y">,
+ left: ((elementRect.left as number) + borderLeft + paddingLeft + marginLeft + relativeLeft) as Coord<
+ "viewport",
+ "css",
+ "x"
+ >,
+ width: width as Length<"css", "x">,
+ height: height as Length<"css", "y">
+ };
+}
+
+export function getPseudoElementRect(
+ element: Element,
+ pseudo: "::before" | "::after",
+ elementRect: Rect<"viewport", "css">
+): Rect<"viewport", "css"> | null {
+ const css = getComputedStyle(element, pseudo);
+
+ if (css.content === "none" || css.display === "none" || css.visibility === "hidden") {
+ return null;
+ }
+
+ const baseRect = computeBaseRect(element, pseudo, css, elementRect);
+ if (!baseRect) return null;
+
+ return applyTransformToRect(baseRect, css);
+}
diff --git a/src/browser/client-scripts/screen-shooter/utils/scroll.ts b/src/browser/client-scripts/screen-shooter/utils/scroll.ts
new file mode 100644
index 000000000..7100466d3
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/utils/scroll.ts
@@ -0,0 +1,172 @@
+import { Coord } from "../../../isomorphic/geometry";
+import { getParentElement } from "./dom";
+import { isSafariMobile } from "./user-agent";
+
+const PSEUDO_SELECTOR_REGEXP = /(.*?)(::before|::after)\s*$/i;
+
+function getElementSelector(selector: string): string {
+ const match = selector.match(PSEUDO_SELECTOR_REGEXP);
+
+ if (!match) {
+ return selector;
+ }
+
+ const elementSelector = match[1].trim();
+
+ return elementSelector || selector;
+}
+
+function isScrollable(element: Element): boolean {
+ const overflowY = getComputedStyle(element).overflowY;
+ return (
+ element.scrollHeight > element.clientHeight &&
+ (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay")
+ );
+}
+
+export function getScrollParent(element: Element): Element | null {
+ if (getComputedStyle(element).position === "fixed") {
+ return null;
+ }
+
+ let current = getParentElement(element);
+
+ while (current && current !== document.documentElement) {
+ if (isScrollable(current)) return current;
+ if (getComputedStyle(current).position === "fixed") return null;
+ current = getParentElement(current);
+ }
+
+ return current === document.documentElement ? document.documentElement : null;
+}
+
+export function getScrollParentsChain(element: Element): Element[] {
+ const chain: Element[] = [];
+ let parent = getScrollParent(element);
+
+ while (parent && parent !== document.documentElement) {
+ chain.unshift(parent);
+ parent = getScrollParent(parent);
+ }
+
+ chain.unshift(document.documentElement);
+ return chain;
+}
+
+export function getCommonScrollParent(selectors: string[]): Element {
+ const elements = selectors
+ .map(s => document.querySelector(getElementSelector(s)))
+ .filter((e): e is NonNullable => e !== null);
+ if (elements.length === 0) return document.documentElement;
+ if (elements.length === 1) {
+ const parent = getScrollParent(elements[0]);
+ return parent ? normalizeRootLikeElement(parent) : document.documentElement;
+ }
+
+ const chains = elements.map(el => getScrollParentsChain(el));
+ const minLength = Math.min(...chains.map(c => c.length));
+
+ let common: Element = document.documentElement;
+ for (let i = 0; i < minLength; i++) {
+ if (chains.every(chain => chain[i] === chains[0][i])) {
+ common = chains[0][i];
+ } else {
+ break;
+ }
+ }
+
+ return normalizeRootLikeElement(common);
+}
+
+function getElementScrollTop(element: Element): number {
+ return isRootLikeElement(element) ? window.scrollY : element.scrollTop;
+}
+
+const SCROLL_APPLY_MAX_WAIT_MS = 50;
+const SCROLL_APPLY_MAX_ITERATIONS = 10000;
+
+function getPageScrollElement(): Element {
+ return document.scrollingElement ?? document.documentElement;
+}
+
+export function isRootLikeElement(element: Element): boolean {
+ const pageScrollElement = getPageScrollElement();
+ return element === document.documentElement || element === document.body || element === pageScrollElement;
+}
+
+function normalizeRootLikeElement(element: Element): Element {
+ return isRootLikeElement(element) ? document.documentElement : element;
+}
+
+/**
+ * iOS Safari has quirks when calling window.scrollTo near page top
+ * @see https://gist.github.com/shadowusr/da03a7d66059c44baeb698db2d4e8658
+ */
+export function performScrollFixForSafariIfNeeded(targetY: number): void {
+ if (!isSafariMobile()) {
+ return;
+ }
+ if (window.scrollY < 100 && targetY < 100) {
+ window.scrollTo(window.scrollX, 100);
+ }
+}
+
+export function scrollElementBy(
+ element: Element,
+ deltaY: Coord<"page", "css", "y">,
+ logger?: (...args: unknown[]) => unknown
+): void {
+ const delta = deltaY;
+ const isRootLike = isRootLikeElement(element);
+ const scrollMetricsElement = isRootLike ? getPageScrollElement() : element;
+ const currentScrollY = isRootLike ? window.scrollY : element.scrollTop;
+
+ // Clamping is needed due to a bug in safari - https://bugs.webkit.org/show_bug.cgi?id=179735
+ const maxScrollY = scrollMetricsElement.scrollHeight - scrollMetricsElement.clientHeight;
+ const targetY = Math.max(0, Math.min(currentScrollY + delta, maxScrollY));
+
+ if (isRootLike) {
+ logger?.("scrollElementBy: scrolling window.scrollTo(" + window.scrollX + ", " + targetY + ")");
+
+ performScrollFixForSafariIfNeeded(targetY);
+ window.scrollTo(window.scrollX, targetY);
+ } else {
+ logger?.("scrollElementBy: scrolling element.scrollTo(" + element.scrollLeft + ", " + targetY + ")");
+ element.scrollTo(element.scrollLeft, targetY);
+ }
+
+ const startedAt = performance.now();
+ let iterations = 0;
+ while (performance.now() - startedAt < SCROLL_APPLY_MAX_WAIT_MS && iterations < SCROLL_APPLY_MAX_ITERATIONS) {
+ if (getElementScrollTop(element) !== currentScrollY) {
+ return;
+ }
+ iterations++;
+ }
+}
+
+export function scrollElementToOffset(element: Element, offset: Coord<"page", "css", "y">): void {
+ const isRootLike = isRootLikeElement(element);
+ const scrollMetricsElement = isRootLike ? getPageScrollElement() : element;
+ const currentScrollY = isRootLike ? window.scrollY : element.scrollTop;
+
+ // Clamping is needed due to a bug in safari - https://bugs.webkit.org/show_bug.cgi?id=179735
+ const maxScrollY = scrollMetricsElement.scrollHeight - scrollMetricsElement.clientHeight;
+ const targetY = Math.max(0, Math.min(offset, maxScrollY));
+
+ if (isRootLike) {
+ performScrollFixForSafariIfNeeded(targetY);
+ window.scrollTo(window.scrollX, targetY);
+ } else {
+ element.scrollTo(element.scrollLeft, targetY);
+ }
+
+ const startedAt = performance.now();
+ let iterations = 0;
+ while (performance.now() - startedAt < SCROLL_APPLY_MAX_WAIT_MS && iterations < SCROLL_APPLY_MAX_ITERATIONS) {
+ if (getElementScrollTop(element) === currentScrollY) {
+ return;
+ }
+ iterations++;
+ }
+}
diff --git a/src/browser/client-scripts/screen-shooter/utils/trusted-types.ts b/src/browser/client-scripts/screen-shooter/utils/trusted-types.ts
new file mode 100644
index 000000000..52b376f50
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/utils/trusted-types.ts
@@ -0,0 +1,19 @@
+declare global {
+ // eslint-disable-next-line no-var
+ var trustedTypes:
+ | {
+ createPolicy(name: string, rules: { createHTML: (string: string) => string }): void;
+ }
+ | undefined;
+}
+
+export function createDefaultTrustedTypesPolicy(): void {
+ const w = window;
+ if (w.trustedTypes && w.trustedTypes.createPolicy) {
+ w.trustedTypes.createPolicy("default", {
+ createHTML: function (string: string) {
+ return string;
+ }
+ });
+ }
+}
diff --git a/src/browser/client-scripts/screen-shooter/utils/user-agent.ts b/src/browser/client-scripts/screen-shooter/utils/user-agent.ts
new file mode 100644
index 000000000..38012cd72
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/utils/user-agent.ts
@@ -0,0 +1,8 @@
+export function isSafariMobile(): boolean {
+ return Boolean(
+ navigator &&
+ typeof navigator.vendor === "string" &&
+ navigator.vendor.match(/apple/i) &&
+ /(iPhone|iPad).*AppleWebKit.*Safari/i.test(navigator.userAgent)
+ );
+}
diff --git a/src/browser/client-scripts/screen-shooter/utils/z-index.ts b/src/browser/client-scripts/screen-shooter/utils/z-index.ts
new file mode 100644
index 000000000..7a8080350
--- /dev/null
+++ b/src/browser/client-scripts/screen-shooter/utils/z-index.ts
@@ -0,0 +1,158 @@
+import { getParentElement } from "./dom";
+
+function getCssProp(style: CSSStyleDeclaration, prop: string): string | undefined {
+ return (style as unknown as Record)[prop];
+}
+
+function hasCssProp(style: CSSStyleDeclaration, prop: string, defaultValue = "none"): boolean {
+ const value = getCssProp(style, prop);
+ return value !== undefined && value !== defaultValue;
+}
+
+function isFlexContainer(style: CSSStyleDeclaration): boolean {
+ return style.display === "flex" || style.display === "inline-flex";
+}
+
+function isGridContainer(style: CSSStyleDeclaration): boolean {
+ return (style.display || "").indexOf("grid") !== -1;
+}
+
+function hasContainStackingContext(contain: string): boolean {
+ return (
+ contain === "layout" ||
+ contain === "paint" ||
+ contain === "strict" ||
+ contain === "content" ||
+ contain.indexOf("paint") !== -1 ||
+ contain.indexOf("layout") !== -1
+ );
+}
+
+function createsStackingContext(element: Element): boolean {
+ const style = getComputedStyle(element);
+ const position = style.position;
+ const zIndex = style.zIndex;
+
+ if (position === "fixed" || position === "sticky") return true;
+ if (getCssProp(style, "containerType") === "size" || getCssProp(style, "containerType") === "inline-size")
+ return true;
+ if (zIndex !== "auto" && position !== "static") return true;
+ if (parseFloat(style.opacity) < 1) return true;
+ if (style.transform !== "none") return true;
+ if (hasCssProp(style, "scale")) return true;
+ if (hasCssProp(style, "rotate")) return true;
+ if (hasCssProp(style, "translate")) return true;
+ if (style.mixBlendMode !== "normal") return true;
+ if (style.filter !== "none") return true;
+ if (hasCssProp(style, "backdropFilter")) return true;
+ if (hasCssProp(style, "webkitBackdropFilter")) return true;
+ if (style.perspective !== "none") return true;
+ if (hasCssProp(style, "clipPath")) return true;
+
+ const mask = getCssProp(style, "mask") || getCssProp(style, "webkitMask");
+ if (mask !== undefined && mask !== "none") return true;
+
+ const maskImage = getCssProp(style, "maskImage") || getCssProp(style, "webkitMaskImage");
+ if (maskImage !== undefined && maskImage !== "none") return true;
+
+ const maskBorder = getCssProp(style, "maskBorder") || getCssProp(style, "webkitMaskBorder");
+ if (maskBorder !== undefined && maskBorder !== "none") return true;
+
+ if (style.isolation === "isolate") return true;
+
+ const willChange = style.willChange || "";
+ if (willChange.indexOf("transform") !== -1 || willChange.indexOf("opacity") !== -1) return true;
+
+ if (getCssProp(style, "webkitOverflowScrolling") === "touch") return true;
+
+ if (zIndex !== "auto") {
+ const parent = getParentElement(element);
+ if (parent) {
+ const parentStyle = getComputedStyle(parent);
+ if (isFlexContainer(parentStyle) || isGridContainer(parentStyle)) return true;
+ }
+ }
+
+ if (hasContainStackingContext(style.contain || "")) return true;
+
+ return false;
+}
+
+function getClosestStackingContext(node: Node | null): Element {
+ if (!node || node === document.documentElement) {
+ return document.documentElement;
+ }
+
+ if (node instanceof ShadowRoot) {
+ return getClosestStackingContext(node.host);
+ }
+
+ if (!(node instanceof Element)) {
+ return getClosestStackingContext(node.parentNode);
+ }
+
+ if (createsStackingContext(node)) {
+ return node;
+ }
+
+ return getClosestStackingContext(getParentElement(node));
+}
+
+function getStackingContextRoot(element: Element): Element {
+ return getClosestStackingContext(getParentElement(element));
+}
+
+function getEffectiveZIndex(element: Element): number {
+ let curr: Element | null = element;
+
+ while (curr && curr !== document.documentElement) {
+ const style = getComputedStyle(curr);
+
+ if (style.zIndex !== "auto") {
+ const num = parseFloat(style.zIndex);
+ return isNaN(num) ? 0 : num;
+ }
+
+ if (createsStackingContext(curr)) {
+ return 0;
+ }
+
+ curr = curr.parentElement;
+ }
+
+ return 0;
+}
+
+interface ZChainItem {
+ ctx: Element;
+ z: number;
+}
+
+export function buildZChain(element: Element): ZChainItem[] {
+ const chain: ZChainItem[] = [];
+ let curr: Element | null = element;
+
+ while (curr && curr !== document.documentElement) {
+ const ctx = getStackingContextRoot(curr);
+ const z = getEffectiveZIndex(curr);
+
+ chain.unshift({ ctx, z });
+
+ if (ctx === document.documentElement) break;
+ curr = ctx;
+ }
+
+ return chain;
+}
+
+export function isChainBehind(candChain: ZChainItem[], targetChain: ZChainItem[]): boolean {
+ for (let j = targetChain.length - 1; j >= 0; j--) {
+ for (let i = candChain.length - 1; i >= 0; i--) {
+ if (candChain[i].ctx === targetChain[j].ctx) {
+ return candChain[i].z < targetChain[j].z;
+ }
+ }
+ }
+
+ return false;
+}
diff --git a/src/browser/client-scripts/lib.compat.js b/src/browser/client-scripts/shared/lib.compat.ts
similarity index 54%
rename from src/browser/client-scripts/lib.compat.js
rename to src/browser/client-scripts/shared/lib.compat.ts
index 4e39a4f68..a240d7807 100644
--- a/src/browser/client-scripts/lib.compat.js
+++ b/src/browser/client-scripts/shared/lib.compat.ts
@@ -1,28 +1,27 @@
-"use strict";
-/*jshint newcap:false*/
-var Sizzle = require("sizzle");
-var xpath = require("./xpath");
+///
+import Sizzle from "sizzle";
+import * as xpath from "./xpath";
-exports.queryFirst = function (selector) {
+export function queryFirst(selector: string): Element | null {
if (xpath.isXpathSelector(selector)) {
return xpath.queryFirst(selector);
}
- var elems = Sizzle(exports.trim(selector) + ":first");
+ const elems = Sizzle(trim(selector) + ":first");
return elems.length > 0 ? elems[0] : null;
-};
+}
-exports.queryAll = function (selector) {
+export function queryAll(selector: string): Element[] {
if (xpath.isXpathSelector(selector)) {
return xpath.queryAll(selector);
}
return Sizzle(selector);
-};
+}
-exports.trim = function (str) {
+export function trim(str: string): string {
// trim spaces, unicode BOM and NBSP and the beginning and the end of the line
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim#Polyfill
return str.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, "");
-};
+}
-exports.getComputedStyle = require("./polyfills/getComputedStyle").getComputedStyle;
-exports.matchMedia = require("./polyfills/matchMedia").matchMedia;
+export { getComputedStyle } from "./polyfills/getComputedStyle";
+export { matchMedia } from "./polyfills/matchMedia";
diff --git a/src/browser/client-scripts/shared/lib.native.ts b/src/browser/client-scripts/shared/lib.native.ts
new file mode 100644
index 000000000..a3c259ebe
--- /dev/null
+++ b/src/browser/client-scripts/shared/lib.native.ts
@@ -0,0 +1,27 @@
+import * as xpath from "./xpath";
+
+export function queryFirst(selector: string): Element | null {
+ if (xpath.isXpathSelector(selector)) {
+ return xpath.queryFirst(selector);
+ }
+ return document.querySelector(selector);
+}
+
+export function queryAll(selector: string): Element[] {
+ if (xpath.isXpathSelector(selector)) {
+ return xpath.queryAll(selector);
+ }
+ return Array.from(document.querySelectorAll(selector));
+}
+
+export function getComputedStyle(element: Element, pseudoElement: string): CSSStyleDeclaration {
+ return getComputedStyle(element, pseudoElement);
+}
+
+export function matchMedia(mediaQuery: string): MediaQueryList {
+ return matchMedia(mediaQuery);
+}
+
+export function trim(str: string): string {
+ return str.trim();
+}
diff --git a/src/browser/client-scripts/shared/logger.ts b/src/browser/client-scripts/shared/logger.ts
new file mode 100644
index 000000000..f5c3d0a6a
--- /dev/null
+++ b/src/browser/client-scripts/shared/logger.ts
@@ -0,0 +1,49 @@
+interface DebugOpts {
+ debug?: string[];
+}
+
+interface DebugLogger {
+ (...args: unknown[]): string;
+ log: string;
+ enabled: boolean;
+}
+
+interface CreateDebugLoggerFn {
+ (opts: DebugOpts, debugTopic: string): DebugLogger;
+}
+
+function makeCreateDebugLogger(): CreateDebugLoggerFn {
+ const fn = function (opts: DebugOpts, debugTopic: string): DebugLogger {
+ // fn.log = "";
+
+ if (opts.debug && opts.debug.indexOf(debugTopic) !== -1) {
+ const enabledLogger = function (...args: unknown[]): string {
+ for (const arg of args) {
+ if (typeof arg === "object" && arg !== null) {
+ try {
+ enabledLogger.log += JSON.stringify(arg, null, 2) + "\n";
+ } catch (e) {
+ enabledLogger.log += "failed to log message due to an error: " + e;
+ }
+ } else {
+ enabledLogger.log += String(arg) + "\n";
+ }
+ }
+ return enabledLogger.log;
+ } as DebugLogger;
+ enabledLogger.enabled = true;
+ enabledLogger.log = "";
+ return enabledLogger;
+ }
+
+ const disabledLogger = function (): string {
+ return "";
+ } as DebugLogger;
+ disabledLogger.enabled = false;
+ return disabledLogger;
+ } as CreateDebugLoggerFn;
+ // fn.log = "";
+ return fn;
+}
+
+export const createDebugLogger: CreateDebugLoggerFn = makeCreateDebugLogger();
diff --git a/src/browser/client-scripts/polyfills/LICENSE.md b/src/browser/client-scripts/shared/polyfills/LICENSE.md
similarity index 100%
rename from src/browser/client-scripts/polyfills/LICENSE.md
rename to src/browser/client-scripts/shared/polyfills/LICENSE.md
diff --git a/src/browser/client-scripts/polyfills/getComputedStyle.js b/src/browser/client-scripts/shared/polyfills/getComputedStyle.ts
similarity index 54%
rename from src/browser/client-scripts/polyfills/getComputedStyle.js
rename to src/browser/client-scripts/shared/polyfills/getComputedStyle.ts
index 984ef3ad9..e97d7357a 100644
--- a/src/browser/client-scripts/polyfills/getComputedStyle.js
+++ b/src/browser/client-scripts/shared/polyfills/getComputedStyle.ts
@@ -1,31 +1,29 @@
/**
* Adapted from: https://raw.githubusercontent.com/Financial-Times/polyfill-service
*/
-function getComputedStylePixel(element, property, fontSize) {
- var // Internet Explorer sometimes struggles to read currentStyle until the element's document is accessed.
- value = (element.document && element.currentStyle[property].match(/([\d.]+)(%|cm|em|in|mm|pc|pt|)/)) || [
- 0,
- 0,
- ""
- ],
+function getComputedStylePixel(element: Element, property: string, fontSize?: number | null): number {
+ const // Internet Explorer sometimes struggles to read currentStyle until the element's document is accessed.
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ value = ((element as any).document &&
+ (element as any).currentStyle[property].match(/([\d.]+)(%|cm|em|in|mm|pc|pt|)/)) || [0, 0, ""],
+ /* eslint-enable @typescript-eslint/no-explicit-any */
size = value[1],
- suffix = value[2],
- rootSize;
+ suffix = value[2];
fontSize = !fontSize
? fontSize
: /%|em/.test(suffix) && element.parentElement
? getComputedStylePixel(element.parentElement, "fontSize", null)
: 16;
- rootSize =
+ const rootSize =
property === "fontSize" ? fontSize : /width/i.test(property) ? element.clientWidth : element.clientHeight;
return suffix === "%"
- ? (size / 100) * rootSize
+ ? (size / 100) * (rootSize as number)
: suffix === "cm"
? size * 0.3937 * 96
: suffix === "em"
- ? size * fontSize
+ ? size * (fontSize as number)
: suffix === "in"
? size * 96
: suffix === "mm"
@@ -37,13 +35,14 @@ function getComputedStylePixel(element, property, fontSize) {
: size;
}
-function setShortStyleProperty(style, property) {
- var borderSuffix = property === "border" ? "Width" : "",
- t = property + "Top" + borderSuffix,
- r = property + "Right" + borderSuffix,
- b = property + "Bottom" + borderSuffix,
- l = property + "Left" + borderSuffix;
+function setShortStyleProperty(style: CSSStyleDeclaration, property: string & keyof CSSStyleDeclaration): void {
+ const borderSuffix = property === "border" ? "Width" : "",
+ t = (property + "Top" + borderSuffix) as keyof CSSStyleDeclaration,
+ r = (property + "Right" + borderSuffix) as keyof CSSStyleDeclaration,
+ b = (property + "Bottom" + borderSuffix) as keyof CSSStyleDeclaration,
+ l = (property + "Left" + borderSuffix) as keyof CSSStyleDeclaration;
+ // @ts-expect-error This is a polyfill, we need manual overrides here
style[property] = (
style[t] === style[r] && style[t] === style[b] && style[t] === style[l]
? [style[t]]
@@ -56,28 +55,34 @@ function setShortStyleProperty(style, property) {
}
//
-function CSSStyleDeclaration(element) {
- var currentStyle = element.currentStyle,
+function CSSStyleDeclaration(this: CSSStyleDeclaration, element: Element): void {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const currentStyle = (element as any).currentStyle,
fontSize = getComputedStylePixel(element, "fontSize"),
- unCamelCase = function (match) {
+ unCamelCase = function (match: string): string {
return "-" + match.toLowerCase();
- },
- property;
+ };
+ let property: string;
for (property in currentStyle) {
Array.prototype.push.call(this, property === "styleFloat" ? "float" : property.replace(/[A-Z]/, unCamelCase));
if (property === "width") {
- this[property] = element.offsetWidth + "px";
+ this[property] = (element as HTMLElement).offsetWidth + "px";
} else if (property === "height") {
- this[property] = element.offsetHeight + "px";
+ this[property] = (element as HTMLElement).offsetHeight + "px";
} else if (property === "styleFloat") {
this.float = currentStyle[property];
- } else if (/margin.|padding.|border.+W/.test(property) && this[property] !== "auto") {
+ } else if (
+ /margin.|padding.|border.+W/.test(property) &&
+ this[property as keyof CSSStyleDeclaration] !== "auto"
+ ) {
+ // @ts-expect-error This is a polyfill, we need manual overrides here
this[property] = Math.round(getComputedStylePixel(element, property, fontSize)) + "px";
} else if (/^outline/.test(property)) {
// errors on checking outline
try {
+ // @ts-expect-error This is a polyfill, we need manual overrides here
this[property] = currentStyle[property];
} catch (error) {
this.outlineColor = currentStyle.color;
@@ -86,6 +91,7 @@ function CSSStyleDeclaration(element) {
this.outline = [this.outlineColor, this.outlineWidth, this.outlineStyle].join(" ");
}
} else {
+ // @ts-expect-error This is a polyfill, we need manual overrides here
this[property] = currentStyle[property];
}
}
@@ -100,39 +106,43 @@ function CSSStyleDeclaration(element) {
CSSStyleDeclaration.prototype = {
constructor: CSSStyleDeclaration,
// .getPropertyPriority
- getPropertyPriority: function () {
+ getPropertyPriority: function (): never {
throw new Error("NotSupportedError: DOM Exception 9");
},
// .getPropertyValue
- getPropertyValue: function (property) {
+ getPropertyValue: function (property: string): string {
return this[
- property.replace(/-\w/g, function (match) {
+ property.replace(/-\w/g, function (match: string): string {
return match[1].toUpperCase();
})
];
},
// .item
- item: function (index) {
+ item: function (index: number): string {
return this[index];
},
// .removeProperty
- removeProperty: function () {
+ removeProperty: function (): never {
throw new Error("NoModificationAllowedError: DOM Exception 7");
},
// .setProperty
- setProperty: function () {
+ setProperty: function (): never {
throw new Error("NoModificationAllowedError: DOM Exception 7");
},
// .getPropertyCSSValue
- getPropertyCSSValue: function () {
+ getPropertyCSSValue: function (): never {
throw new Error("NotSupportedError: DOM Exception 9");
}
};
-exports.CSSStyleDeclaration = CSSStyleDeclaration;
+export { CSSStyleDeclaration };
// .getComputedStyle
-exports.getComputedStyle = function getComputedStyle(element, pseudoEl) {
+export function getComputedStyle(element: Element, pseudoEl: string): CSSStyleDeclaration {
// IE9 needs matchMedia support but already support getComputedStyle
- return window.getComputedStyle ? window.getComputedStyle(element, pseudoEl) : new CSSStyleDeclaration(element);
-};
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ return window.getComputedStyle
+ ? window.getComputedStyle(element, pseudoEl)
+ : new (CSSStyleDeclaration as any)(element);
+ /* eslint-enable @typescript-eslint/no-explicit-any */
+}
diff --git a/src/browser/client-scripts/polyfills/matchMedia.js b/src/browser/client-scripts/shared/polyfills/matchMedia.ts
similarity index 58%
rename from src/browser/client-scripts/polyfills/matchMedia.js
rename to src/browser/client-scripts/shared/polyfills/matchMedia.ts
index fe5c9ffb5..7a7f2872f 100644
--- a/src/browser/client-scripts/polyfills/matchMedia.js
+++ b/src/browser/client-scripts/shared/polyfills/matchMedia.ts
@@ -1,8 +1,7 @@
/**
* Adapted from: https://raw.githubusercontent.com/Financial-Times/polyfill-service
*/
-function evalQuery(query) {
- /* jshint evil: true */
+function evalQuery(query: string): boolean {
query = (query || "true")
.replace(/^only\s+/, "")
.replace(/(device)-([\w.]+)/g, "$1.$2")
@@ -14,9 +13,9 @@ function evalQuery(query) {
.replace(/,/g, "||")
.replace(/\band\b/g, "&&")
.replace(/dpi/g, "")
- .replace(/(\d+)(cm|em|in|dppx|mm|pc|pt|px|rem)/g, function ($0, $1, $2) {
+ .replace(/(\d+)(cm|em|in|dppx|mm|pc|pt|px|rem)/g, function ($0: string, $1: string, $2: string): string {
return (
- $1 *
+ parseFloat($1) *
($2 === "cm"
? 0.3937 * 96
: $2 === "em" || $2 === "rem"
@@ -30,38 +29,48 @@ function evalQuery(query) {
: $2 === "pt"
? 96 / 72
: 1)
- );
+ ).toString();
});
+ // @ts-expect-error global might be present in old browsers
+ const globalObj = window || globalThis || global;
return new Function("media", "try{ return !!(%s) }catch(e){ return false }".replace("%s", query))({
- width: global.innerWidth,
- height: global.innerHeight,
- orientation: global.orientation || "landscape",
+ width: globalObj.innerWidth,
+ height: globalObj.innerHeight,
+ orientation: globalObj.orientation || "landscape",
device: {
- width: global.screen.width,
- height: global.screen.height,
- orientation: global.screen.orientation || global.orientation || "landscape"
+ width: globalObj.screen.width,
+ height: globalObj.screen.height,
+ orientation: globalObj.screen.orientation || globalObj.orientation || "landscape"
}
});
}
-function MediaQueryList() {
+interface MediaQueryListPolyfill {
+ matches: boolean;
+ media: string;
+ addListener: (listener: () => void) => void;
+ removeListener: (listener: () => void) => void;
+}
+
+function MediaQueryList(this: MediaQueryListPolyfill): void {
this.matches = false;
this.media = "invalid";
}
-MediaQueryList.prototype.addListener = function addListener(listener) {
+MediaQueryList.prototype.addListener = function addListener(listener: () => void): void {
this.addListener.listeners.push(listener);
};
-MediaQueryList.prototype.removeListener = function removeListener(listener) {
+MediaQueryList.prototype.removeListener = function removeListener(listener: () => void): void {
this.addListener.listeners.splice(this.addListener.listeners.indexOf(listener), 1);
};
-exports.MediaQueryList = MediaQueryList;
+export { MediaQueryList };
// .matchMedia
-exports.matchMedia = function matchMedia(query) {
- var list = new MediaQueryList();
+export function matchMedia(query: string): MediaQueryList {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const list = new (MediaQueryList as any)();
if (arguments.length === 0) {
throw new TypeError("Not enough arguments to matchMedia");
@@ -71,17 +80,20 @@ exports.matchMedia = function matchMedia(query) {
list.matches = evalQuery(list.media);
list.addListener.listeners = [];
+ // @ts-expect-error global might be present in old browsers
+ const globalObj = window || globalThis || global;
+
window.addEventListener("resize", function () {
- var listeners = [].concat(list.addListener.listeners),
+ const listeners = [].concat(list.addListener.listeners),
matches = evalQuery(list.media);
if (matches !== list.matches) {
list.matches = matches;
- for (var index = 0, length = listeners.length; index < length; ++index) {
- listeners[index].call(global, list);
+ for (let index = 0, length = listeners.length; index < length; ++index) {
+ (listeners[index] as (...args: unknown[]) => void).call(globalObj, list);
}
}
});
return list;
-};
+}
diff --git a/src/browser/client-scripts/shared/sizzle.d.ts b/src/browser/client-scripts/shared/sizzle.d.ts
new file mode 100644
index 000000000..4d05accb8
--- /dev/null
+++ b/src/browser/client-scripts/shared/sizzle.d.ts
@@ -0,0 +1,3 @@
+declare module "sizzle" {
+ export default function (selector: string): Element[];
+}
diff --git a/src/browser/client-scripts/shared/xpath.ts b/src/browser/client-scripts/shared/xpath.ts
new file mode 100644
index 000000000..2acb02d04
--- /dev/null
+++ b/src/browser/client-scripts/shared/xpath.ts
@@ -0,0 +1,26 @@
+const XPATH_SELECTORS_START = ["/", "(", "../", "./", "*/"];
+
+export function isXpathSelector(selector: string): boolean {
+ return XPATH_SELECTORS_START.some(function (startString) {
+ return selector.indexOf(startString) === 0;
+ });
+}
+
+export function queryFirst(selector: string): Element | null {
+ return document.evaluate(selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)
+ .singleNodeValue as Element | null;
+}
+
+export function queryAll(selector: string): Element[] {
+ const elements = document.evaluate(selector, document, null, XPathResult.ANY_TYPE, null);
+ let node: Node | null;
+ const nodes: Element[] = [];
+ node = elements.iterateNext();
+
+ while (node) {
+ nodes.push(node as Element);
+ node = elements.iterateNext();
+ }
+
+ return nodes;
+}
diff --git a/src/browser/client-scripts/tsconfig.compat.common.json b/src/browser/client-scripts/tsconfig.compat.common.json
new file mode 100644
index 000000000..d9c977472
--- /dev/null
+++ b/src/browser/client-scripts/tsconfig.compat.common.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "module": "CommonJS",
+ "composite": true,
+ "target": "ES3",
+ "lib": ["es5", "dom"],
+ "moduleResolution": "node",
+ "rootDir": "..",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "ignoreDeprecations": "5.0", // TODO: latest versions of TypeScript don't support ES3. We should migrate to babel build instead of emitting ES3 code!
+ "types": [],
+ "paths": {
+ "@lib": ["./shared/lib.compat.ts"],
+ "@isomorphic": ["../isomorphic/index.ts"]
+ }
+ },
+ "references": [
+ {
+ "path": "../isomorphic/tsconfig.json"
+ }
+ ]
+}
diff --git a/src/browser/client-scripts/tsconfig.native.common.json b/src/browser/client-scripts/tsconfig.native.common.json
new file mode 100644
index 000000000..e9f339003
--- /dev/null
+++ b/src/browser/client-scripts/tsconfig.native.common.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "module": "CommonJS",
+ "composite": true,
+ "target": "es3",
+ "lib": ["es2020", "dom"],
+ "moduleResolution": "node",
+ "rootDir": "..",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "ignoreDeprecations": "5.0", // TODO: latest versions of TypeScript don't support ES3. We should migrate to babel build instead of emitting ES3 code!
+ "types": [],
+ "paths": {
+ "@lib": ["./shared/lib.native.ts"],
+ "@isomorphic": ["../isomorphic/index.ts"]
+ }
+ },
+ "references": [
+ {
+ "path": "../isomorphic/tsconfig.json"
+ }
+ ]
+}
diff --git a/src/browser/client-scripts/util.js b/src/browser/client-scripts/util.js
deleted file mode 100644
index d34de4560..000000000
--- a/src/browser/client-scripts/util.js
+++ /dev/null
@@ -1,142 +0,0 @@
-"use strict";
-
-var SCROLL_DIR_NAME = {
- top: "scrollTop",
- left: "scrollLeft"
-};
-
-var PAGE_OFFSET_NAME = {
- x: "pageXOffset",
- y: "pageYOffset"
-};
-
-exports.each = arrayUtil(Array.prototype.forEach, myForEach);
-exports.some = arrayUtil(Array.prototype.some, mySome);
-exports.every = arrayUtil(Array.prototype.every, myEvery);
-
-function arrayUtil(nativeFunc, shimFunc) {
- return nativeFunc ? contextify(nativeFunc) : shimFunc;
-}
-
-/**
- * Makes function f to accept context as a
- * first argument
- */
-function contextify(f) {
- return function (ctx) {
- var rest = Array.prototype.slice.call(arguments, 1);
- return f.apply(ctx, rest);
- };
-}
-
-function myForEach(array, cb, context) {
- for (var i = 0; i < array.length; i++) {
- cb.call(context, array[i], i, array);
- }
-}
-
-function mySome(array, cb, context) {
- for (var i = 0; i < array.length; i++) {
- if (cb.call(context, array[i], i, array)) {
- return true;
- }
- }
- return false;
-}
-
-function myEvery(array, cb, context) {
- for (var i = 0; i < array.length; i++) {
- if (!cb.call(context, array[i], i, array)) {
- return false;
- }
- }
- return true;
-}
-
-function getScroll(scrollElem, direction, coord) {
- var scrollDir = SCROLL_DIR_NAME[direction];
- var pageOffset = PAGE_OFFSET_NAME[coord];
-
- if (scrollElem && scrollElem !== window) {
- return scrollElem[scrollDir];
- }
-
- if (typeof window[pageOffset] !== "undefined") {
- return window[pageOffset];
- }
-
- return document.documentElement[scrollDir];
-}
-
-exports.getScrollTop = function (scrollElem) {
- return getScroll(scrollElem, "top", "y");
-};
-
-exports.getScrollLeft = function (scrollElem) {
- return getScroll(scrollElem, "left", "x");
-};
-
-exports.isSafariMobile = function () {
- return (
- navigator &&
- typeof navigator.vendor === "string" &&
- navigator.vendor.match(/apple/i) &&
- /(iPhone|iPad).*AppleWebKit.*Safari/i.test(navigator.userAgent)
- );
-};
-
-exports.isInteger = function (num) {
- return num % 1 === 0;
-};
-
-exports.forEachRoot = function (cb) {
- function traverseRoots(root) {
- cb(root);
-
- // In IE 11, we need to pass the third and fourth arguments
- var treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false);
-
- for (var node = treeWalker.currentNode; node !== null; node = treeWalker.nextNode()) {
- if (node instanceof Element && node.shadowRoot) {
- traverseRoots(node.shadowRoot);
- }
- }
- }
-
- traverseRoots(document.documentElement);
-};
-
-exports.getOwnerWindow = function (node) {
- if (!node.ownerDocument) {
- return null;
- }
-
- return node.ownerDocument.defaultView;
-};
-
-exports.getOwnerIframe = function (node) {
- var nodeWindow = exports.getOwnerWindow(node);
- if (nodeWindow) {
- return nodeWindow.frameElement;
- }
-
- return null;
-};
-
-exports.getMainDocumentElem = function (currDocumentElem) {
- if (!currDocumentElem) {
- currDocumentElem = document.documentElement;
- }
-
- var currIframe = exports.getOwnerIframe(currDocumentElem);
- if (!currIframe) {
- return currDocumentElem;
- }
-
- var currWindow = exports.getOwnerWindow(currIframe);
- if (!currWindow) {
- return currDocumentElem;
- }
-
- return exports.getMainDocumentElem(currWindow.document.documentElement);
-};
diff --git a/src/browser/client-scripts/xpath.js b/src/browser/client-scripts/xpath.js
deleted file mode 100644
index b88ce31f5..000000000
--- a/src/browser/client-scripts/xpath.js
+++ /dev/null
@@ -1,31 +0,0 @@
-"use strict";
-
-var XPATH_SELECTORS_START = ["/", "(", "../", "./", "*/"];
-
-function isXpathSelector(selector) {
- return XPATH_SELECTORS_START.some(function (startString) {
- return selector.indexOf(startString) === 0;
- });
-}
-
-function queryFirst(selector) {
- return document.evaluate(selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
-}
-
-function queryAll(selector) {
- var elements = document.evaluate(selector, document, null, XPathResult.ANY_TYPE, null);
- var node,
- nodes = [];
- node = elements.iterateNext();
- while (node) {
- nodes.push(node);
- node = elements.iterateNext();
- }
- return nodes;
-}
-
-module.exports = {
- isXpathSelector: isXpathSelector,
- queryFirst: queryFirst,
- queryAll: queryAll
-};
diff --git a/src/browser/commands/assert-view/index.js b/src/browser/commands/assert-view/index.js
index 1ce8c78c2..626122bb5 100644
--- a/src/browser/commands/assert-view/index.js
+++ b/src/browser/commands/assert-view/index.js
@@ -2,15 +2,24 @@
const fs = require("fs-extra");
const path = require("path");
+const crypto = require("crypto");
const _ = require("lodash");
+const { pngValidator: validatePng } = require("png-validator");
const { Image } = require("../../../image");
-const ScreenShooter = require("../../screen-shooter");
+const { ElementsScreenShooter } = require("../../screen-shooter/elements-screen-shooter");
+const { ViewportScreenShooter } = require("../../screen-shooter/viewport-screen-shooter");
const temp = require("../../../temp");
const { getCaptureProcessors } = require("./capture-processors");
const RuntimeConfig = require("../../../config/runtime-config");
const AssertViewResults = require("./assert-view-results");
const { BaseStateError } = require("./errors/base-state-error");
const { addTestplaneSelectivityPngDependency } = require("../../cdp/selectivity/testplane-selectivity");
+const { AssertViewError } = require("./errors/assert-view-error");
+
+const makeDebug = require("debug");
+const debug = makeDebug("testplane:screenshots:assert-view");
+
+const getShortDebugId = debugId => crypto.createHash("sha1").update(debugId).digest("hex").slice(0, 7);
const getIgnoreDiffPixelCountRatio = value => {
const percent = _.isString(value) && value.endsWith("%") ? parseFloat(value.slice(0, -1)) : false;
@@ -27,7 +36,18 @@ const getIgnoreDiffPixelCountRatio = value => {
};
module.exports.default = browser => {
- const screenShooter = ScreenShooter.create(browser);
+ const { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib } = browser;
+ const browserProperties = { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib };
+ const elementsScreenShooterPromise = ElementsScreenShooter.create({
+ camera: browser.camera,
+ browser: browser.publicAPI,
+ browserProperties,
+ });
+ const viewportScreenShooterPromise = ViewportScreenShooter.create({
+ camera: browser.camera,
+ browser: browser.publicAPI,
+ browserProperties,
+ });
const { publicAPI: session, config } = browser;
const {
assertViewOpts,
@@ -41,8 +61,8 @@ module.exports.default = browser => {
const { handleNoRefImage, handleImageDiff, handleInvalidRefImage } = getCaptureProcessors();
- const assertView = async (state, selectors, opts) => {
- opts = _.defaults(opts, assertViewOpts, {
+ const getDefaultOpts = opts =>
+ _.defaults(opts, assertViewOpts, {
compositeImage,
screenshotDelay,
tolerance,
@@ -50,13 +70,7 @@ module.exports.default = browser => {
disableAnimation,
});
- const { testplaneCtx } = session.executionContext;
- testplaneCtx.assertViewResults = testplaneCtx.assertViewResults || AssertViewResults.create();
-
- if (testplaneCtx.assertViewResults.hasState(state)) {
- return Promise.reject(new Error(`duplicate name for "${state}" state`));
- }
-
+ const waitForStaticToLoad = async opts => {
if (opts.waitForStaticToLoadTimeout) {
// Interval between checks is "waitPageReadyTimeout / 10" ms, but at least 50ms and not more than 500ms
await session.waitForStaticToLoad({
@@ -64,34 +78,26 @@ module.exports.default = browser => {
interval: Math.min(Math.max(50, opts.waitForStaticToLoadTimeout / 10), 500),
});
}
+ };
+
+ const compareScreenshot = async (state, currImgInst, currImgMeta, opts) => {
+ const { testplaneCtx } = session.executionContext;
+ const test = session.executionContext.ctx.currentTest;
+ testplaneCtx.assertViewResults = testplaneCtx.assertViewResults || AssertViewResults.create();
+
+ if (testplaneCtx.assertViewResults.hasState(state)) {
+ return Promise.reject(new AssertViewError(`duplicate name for "${state}" state`));
+ }
const handleCaptureProcessorError = e =>
e instanceof BaseStateError ? testplaneCtx.assertViewResults.add(e) : Promise.reject(e);
- const page = await browser.prepareScreenshot([].concat(selectors), {
- ignoreSelectors: [].concat(opts.ignoreElements),
- allowViewportOverflow: opts.allowViewportOverflow,
- captureElementFromTop: opts.captureElementFromTop,
- selectorToScroll: opts.selectorToScroll,
- disableAnimation: opts.disableAnimation,
- });
-
const { tempOpts, updateRefs: isUpdatingRefs } = RuntimeConfig.getInstance();
temp.attach(tempOpts);
- const screenshoterOpts = _.pick(opts, [
- "allowViewportOverflow",
- "compositeImage",
- "screenshotDelay",
- "selectorToScroll",
- ]);
- const currImgInst = await screenShooter
- .capture(page, screenshoterOpts)
- .finally(() => browser.cleanupScreenshot(opts));
const currSize = currImgInst.getSize();
const currImg = { path: temp.path(Object.assign(tempOpts, { suffix: ".png" })), size: currSize };
- const test = session.executionContext.ctx.currentTest;
const refImgAbsolutePath = config.getScreenshotPath(test, state);
const refImgRelativePath = refImgAbsolutePath && path.relative(process.cwd(), refImgAbsolutePath);
const refImg = { path: refImgAbsolutePath, relativePath: refImgRelativePath, size: null };
@@ -109,7 +115,7 @@ module.exports.default = browser => {
addTestplaneSelectivityPngDependency(refImg.path);
- const { canHaveCaret, pixelRatio } = page;
+ const { canHaveCaret, pixelRatio } = currImgMeta;
const imageCompareOpts = {
tolerance: opts.tolerance,
antialiasingTolerance: opts.antialiasingTolerance,
@@ -121,7 +127,7 @@ module.exports.default = browser => {
const refBuffer = await fs.readFile(refImg.path);
try {
- require("png-validator").pngValidator(refBuffer);
+ validatePng(refBuffer);
} catch (err) {
await currImgInst.save(currImg.path);
@@ -170,11 +176,47 @@ module.exports.default = browser => {
testplaneCtx.assertViewResults.add({ stateName: state, refImg: refImg });
};
+ const assertView = async (state, selectors, opts) => {
+ opts = getDefaultOpts(opts);
+
+ let debugId = "debugId";
+ try {
+ const test = session.executionContext.ctx.currentTest;
+ const fullDebugId = `${test.fullTitle()}.${browser.id}.${state}`;
+ debugId = getShortDebugId(fullDebugId);
+ opts.debugId = debugId;
+ debug(`[${debugId}] assertView id: ${fullDebugId}`);
+ } catch {
+ /**/
+ }
+ debug(`[${debugId}] assertView selectors: %O`, selectors);
+ debug(`[${debugId}] assertView opts: %O`, opts);
+
+ const screenShooter = await elementsScreenShooterPromise;
+ await waitForStaticToLoad(opts);
+ const { image, meta } = await screenShooter.capture(selectors, opts);
+
+ return compareScreenshot(state, image, meta, opts);
+ };
+
+ const PSEUDO_SELECTOR_REGEXP = /(.*?)(::before|::after)\s*$/i;
+ const getSelectorToWaitForExist = selector => {
+ if (!_.isString(selector)) {
+ return selector;
+ }
+ const match = selector.match(PSEUDO_SELECTOR_REGEXP);
+ if (!match) {
+ return selector;
+ }
+ const elementSelector = match[1].trim();
+ return elementSelector || selector;
+ };
+
const waitSelectorsForExist = async (browser, selectors) => {
await Promise.all(
[].concat(selectors).map(selector =>
browser
- .$(selector)
+ .$(getSelectorToWaitForExist(selector))
.then(el => el.waitForExist())
.catch(() => {
throw new Error(
@@ -192,13 +234,15 @@ module.exports.default = browser => {
};
const assertViewByViewport = async (state, opts) => {
- opts = Object.assign(opts, {
- allowViewportOverflow: true,
- compositeImage: false,
- captureElementFromTop: false,
- });
+ opts = getDefaultOpts(opts);
+
+ debug(`assertViewByViewport state: ${state}, opts: %O`, opts);
+
+ const vpScreenShooter = await viewportScreenShooterPromise;
+ await waitForStaticToLoad(opts);
+ const { image, meta } = await vpScreenShooter.capture(opts);
- return assertView(state, "body", opts);
+ return compareScreenshot(state, image, meta, opts);
};
const shouldAssertViewport = selectorsOrOpts => {
diff --git a/src/browser/commands/openAndWait.ts b/src/browser/commands/openAndWait.ts
index 329810b15..76acb8514 100644
--- a/src/browser/commands/openAndWait.ts
+++ b/src/browser/commands/openAndWait.ts
@@ -40,9 +40,8 @@ const makeOpenAndWaitCommand = (config: BrowserConfig, session: WebdriverIO.Brow
): Promise {
const PageLoader = await import("../../utils/page-loader").then(m => m.default);
const isChrome = config.desiredCapabilities?.browserName === "chrome";
- const isCDP = config.automationProtocol === "devtools";
- waitNetworkIdle &&= isChrome || isCDP;
+ waitNetworkIdle &&= isChrome;
const originalPageLoadTimeout = config.pageLoadTimeout;
const shouldUpdateTimeout = timeout && timeout !== originalPageLoadTimeout;
diff --git a/src/browser/commands/restoreState/index.ts b/src/browser/commands/restoreState/index.ts
index 4b26ec69d..3be6ca850 100644
--- a/src/browser/commands/restoreState/index.ts
+++ b/src/browser/commands/restoreState/index.ts
@@ -3,8 +3,7 @@ import fs from "fs-extra";
import { restoreStorage } from "./restoreStorage";
import type { Browser } from "../../types";
-import { DEVTOOLS_PROTOCOL, WEBDRIVER_PROTOCOL } from "../../../constants/config";
-import { getOverridesProtocol, getWebdriverFrames, SaveStateData } from "../saveState";
+import { isBidiWithIsolation, getWebdriverFrames, SaveStateData } from "../saveState";
import { getActivePuppeteerPage } from "../../existing-browser";
import { StateOpts } from "../../../config/types";
import type { Cookie as WebdriverCookie } from "@testplane/wdio-protocols";
@@ -58,8 +57,8 @@ export default (browser: Browser): void => {
const cookies = restoreState.cookies?.map(normalizeCookiePrefixConstraints);
- switch (getOverridesProtocol(browser)) {
- case WEBDRIVER_PROTOCOL: {
+ switch (isBidiWithIsolation(browser)) {
+ case false: {
await session.switchToParentFrame();
if (cookies && options.cookies) {
@@ -123,7 +122,7 @@ export default (browser: Browser): void => {
break;
}
- case DEVTOOLS_PROTOCOL: {
+ case true: {
const page = await getActivePuppeteerPage(session);
if (!page) {
diff --git a/src/browser/commands/saveState/index.ts b/src/browser/commands/saveState/index.ts
index cb0e8ac70..60b9cf6d6 100644
--- a/src/browser/commands/saveState/index.ts
+++ b/src/browser/commands/saveState/index.ts
@@ -2,7 +2,6 @@ import fs from "fs-extra";
import _ from "lodash";
import { dumpStorage, StorageData } from "./dumpStorage";
-import { DEVTOOLS_PROTOCOL, WEBDRIVER_PROTOCOL } from "../../../constants/config";
import { ExistingBrowser, getActivePuppeteerPage } from "../../existing-browser";
import * as logger from "../../../utils/logger";
import { Cookie } from "../../../types";
@@ -18,13 +17,9 @@ export type SaveStateData = {
framesData: Record;
};
-// in case when we use webdriver protocol, bidi and isolation
-// we have to force change protocol to devtools, for use puppeteer,
-// because we use it for create incognito window
-export const getOverridesProtocol = (browser: Browser): typeof WEBDRIVER_PROTOCOL | typeof DEVTOOLS_PROTOCOL =>
- browser.config.automationProtocol === WEBDRIVER_PROTOCOL && browser.publicAPI.isBidi && browser.config.isolation
- ? DEVTOOLS_PROTOCOL
- : browser.config.automationProtocol;
+// in case when we use bidi and isolation
+// we have to use puppeteer, because we use it for creating incognito window
+export const isBidiWithIsolation = (browser: Browser): boolean => browser.publicAPI.isBidi && browser.config.isolation;
export const getWebdriverFrames = async (session: WebdriverIO.Browser): Promise =>
session.execute(() =>
@@ -49,8 +44,8 @@ export default (browser: ExistingBrowser): void => {
framesData: {},
};
- switch (getOverridesProtocol(browser)) {
- case WEBDRIVER_PROTOCOL: {
+ switch (isBidiWithIsolation(browser)) {
+ case false: {
if (options.cookies) {
const cookies = await session.getAllCookies();
@@ -106,7 +101,7 @@ export default (browser: ExistingBrowser): void => {
data.framesData = framesData;
break;
}
- case DEVTOOLS_PROTOCOL: {
+ case true: {
if (options.cookies) {
const cookies = await session.getAllCookies();
diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts
index 77651c972..f002acad3 100644
--- a/src/browser/existing-browser.ts
+++ b/src/browser/existing-browser.ts
@@ -4,8 +4,8 @@ import { attach, type AttachOptions, type ElementArray } from "@testplane/webdri
import { sessionEnvironmentDetector } from "@testplane/wdio-utils";
import { Browser, BrowserOpts } from "./browser";
import { customCommandFileNames } from "./commands";
-import { Camera, PageMeta } from "./camera";
-import { type ClientBridge, build as buildClientBridge } from "./client-bridge";
+import { Camera, CaptureViewportImageOpts } from "./camera";
+import { ClientBridge } from "./client-bridge";
import * as history from "./history";
import * as logger from "../utils/logger";
import { WEBDRIVER_PROTOCOL } from "../constants/config";
@@ -13,28 +13,17 @@ import { MIN_CHROME_VERSION_SUPPORT_ISOLATION } from "../constants/browser";
import { isSupportIsolation } from "../utils/browser";
import { isRunInNodeJsEnv } from "../utils/config";
import { Config } from "../config";
-import { Image, Rect } from "../image";
+import { Image } from "../image";
import type { CalibrationResult, Calibrator } from "./calibrator";
import { NEW_ISSUE_LINK } from "../constants/help";
-import { runWithoutHistory } from "./history";
import type { SessionOptions } from "./types";
import { Page } from "puppeteer-core";
import { CDP } from "./cdp";
import { WSDriverRequestAgent } from "./wsdriver";
-import type { ElementReference } from "@testplane/wdio-protocols";
+import type * as browserSideUtilsImplementation from "./client-scripts/browser-utils/implementation";
const OPTIONAL_SESSION_OPTS = ["transformRequest", "transformResponse"];
-interface PrepareScreenshotOpts {
- disableAnimation?: boolean;
- // TODO: specify the rest of the options
-}
-
-interface ClientBridgeErrorData {
- error: string;
- message: string;
-}
-
interface ScrollByParams {
x: number;
y: number;
@@ -43,7 +32,6 @@ interface ScrollByParams {
const BROWSER_SESSION_HINT = "browser session";
const CDP_CONNECTION_HINT = "cdp connection";
-const CLIENT_BRIDGE_HINT = "client bridge";
function ensure(value: T | undefined | null, hint?: string): asserts value is T {
if (!value) {
@@ -56,10 +44,6 @@ function ensure(value: T | undefined | null, hint?: string): asserts value is
}
}
-const isClientBridgeErrorData = (data: unknown): data is ClientBridgeErrorData => {
- return Boolean(data && (data as ClientBridgeErrorData).error && (data as ClientBridgeErrorData).message);
-};
-
export const getActivePuppeteerPage = async (session: WebdriverIO.Browser): Promise => {
const puppeteer = await session.getPuppeteer();
@@ -90,7 +74,7 @@ export class ExistingBrowser extends Browser {
protected _camera: Camera;
protected _meta: Record;
protected _calibration?: CalibrationResult;
- protected _clientBridge?: ClientBridge;
+ protected _browserSideUtils?: ClientBridge;
protected _cdp: CDP | null = null;
protected _wsDriver: WSDriverRequestAgent | null = null;
protected _tags: Set = new Set();
@@ -143,7 +127,7 @@ export class ExistingBrowser extends Browser {
await this._prepareSession();
await this._performCalibration(calibrator);
- await this._buildClientScripts();
+ await this._initBrowserSideUtils();
},
);
@@ -168,38 +152,6 @@ export class ExistingBrowser extends Browser {
this._meta = this._initMeta();
}
- async prepareScreenshot(selectors: string[] | Rect[], opts: PrepareScreenshotOpts = {}): Promise {
- // Running this fragment with history causes rrweb snapshots to break on pages with iframes
- return runWithoutHistory({ callstack: this._callstackHistory! }, async () => {
- opts = _.extend(opts, {
- usePixelRatio: this._calibration ? this._calibration.usePixelRatio : true,
- });
-
- ensure(this._clientBridge, CLIENT_BRIDGE_HINT);
- const result = await this._clientBridge.call("prepareScreenshot", [selectors, opts]);
- if (isClientBridgeErrorData(result)) {
- throw new Error(
- `Prepare screenshot failed with error type '${result.error}' and error message: ${result.message}`,
- );
- }
-
- // https://github.com/webdriverio/webdriverio/issues/11396
- if (this._config.automationProtocol === WEBDRIVER_PROTOCOL && opts.disableAnimation) {
- await this._disableIframeAnimations();
- }
-
- return result;
- });
- }
-
- async cleanupScreenshot(opts: { disableAnimation?: boolean } = {}): Promise {
- if (opts.disableAnimation) {
- return runWithoutHistory({ callstack: this._callstackHistory! }, async () => {
- await this._cleanupPageAnimations();
- });
- }
- }
-
open(url: string): Promise {
ensure(this._session, BROWSER_SESSION_HINT);
@@ -218,12 +170,20 @@ export class ExistingBrowser extends Browser {
return this._session.execute(script);
}
- async captureViewportImage(page?: PageMeta, screenshotDelay?: number): Promise {
- if (screenshotDelay) {
- await new Promise(resolve => setTimeout(resolve, screenshotDelay));
- }
+ get shouldUsePixelRatio(): boolean {
+ return this._calibration ? this._calibration.usePixelRatio : true;
+ }
+
+ get isWebdriverProtocol(): boolean {
+ return this._config.automationProtocol === WEBDRIVER_PROTOCOL;
+ }
- return this._camera.captureViewportImage(page);
+ get needsCompatLib(): boolean {
+ return this._calibration ? this._calibration.needsCompatLib : false;
+ }
+
+ async captureViewportImage(opts?: CaptureViewportImageOpts): Promise {
+ return this._camera.captureViewportImage(opts);
}
scrollBy(params: ScrollByParams): Promise {
@@ -380,8 +340,8 @@ export class ExistingBrowser extends Browser {
this.restoreHttpTimeout();
}
- if (this._clientBridge) {
- await this._clientBridge.call("resetZoom");
+ if (this._browserSideUtils) {
+ await this._browserSideUtils.call("resetZoom", []);
}
return result;
@@ -522,78 +482,14 @@ export class ExistingBrowser extends Browser {
return calibrator.calibrate(this).then(calibration => {
this._calibration = calibration;
- this._camera.calibrate(calibration);
+ this._camera.calibrate(calibration.viewportArea, calibration.screenshotSize);
});
}
- protected async _buildClientScripts(): Promise {
- return buildClientBridge(this, { calibration: this._calibration }).then(
- clientBridge => (this._clientBridge = clientBridge),
- );
- }
-
- protected async _runInEachDisplayedIframe(cb: (...args: unknown[]) => unknown): Promise {
- ensure(this._session, BROWSER_SESSION_HINT);
- const session = this._session;
- const iframes = await session.findElements("css selector", "iframe[src]");
- const displayedIframes: ElementReference[] = [];
-
- await Promise.all(
- iframes.map(async iframe => {
- const isIframeDisplayed = await session.$(iframe).isDisplayed();
-
- if (isIframeDisplayed) {
- displayedIframes.push(iframe);
- }
- }),
- );
-
- try {
- for (const iframe of displayedIframes) {
- await session.switchToFrame(iframe);
- await cb();
- // switchToParentFrame does not work in ios - https://github.com/appium/appium/issues/14882
- await session.switchToFrame(null);
- }
- } catch (e) {
- await session.switchToFrame(null);
- throw e;
- }
- }
-
- protected async _disableFrameAnimations(): Promise {
- ensure(this._clientBridge, CLIENT_BRIDGE_HINT);
- const result = await this._clientBridge.call("disableFrameAnimations");
-
- if (isClientBridgeErrorData(result)) {
- throw new Error(
- `Disable animations failed with error type '${result.error}' and error message: ${result.message}`,
- );
- }
-
- return result;
- }
-
- protected async _disableIframeAnimations(): Promise {
- await this._runInEachDisplayedIframe(() => this._disableFrameAnimations());
- }
-
- protected async _cleanupFrameAnimations(): Promise {
- ensure(this._clientBridge, CLIENT_BRIDGE_HINT);
-
- return this._clientBridge.call("cleanupFrameAnimations");
- }
-
- protected async _cleanupIframeAnimations(): Promise {
- await this._runInEachDisplayedIframe(() => this._cleanupFrameAnimations());
- }
-
- protected async _cleanupPageAnimations(): Promise {
- await this._cleanupFrameAnimations();
-
- if (this._config.automationProtocol === WEBDRIVER_PROTOCOL) {
- await this._cleanupIframeAnimations();
- }
+ protected async _initBrowserSideUtils(): Promise> {
+ return ClientBridge.create(this._session!, "browser-utils", {
+ needsCompatLib: this._calibration?.needsCompatLib,
+ }).then(clientBridge => (this._browserSideUtils = clientBridge));
}
_stubCommands(): void {
@@ -623,4 +519,8 @@ export class ExistingBrowser extends Browser {
get cdp(): CDP | null {
return this._cdp;
}
+
+ get camera(): Camera {
+ return this._camera;
+ }
}
diff --git a/src/browser/isomorphic/assign.ts b/src/browser/isomorphic/assign.ts
new file mode 100644
index 000000000..ba768540b
--- /dev/null
+++ b/src/browser/isomorphic/assign.ts
@@ -0,0 +1,15 @@
+/** ES5-safe Object.assign polyfill for use in browser bundles targeting old browsers */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function assign(target: T, ...sources: any[]): T {
+ for (let i = 0; i < sources.length; i++) {
+ const source = sources[i];
+
+ for (const key in source) {
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
+ (target as Record)[key] = (source as Record)[key];
+ }
+ }
+ }
+
+ return target;
+}
diff --git a/src/browser/isomorphic/geometry.ts b/src/browser/isomorphic/geometry.ts
new file mode 100644
index 000000000..2ea4f8f60
--- /dev/null
+++ b/src/browser/isomorphic/geometry.ts
@@ -0,0 +1,289 @@
+import { assign } from "./assign";
+
+declare const brand: unique symbol;
+type Brand = T & { readonly [brand]: B };
+
+export type Space = "page" | "viewport" | "image" | "capture";
+/** css are logical pixels, device are physical pixels, usually equal to * */
+export type Unit = "css" | "device";
+type MainAxis = "x" | "y";
+type InverseAxis = "x-inverse" | "y-inverse";
+type Axis = MainAxis | InverseAxis;
+
+export type Coord = Brand;
+export type Length = Brand;
+
+export type Point = Readonly<{
+ top: Coord;
+ left: Coord;
+}>;
+
+export type Size = Readonly<{
+ width: Length;
+ height: Length;
+}>;
+
+export type Rect = Point & Size;
+
+export type YBand = {
+ top: Coord;
+ height: Length;
+};
+
+export type XBand = {
+ left: Coord;
+ width: Length;
+};
+
+export const getSize = (rect: Rect): Size => {
+ return {
+ width: rect.width,
+ height: rect.height,
+ };
+};
+
+export const addCoords = >(a: T, b: T): T => {
+ return (a + b) as T;
+};
+
+export const subtractCoords = >(a: T, b: T): T => {
+ return (a - b) as T;
+};
+
+export const equalsSize = >(a: T, b: T): boolean => {
+ return a.width === b.width && a.height === b.height;
+};
+
+export const prettyPoint = >(point: T): string => {
+ return `{ left: ${point.left}, top: ${point.top} }`;
+};
+
+export const prettySize = >(size: T): string => {
+ return `{ width: ${size.width}, height: ${size.height} }`;
+};
+
+export const prettyRect = >(rect: T): string => {
+ return `{ left: ${rect.left}, top: ${rect.top}, width: ${rect.width}, height: ${rect.height} }`;
+};
+
+export const intersectYBands = >(a: T | null, b: T | null): T | null => {
+ if (!a || !b) {
+ return null;
+ }
+ const top = Math.max(a.top, b.top);
+ const bottom = Math.min(a.top + a.height, b.top + b.height);
+
+ return bottom <= top ? null : ({ top, height: bottom - top } as T);
+};
+
+export const intersectXBands = >(a: T | null, b: T | null): T | null => {
+ if (!a || !b) {
+ return null;
+ }
+ const left = Math.max(a.left, b.left);
+ const right = Math.min(a.left + a.width, b.left + b.width);
+
+ return right <= left ? null : ({ left, width: right - left } as T);
+};
+
+export const getIntersection = <
+ S extends Space,
+ U extends Unit,
+ A extends Rect | YBand | XBand,
+ B extends Rect | YBand | XBand,
+>(
+ a: A | null,
+ b: B | null,
+): (A & B) | null => {
+ if (!a || !b) return null;
+
+ const result = { ...a, ...b };
+
+ if ("top" in a && "top" in b) {
+ const y = intersectYBands(a as YBand, b as YBand);
+ if (!y) return null;
+ assign(result, y);
+ }
+
+ if ("left" in a && "left" in b) {
+ const x = intersectXBands(a as XBand, b as XBand);
+ if (!x) return null;
+ assign(result, x);
+ }
+
+ return result as A & B;
+};
+
+type GetUnit = T extends Coord ? Unit : never;
+type GetAxis = T extends Coord ? Axis : never;
+
+/*
+Note: width and height between a and b is computed inclusive of a, but exclusive of b. Width between 1 and 2 is 1.
+ bottom of rect with top=0 and height=1 is 1.
+ These conventions are very useful when dealing with 0-sized areas.
+*/
+
+export const getHeight = >(a: T, b: T): Length, GetAxis> => {
+ return Math.abs(a - b) as Length, GetAxis>;
+};
+
+export const getWidth = >(a: T, b: T): Length, GetAxis> => {
+ return Math.abs(a - b) as Length, GetAxis>;
+};
+
+export const getBottom = (bandOrRect: YBand): Coord => {
+ return (bandOrRect.top + bandOrRect.height) as Coord;
+};
+
+export const getRight = (bandOrRect: XBand): Coord => {
+ return (bandOrRect.left + bandOrRect.width) as Coord;
+};
+
+export const getMaxLength = (...lengths: Length[]): Length => {
+ return Math.max(...lengths) as Length;
+};
+
+export const getMaxCoord = >(...coords: T[]): T => {
+ return Math.max(...coords) as T;
+};
+
+export const getMinCoord = >(...coords: T[]): T => {
+ return Math.min(...coords) as T;
+};
+
+export const fromCaptureAreaToViewport = (
+ coordRelativeToCaptureArea: Coord<"capture", U, "y">,
+ captureAreaTopRelativeToViewport: Coord<"viewport", U, "y">,
+): Coord<"viewport", U, "y"> => {
+ return (coordRelativeToCaptureArea + captureAreaTopRelativeToViewport) as Coord<"viewport", U, "y">;
+};
+
+export const fromViewportToCaptureArea = (
+ coordRelativeToViewport: Coord<"viewport", U, "y">,
+ captureAreaTopRelativeToViewport: Coord<"viewport", U, "y">,
+): Coord<"capture", U, "y"> => {
+ return (coordRelativeToViewport - captureAreaTopRelativeToViewport) as Coord<"capture", U, "y">;
+};
+
+export const getCoveringRect = >(rects: T[]): T => {
+ if (rects.length === 0) {
+ throw new Error("No rectangles to cover");
+ }
+
+ let left = rects[0].left;
+ let top = rects[0].top;
+ let right = getRight(rects[0]);
+ let bottom = getBottom(rects[0]);
+
+ for (let i = 1; i < rects.length; i++) {
+ const r = rects[i];
+ const rLeft = r.left;
+ const rTop = r.top;
+ left = getMinCoord(left, rLeft);
+ top = getMinCoord(top, rTop);
+ right = getMaxCoord(right, getRight(r));
+ bottom = getMaxCoord(bottom, getBottom(r));
+ }
+
+ return { left, top, width: getWidth(left, right), height: getHeight(top, bottom) } as T;
+};
+
+type NumericShape = Rect | YBand | XBand | Size | Point;
+
+export const roundCoords = (value: T): T => {
+ const v = value as unknown as Record;
+ const result: Record = {};
+
+ if ("top" in v) {
+ const top = v.top;
+ result.top = Math.floor(top);
+ }
+
+ if ("height" in v) {
+ result.height = "top" in v ? Math.ceil(v.top + v.height) - result.top : Math.ceil(v.height);
+ }
+
+ if ("left" in v) {
+ const left = v.left;
+ result.left = Math.floor(left);
+ }
+
+ if ("width" in v) {
+ result.width = "left" in v ? Math.ceil(v.left + v.width) - result.left : Math.ceil(v.width);
+ }
+
+ return result as T;
+};
+
+export const floorCoords = (value: T): T => {
+ const v = value as unknown as Record;
+ const result: Record = {};
+
+ for (const key in v) {
+ result[key] = Math.floor(v[key]);
+ }
+
+ return result as T;
+};
+
+export const ceilCoords = (value: T): T => {
+ const v = value as unknown as Record;
+ const result: Record = {};
+
+ for (const key in v) {
+ result[key] = Math.ceil(v[key]);
+ }
+
+ return result as T;
+};
+
+type CssToDevice = T extends Rect
+ ? Rect
+ : T extends YBand
+ ? YBand
+ : T extends XBand
+ ? XBand
+ : T extends Size<"css">
+ ? Size<"device">
+ : T extends Point
+ ? Point
+ : never;
+
+export const fromCssToDevice = <
+ T extends Size<"css"> | Point | Rect | YBand | XBand,
+>(
+ value: T,
+ pixelRatio: number,
+): CssToDevice => {
+ const v = value as unknown as Record;
+ const scaled: Record = {};
+
+ for (const key in v) {
+ scaled[key] = v[key] * pixelRatio;
+ }
+
+ return (pixelRatio % 1 === 0 ? scaled : roundCoords(scaled as NumericShape)) as unknown as CssToDevice;
+};
+
+export const fromCssToDeviceNumber: {
+ (value: Coord, pixelRatio: number): Coord;
+ (value: Length, pixelRatio: number): Length<"device", A>;
+} = (value: number, pixelRatio: number): never => {
+ return (value * pixelRatio) as never;
+};
+
+export const fromDeviceToCssNumber: {
+ (value: Coord, pixelRatio: number): Coord;
+ (value: Length, pixelRatio: number): Length<"css", A>;
+} = (value: number, pixelRatio: number): never => {
+ return (value / pixelRatio) as never;
+};
+
+export const fromBcrToRect = (bcr: DOMRect): Rect<"viewport", "css"> => {
+ return {
+ left: bcr.left as Coord<"viewport", "css", "x">,
+ top: bcr.top as Coord<"viewport", "css", "y">,
+ width: bcr.width as Length<"css", "x">,
+ height: bcr.height as Length<"css", "y">,
+ };
+};
diff --git a/src/browser/isomorphic/index.ts b/src/browser/isomorphic/index.ts
new file mode 100644
index 000000000..48375ec19
--- /dev/null
+++ b/src/browser/isomorphic/index.ts
@@ -0,0 +1,5 @@
+export * from "./assign";
+
+export * from "./geometry";
+
+export * from "./types";
diff --git a/src/browser/isomorphic/tsconfig.json b/src/browser/isomorphic/tsconfig.json
new file mode 100644
index 000000000..85e2d4ee8
--- /dev/null
+++ b/src/browser/isomorphic/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../../tsconfig.common.json",
+ "include": ["."],
+ "compilerOptions": {
+ "composite": true,
+ "declaration": true
+ }
+}
diff --git a/src/browser/isomorphic/types.ts b/src/browser/isomorphic/types.ts
new file mode 100644
index 000000000..18a7a1fd8
--- /dev/null
+++ b/src/browser/isomorphic/types.ts
@@ -0,0 +1,20 @@
+export enum DisableHoverMode {
+ Always = "always",
+ WhenScrollingNeeded = "when-scrolling-needed",
+ Never = "never",
+}
+
+export enum BrowserSideErrorCode {
+ JS = "JS",
+ OUTSIDE_OF_VIEWPORT = "OUTSIDE_OF_VIEWPORT",
+}
+
+export interface BrowserSideError {
+ errorCode: BrowserSideErrorCode;
+ message: string;
+ debugLog?: string;
+}
+
+export const isBrowserSideError = (error: unknown): error is BrowserSideError => {
+ return Boolean(error && (error as BrowserSideError).errorCode && (error as BrowserSideError).message);
+};
diff --git a/src/browser/new-browser.ts b/src/browser/new-browser.ts
index f3a81a5e1..12d7e4295 100644
--- a/src/browser/new-browser.ts
+++ b/src/browser/new-browser.ts
@@ -9,16 +9,9 @@ import signalHandler from "../signal-handler";
import { warn } from "../utils/logger";
import { getNormalizedBrowserName } from "../utils/browser";
import { getInstance } from "../config/runtime-config";
-import {
- DEVTOOLS_PROTOCOL,
- WEBDRIVER_PROTOCOL,
- LOCAL_GRID_URL,
- W3C_CAPABILITIES,
- VENDOR_CAPABILITIES,
-} from "../constants/config";
+import { LOCAL_GRID_URL, W3C_CAPABILITIES, VENDOR_CAPABILITIES } from "../constants/config";
import { Config } from "../config";
import { BrowserConfig } from "../config/browser-config";
-import { gridUrl as DEFAULT_GRID_URL } from "../config/defaults";
import { BrowserName, type W3CBrowserName } from "./types";
export type VendorSpecificCapabilityName = "goog:chromeOptions" | "moz:firefoxOptions" | "ms:edgeOptions";
@@ -146,22 +139,16 @@ export class NewBrowser extends Browser {
protected async _getSessionOpts(): Promise {
const config = this._config;
- let gridUrl;
+ let gridUrl = config.gridUrl;
- if (this._isLocalGridUrl() && config.automationProtocol === WEBDRIVER_PROTOCOL) {
+ if (this._isLocalGridUrl()) {
gridUrl = await this._getLocalWebdriverGridUrl();
- } else {
- // if automationProtocol is not "webdriver", fallback to default grid url from "local"
- // because in "devtools" protocol we dont need gridUrl, but it still has to be valid URL
- gridUrl = config.gridUrl === LOCAL_GRID_URL ? DEFAULT_GRID_URL : config.gridUrl;
}
const gridUri = new URI(gridUrl);
const capabilities = await this._extendCapabilities(config);
- const { devtools } = getInstance();
-
const options = {
protocol: gridUri.protocol(),
hostname: this._getGridHost(gridUri),
@@ -169,7 +156,7 @@ export class NewBrowser extends Browser {
path: gridUri.path(),
queryParams: this._getQueryParams(gridUri.query()),
capabilities,
- automationProtocol: devtools ? DEVTOOLS_PROTOCOL : config.automationProtocol,
+ automationProtocol: config.automationProtocol,
connectionRetryTimeout: config.sessionRequestTimeout || config.httpTimeout,
connectionRetryCount: 3,
baseUrl: config.baseUrl,
diff --git a/src/browser/screen-shooter/README.md b/src/browser/screen-shooter/README.md
new file mode 100644
index 000000000..376cf76cc
--- /dev/null
+++ b/src/browser/screen-shooter/README.md
@@ -0,0 +1,52 @@
+# Screenshots capturing in Testplane: Developer's perspective
+
+> [!NOTE]
+> This document is for Testplane developers. If you are looking for a user's guide, see [Visual Testing Guide](https://testplane.io/docs/v8/visual-testing/visual-testing-intro/) in our docs.
+
+### Terminology
+
+
+
+### Debugging
+
+Screenshots logic is heavily covered by debug logs. Basic assertView and perf logs can be enabled by setting `DEBUG` environment variable to `testplane:screenshots*`, various namespaces are available.
+
+Verbose compositing logs and prepare/getCaptureState browser-side geometry logs require `TESTPLANE_DEBUG_SCREENSHOTS` in addition to `DEBUG`. This environment variable also saves viewport images with debug rectangles to a directory (the directory will be created and logged to console).
+
+### Algorithm overview
+
+For element screenshots, we have two stages: coordinates computation and screenshot capturing itself.
+
+Here's what happens during coordinates computation:
+
+1. Save current scroll positions so they can be restored after capture.
+
+2. Detect the scroll element from `selectorToScroll` or from common scroll parents of captured elements.
+
+3. Scroll to the topmost element that we want to capture inside the scroll element, if it does not have enough safe visibility.
+
+4. Compute capture specs for every selector that we want to capture. Each spec has full, clipped and visible rectangles, taking into account things like box shadows, outlines and pseudo elements.
+
+5. Compute safe area — an area that's free of potentially interfering sticky/fixed/absolute elements that may produce unwanted artifacts when scrolling.
+
+6. Compute ignore elements areas — for each element that we want ignore, produce 1 rectangle that covers that element and takes box shadows, outlines, etc. into account.
+
+7. Return result — an object that has everything that's needed: capture specs, scroll offset, pixelRatio, safeArea coordinates, ignoreElements coordinates and viewport data.
+
+Then we can start capturing actual screenshot:
+
+1. Capture current viewport screenshot.
+
+2. Register it as an in-memory composite chunk, together with current capture specs, safe area, ignore areas and anchor data.
+
+3. Recompute capture specs and safe area after every scroll, because sticky elements, lazy loading and layout shifts can change them.
+
+4. Scroll by the useful remaining capture height, usually close to safeArea size, and repeat 1-3 until the moving capture area is captured. If at any point we can't scroll further, stop. If allowViewportOverflow is false, print a warning.
+
+5. Anchor all chunks in capture-area coordinates, choose safe vertical bands, fill missing gaps with black pieces and join the cropped pieces into the resulting screenshot.
+
+### Notes
+
+Viewport and full-page screenshots use the same camera and compositing building blocks, but skip selector-specific capture specs.
+
+In some browsers, namely Chrome, it's possible to capture the whole element without scrolling, with a single method call. However, it still doesn't produce clean looking screenshots in many cases, e.g. long content inside modals. Besides, we are looking for a universal, cross-browser solution, so we can't rely on those methods alone. We could use those techniques in supported browsers, but we haven't done so yet.
diff --git a/src/browser/screen-shooter/composite-image/debug-utils.ts b/src/browser/screen-shooter/composite-image/debug-utils.ts
new file mode 100644
index 000000000..cef6593f7
--- /dev/null
+++ b/src/browser/screen-shooter/composite-image/debug-utils.ts
@@ -0,0 +1,183 @@
+import fs from "node:fs";
+import { Image } from "../../../image";
+import { convertRgbaToPng } from "../../../utils/eight-bit-rgba-to-png";
+import { loadEsm } from "../../../utils/preload-utils";
+import type { Coord, Rect, Size, YBand } from "../../isomorphic/geometry";
+import path from "node:path";
+import type { CaptureSpec } from "../../client-scripts/screen-shooter/types";
+
+type DebugRectColor = { r: number; g: number; b: number; a: number };
+
+export type ViewportDebugRect = {
+ rect: Rect<"viewport", "device">;
+ color: DebugRectColor;
+};
+
+/*
+This file is used for debugging purposes only, to produce images with capture areas, safe areas, etc. visible when TESTPLANE_DEBUG_SCREENSHOTS is set
+Green frame means safe area, red means area we want to capture.
+*/
+
+export const COMPOSITE_IMAGE_DEBUG_COLORS = {
+ safeArea: { r: 0, g: 255, b: 0, a: 255 }, // green
+ captureSpecVisible: { r: 255, g: 0, b: 0, a: 255 }, // red
+ visibleCoveringRect: { r: 255, g: 105, b: 180, a: 255 }, // pink
+} as const;
+
+const initJsquashPromise = new Promise(resolve => {
+ const wasmLocation = require.resolve("@jsquash/png/codec/pkg/squoosh_png_bg.wasm");
+
+ Promise.all([
+ loadEsm("@jsquash/png/decode.js"),
+ fs.promises.readFile(wasmLocation),
+ ])
+ .then(([mod, wasmBytes]) => mod.init(wasmBytes))
+ .then(resolve);
+});
+
+const decodePngToRgba = async (buffer: Buffer): Promise<{ data: Buffer; width: number; height: number }> => {
+ const [mod] = await Promise.all([
+ loadEsm("@jsquash/png/decode.js"),
+ initJsquashPromise,
+ ]);
+ const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
+ const imageData = await mod.decode(arrayBuffer, { bitDepth: 8 });
+
+ return {
+ data: Buffer.from(imageData.data.buffer, imageData.data.byteOffset, imageData.data.byteLength),
+ width: imageData.width,
+ height: imageData.height,
+ };
+};
+
+function setPixel(
+ rgbaData: Buffer,
+ imageWidth: number,
+ imageHeight: number,
+ x: number,
+ y: number,
+ color: DebugRectColor,
+): void {
+ if (x < 0 || y < 0 || x >= imageWidth || y >= imageHeight) {
+ return;
+ }
+
+ const offset = (y * imageWidth + x) * 4;
+ rgbaData[offset] = color.r;
+ rgbaData[offset + 1] = color.g;
+ rgbaData[offset + 2] = color.b;
+ rgbaData[offset + 3] = color.a;
+}
+
+function drawRectOutline(
+ rgbaData: Buffer,
+ imageWidth: number,
+ imageHeight: number,
+ rect: Rect<"viewport", "device">,
+ color: DebugRectColor,
+): void {
+ const left = Math.max(0, Math.floor(rect.left as number));
+ const top = Math.max(0, Math.floor(rect.top as number));
+ const right = Math.min(imageWidth - 1, Math.ceil((rect.left as number) + (rect.width as number)) - 1);
+ const bottom = Math.min(imageHeight - 1, Math.ceil((rect.top as number) + (rect.height as number)) - 1);
+
+ if (right < left || bottom < top) {
+ return;
+ }
+
+ for (let x = left; x <= right; x++) {
+ setPixel(rgbaData, imageWidth, imageHeight, x, top, color);
+ setPixel(rgbaData, imageWidth, imageHeight, x, bottom, color);
+ }
+
+ for (let y = top; y <= bottom; y++) {
+ setPixel(rgbaData, imageWidth, imageHeight, left, y, color);
+ setPixel(rgbaData, imageWidth, imageHeight, right, y, color);
+ }
+}
+
+export async function saveViewportImageWithDebugRects(
+ image: Image,
+ rects: ViewportDebugRect[],
+ outputPath: string,
+): Promise {
+ const pngBuffer = await image.toPngBuffer({ resolveWithObject: false });
+ const { data, width, height } = await decodePngToRgba(pngBuffer);
+
+ for (const { rect, color } of rects) {
+ drawRectOutline(data, width, height, rect, color);
+ }
+
+ const annotatedPngBuffer = convertRgbaToPng(data, width, height);
+ await fs.promises.writeFile(outputPath, annotatedPngBuffer);
+}
+
+export async function saveRenderedPiecesForDebugIfNeeded(
+ renderedPieces: Image[],
+ destinationDirPath: string | null,
+): Promise {
+ if (process.env.TESTPLANE_DEBUG_SCREENSHOTS && destinationDirPath) {
+ const tmpDir = path.join(destinationDirPath, "rendered-pieces");
+ fs.mkdirSync(tmpDir, { recursive: true });
+ for (let index = 0; index < renderedPieces.length; index++) {
+ const renderedPiece = renderedPieces[index];
+ const piecePath = path.join(tmpDir, `rendered-piece-${index}.png`);
+
+ await renderedPiece.save(piecePath);
+ }
+
+ console.log(`Testplane Composite image pieces saved to ${destinationDirPath}`);
+ }
+}
+
+export async function saveViewportImageForDebugIfNeeded(
+ chunkIndex: number,
+ viewportImage: Image,
+ imageSize: Size<"device">,
+ safeArea: YBand<"viewport", "device">,
+ captureSpecs: CaptureSpec<"viewport", "device">[],
+ visibleCoveringRect: Rect<"viewport", "device">,
+ destinationDirPath: string | null,
+): Promise {
+ if (!process.env.TESTPLANE_DEBUG_SCREENSHOTS || !destinationDirPath) {
+ return;
+ }
+
+ try {
+ const viewportDebugDir = path.join(destinationDirPath, `viewports`);
+ fs.mkdirSync(viewportDebugDir, { recursive: true });
+
+ const debugRects: Array<{
+ rect: Rect<"viewport", "device">;
+ color: { r: number; g: number; b: number; a: number };
+ }> = [
+ {
+ rect: {
+ left: 0 as Coord<"viewport", "device", "x">,
+ top: safeArea.top,
+ width: imageSize.width,
+ height: safeArea.height,
+ },
+ color: COMPOSITE_IMAGE_DEBUG_COLORS.safeArea,
+ },
+ ...captureSpecs
+ .filter(spec => spec.visible.width > 0 && spec.visible.height > 0)
+ .map(spec => ({
+ rect: spec.visible,
+ color: COMPOSITE_IMAGE_DEBUG_COLORS.captureSpecVisible,
+ })),
+ {
+ rect: visibleCoveringRect,
+ color: COMPOSITE_IMAGE_DEBUG_COLORS.visibleCoveringRect,
+ },
+ ];
+
+ await saveViewportImageWithDebugRects(
+ viewportImage,
+ debugRects,
+ path.join(viewportDebugDir, `viewport-${chunkIndex}.png`),
+ );
+ } catch (error) {
+ console.warn("Failed to save viewport debug image: %O", error);
+ }
+}
diff --git a/src/browser/screen-shooter/composite-image/index.ts b/src/browser/screen-shooter/composite-image/index.ts
new file mode 100644
index 000000000..1bc0af684
--- /dev/null
+++ b/src/browser/screen-shooter/composite-image/index.ts
@@ -0,0 +1,755 @@
+import os from "node:os";
+import path from "node:path";
+import { Image } from "../../../image";
+import {
+ YBand,
+ XBand,
+ Rect,
+ Size,
+ getSize,
+ prettySize,
+ subtractCoords,
+ Coord,
+ getBottom,
+ getMaxCoord,
+ getMinCoord,
+ getHeight,
+ intersectYBands,
+ getMaxLength,
+ intersectXBands,
+ Length,
+ fromCaptureAreaToViewport,
+ fromViewportToCaptureArea,
+ getCoveringRect,
+} from "../../isomorphic/geometry";
+import type { CaptureSpec } from "../../client-scripts/screen-shooter/types";
+import { saveRenderedPiecesForDebugIfNeeded, saveViewportImageForDebugIfNeeded } from "./debug-utils";
+import { makeVerboseScreenshotsDebug } from "../debug";
+
+const debug = makeVerboseScreenshotsDebug("testplane:screenshots:composite-image");
+
+/** Raw chunk data as registered by the caller. */
+interface CompositeChunk {
+ image: Image;
+ imageSize: Size<"device">;
+ safeArea: YBand<"viewport", "device">;
+ captureSpecs: CaptureSpec<"viewport", "device">[];
+ boundingRectsToIgnore: Rect<"viewport", "device">[];
+ /** Anchor correction delta in device px. */
+ anchorShift: number | null;
+}
+
+/** Chunk enriched with render-time computed anchor top. */
+interface AnchoredChunk extends CompositeChunk {
+ anchorTop: Coord<"viewport", "device", "y">;
+}
+
+interface SegmentCandidate {
+ chunk: AnchoredChunk;
+ /** Preferred vertical crop area candidate, which fully respects safe area. */
+ strict: YBand<"viewport", "device"> | null;
+ /** Possible vertical crop area candidate, which respects bottom edge of safe area, but includes area beyond safe area at the top. */
+ relaxTop: YBand<"viewport", "device"> | null;
+ /** Possible vertical crop area candidate, which respects top edge of safe area, but includes area beyond safe area at the bottom. */
+ relaxBottom: YBand<"viewport", "device"> | null;
+ /** Possible vertical crop area candidate which doesn't respect safe area at all and contains the entire viewport. */
+ full: YBand<"viewport", "device"> | null;
+ /** Chosen vertical crop area candidate, which will be used to crop the viewport image. */
+ chosen: YBand<"viewport", "device"> | null;
+ expanded?: boolean;
+}
+
+type RenderPiece =
+ | { type: "chunk"; chunk: AnchoredChunk; verticalArea: YBand<"viewport", "device"> }
+ | { type: "black"; height: Length<"device", "y"> };
+
+export class CompositeImage {
+ private _captureAreaSize: Size<"device"> | null;
+ private _compositeChunks: CompositeChunk[];
+ private _debugTmpDir: string | null = null;
+
+ /** Creates a composite renderer instance while preserving subclass construction. */
+ static create(...args: ConstructorParameters): CompositeImage {
+ return new this(...args);
+ }
+
+ /** Initializes chunk storage and an optional debug-output directory. */
+ constructor() {
+ this._captureAreaSize = null;
+ this._compositeChunks = [];
+ if (process.env.TESTPLANE_DEBUG_SCREENSHOTS) {
+ this._debugTmpDir = path.join(
+ os.tmpdir(),
+ `testplane-composite-image-${Math.random().toString(36).slice(2)}`,
+ );
+ }
+ }
+
+ /**
+ * Registers a viewport image with corresponding safe area, capture bounding rects and ignore bounding rects, all relative to the viewport.
+ * The order of registration can be arbitrary, viewport height can change between chunks, gaps will be handled gracefully.
+ * Expects finite integer coords and sizes, otherwise behavior is undefined.
+ * @throws {Error} if capture area size is zero or negative
+ */
+ async registerViewportImageAtOffset(
+ viewportImage: Image,
+ safeArea: YBand<"viewport", "device">,
+ captureSpecs: CaptureSpec<"viewport", "device">[],
+ ignoreBoundingRects: Rect<"viewport", "device">[],
+ anchorShift: number | null = null,
+ ): Promise {
+ const visibleCoveringRect =
+ this._getVisibleCoveringRect({ captureSpecs }) ?? getCoveringRect(captureSpecs.map(s => s.visible));
+
+ if (!this._captureAreaSize) {
+ this._captureAreaSize = getSize(visibleCoveringRect);
+ }
+
+ if (this._captureAreaSize.width <= 0 || this._captureAreaSize.height <= 0) {
+ throw new Error("Capture area size cannot be zero or negative. Got: " + prettySize(this._captureAreaSize));
+ }
+
+ const imageSize = viewportImage.getSize() as Size<"device">;
+
+ debug(
+ "Captured the next chunk.\n captureSpecs: %O\n visibleCoveringRect: %O\n ignoreBoundingRects: %O\n viewportImageSize: %O",
+ captureSpecs,
+ visibleCoveringRect,
+ ignoreBoundingRects,
+ imageSize,
+ );
+
+ await saveViewportImageForDebugIfNeeded(
+ this._compositeChunks.length,
+ viewportImage,
+ imageSize,
+ safeArea,
+ captureSpecs,
+ visibleCoveringRect,
+ this._debugTmpDir,
+ );
+
+ this._compositeChunks.push({
+ image: viewportImage,
+ imageSize,
+ safeArea,
+ captureSpecs,
+ boundingRectsToIgnore: ignoreBoundingRects,
+ anchorShift,
+ });
+ }
+
+ /**
+ * Renders a composite image from the registered chunks.
+ * @throws {Error} if trying to render with zero chunks registered
+ * @throws {Error} if any of the chunks contain malformed PNG data
+ */
+ async render(): Promise {
+ if (!this._compositeChunks.length) {
+ throw new Error(
+ "Cannot render composite image: no chunks were registered.\n" +
+ "This means that screenshot was not captured even once and we have no image to render.",
+ );
+ }
+
+ if (!this._captureAreaSize) {
+ throw new Error(
+ "Cannot render composite image: capture area size is not set.\n" +
+ "This means registerViewportImageAtOffset was never called with a valid capture rect.",
+ );
+ }
+
+ const anchoredChunks = this._computeAnchoredChunks();
+ const sortedChunks = anchoredChunks.slice().sort((a, b) => subtractCoords(b.anchorTop, a.anchorTop));
+
+ const candidates = sortedChunks.map(chunk => this._buildCandidate(chunk));
+
+ debug("Candidates: %O", candidates);
+
+ this._chooseBestCandidates(candidates);
+ this._expandCandidatesToFullArea(candidates);
+
+ const commonHorizontalArea = this._computeCommonHorizontalAreaIfNeeded(candidates, this._captureAreaSize.width);
+ const captureWidth = commonHorizontalArea?.width ?? this._captureAreaSize.width;
+
+ debug("Chosen best candidates: %O", candidates);
+
+ const pieces = this._buildRenderPieces(candidates);
+
+ debug("Rendering composite image. chunks: %d, pieces: %O", this._compositeChunks.length, pieces);
+
+ const renderedPieces: Image[] = [];
+
+ for (const piece of pieces) {
+ if (piece.type === "black") {
+ renderedPieces.push(await this._createBlackPiece(piece.height, captureWidth));
+ continue;
+ }
+
+ if (piece.verticalArea.height <= 0) {
+ continue;
+ }
+
+ renderedPieces.push(
+ await this._createChunkPiece(piece.chunk, piece.verticalArea, captureWidth, commonHorizontalArea),
+ );
+ }
+
+ await saveRenderedPiecesForDebugIfNeeded(renderedPieces, this._debugTmpDir);
+
+ if (!renderedPieces.length) {
+ return this._createBlackPiece(this._captureAreaSize.height, captureWidth);
+ }
+
+ const result = renderedPieces[0];
+
+ if (renderedPieces.length > 1) {
+ result.addJoin(renderedPieces.slice(1));
+ }
+
+ await result.applyJoin();
+
+ return result;
+ }
+
+ /**
+ * Computes anchor tops for all chunks.
+ *
+ * The reference chunk is the one with the highest captureSpec covering-rect top (= the first
+ * scroll position, which has the most positive viewport-space top).
+ *
+ * For each non-reference chunk the base anchorTop is computed from captureSpec deltas (same as
+ * before). When per-chunk correction data is available, the anchor is additionally corrected.
+ *
+ * anchorTop_corrected = anchorTop_from_specs + (chunkAnchorShift - referenceAnchorShift)
+ *
+ * In the stable case correction values are 0 for all chunks.
+ */
+ private _computeAnchoredChunks(): AnchoredChunk[] {
+ let referenceIndex = 0;
+ let referenceCoveringRectTop = getCoveringRect(this._compositeChunks[0].captureSpecs.map(s => s.full)).top;
+
+ for (let i = 1; i < this._compositeChunks.length; i++) {
+ const coveringRectTop = getCoveringRect(this._compositeChunks[i].captureSpecs.map(s => s.full)).top;
+ if (coveringRectTop > referenceCoveringRectTop) {
+ referenceIndex = i;
+ referenceCoveringRectTop = coveringRectTop;
+ }
+ }
+
+ const referenceChunk = this._compositeChunks[referenceIndex];
+ const referenceCaptureSpecs = referenceChunk.captureSpecs;
+ const referenceAnchorShift = referenceChunk.anchorShift;
+
+ const anchoredChunks = this._compositeChunks.map((chunk, index) => {
+ if (index === referenceIndex) {
+ return { ...chunk, anchorTop: referenceCoveringRectTop };
+ }
+
+ let maxDelta = 0;
+ let hasRenderableDelta = false;
+ const minLength = Math.min(chunk.captureSpecs.length, referenceCaptureSpecs.length);
+ for (let i = 0; i < minLength; i++) {
+ const referenceSpec = referenceCaptureSpecs[i];
+ const chunkSpec = chunk.captureSpecs[i];
+
+ if (!this._isRenderableCaptureSpec(referenceSpec) || !this._isRenderableCaptureSpec(chunkSpec)) {
+ continue;
+ }
+
+ const delta = subtractCoords(referenceSpec.full.top, chunkSpec.full.top);
+ if (delta > maxDelta) {
+ maxDelta = delta;
+ }
+ hasRenderableDelta = true;
+ }
+
+ if (!hasRenderableDelta) {
+ for (let i = 0; i < minLength; i++) {
+ const referenceSpec = referenceCaptureSpecs[i];
+ const chunkSpec = chunk.captureSpecs[i];
+
+ const delta = subtractCoords(referenceSpec.full.top, chunkSpec.full.top);
+ if (delta > maxDelta) {
+ maxDelta = delta;
+ }
+ }
+ } else if (maxDelta === 0) {
+ for (let i = 0; i < minLength; i++) {
+ const referenceSpec = referenceCaptureSpecs[i];
+ const chunkSpec = chunk.captureSpecs[i];
+
+ if (!this._isRenderableCaptureSpec(chunkSpec)) {
+ continue;
+ }
+
+ const delta = subtractCoords(referenceSpec.full.top, chunkSpec.full.top);
+ if (delta > maxDelta) {
+ maxDelta = delta;
+ }
+ }
+ }
+
+ const anchorTopFromSpecs = (referenceCoveringRectTop as number) - maxDelta;
+
+ // Apply content-shift correction when anchor tracking data is available (best-effort pass).
+ const shiftCorrection =
+ chunk.anchorShift !== null && referenceAnchorShift !== null
+ ? chunk.anchorShift - referenceAnchorShift
+ : 0;
+
+ return {
+ ...chunk,
+ anchorTop: (anchorTopFromSpecs + shiftCorrection) as Coord<"viewport", "device", "y">,
+ };
+ });
+
+ debug("Anchored chunks: %O", anchoredChunks);
+
+ return anchoredChunks;
+ }
+
+ /** Checks whether a capture spec contributes visible pixels in the current chunk. */
+ private _isRenderableCaptureSpec(spec: CaptureSpec<"viewport", "device">): boolean {
+ return spec.visible.width > 0 && spec.visible.height > 0;
+ }
+
+ /** Returns the bounding rect that covers all visible capture-spec parts for a chunk. */
+ private _getVisibleCoveringRect(chunk: Pick): Rect<"viewport", "device"> | null {
+ const visibleRects = chunk.captureSpecs
+ .filter(spec => this._isRenderableCaptureSpec(spec))
+ .map(spec => spec.visible);
+
+ if (!visibleRects.length) {
+ return null;
+ }
+
+ return getCoveringRect(visibleRects);
+ }
+
+ /** Builds a segment candidate, listing all possible options, e.g. strictly follow safe area, relax top/bottom edges, ignore safe area at all. */
+ private _buildCandidate(chunk: AnchoredChunk): SegmentCandidate {
+ const strict = this._getYBandForMode(chunk, "strict");
+ const relaxTop = this._getYBandForMode(chunk, "relaxTop");
+ const relaxBottom = this._getYBandForMode(chunk, "relaxBottom");
+ const full = this._getYBandForMode(chunk, "full");
+
+ return {
+ chunk,
+ strict,
+ relaxTop,
+ relaxBottom,
+ full,
+ chosen: strict,
+ };
+ }
+
+ /** Computes a usable vertical band for a specific mode: e.g. what if we expand the top edge of the safe area? */
+ private _getYBandForMode(
+ chunk: AnchoredChunk,
+ mode: "strict" | "relaxTop" | "relaxBottom" | "full",
+ ): YBand<"viewport", "device"> | null {
+ const viewportTop = 0 as Coord<"viewport", "device", "y">;
+ const viewportBottom = chunk.imageSize.height as number as Coord<"viewport", "device", "y">;
+
+ const safeTop = chunk.safeArea.top;
+ const safeBottom = getBottom(chunk.safeArea);
+
+ let resultingBand: YBand<"viewport", "device"> | null = {
+ top: safeTop,
+ height: getHeight(safeTop, safeBottom),
+ };
+
+ if (mode === "relaxTop") {
+ resultingBand.top = viewportTop;
+ resultingBand.height = getHeight(resultingBand.top, safeBottom);
+ } else if (mode === "relaxBottom") {
+ resultingBand.height = getHeight(resultingBand.top, viewportBottom);
+ } else if (mode === "full") {
+ resultingBand.top = viewportTop;
+ resultingBand.height = getHeight(resultingBand.top, viewportBottom);
+ }
+
+ const visibleCoveringRect = this._getVisibleCoveringRect(chunk);
+ if (!visibleCoveringRect) {
+ return null;
+ }
+
+ resultingBand = intersectYBands(resultingBand, { top: viewportTop, height: chunk.imageSize.height });
+ resultingBand = intersectYBands(resultingBand, visibleCoveringRect);
+
+ if (!resultingBand || resultingBand.height <= 0) {
+ return null;
+ }
+
+ return resultingBand;
+ }
+
+ /** Chooses the best vertical band per chunk, relaxing edges only where needed to avoid gaps. */
+ private _chooseBestCandidates(candidates: SegmentCandidate[]): void {
+ if (!candidates.length) {
+ return;
+ }
+
+ // Always choose relaxed values for the first and last candidates
+ const first = candidates[0];
+ first.chosen = first.chosen ?? first.relaxTop ?? first.full;
+ if (first.chosen) {
+ const originalBottom = getBottom(first.chosen);
+ const relaxedTop = first.relaxTop?.top ?? first.full?.top ?? first.chosen.top;
+ first.chosen.top = getMinCoord(first.chosen.top, relaxedTop);
+ first.chosen.height = getHeight(first.chosen.top, originalBottom);
+ }
+
+ const last = candidates[candidates.length - 1];
+ last.chosen = last.chosen ?? last.relaxBottom ?? last.full;
+ if (last.chosen) {
+ const relaxedBottom = getBottom(last.relaxBottom ?? last.full ?? last.chosen);
+ const currentBottom = getBottom(last.chosen);
+ const maxBottom = getMaxCoord(currentBottom, relaxedBottom);
+ const maxHeight = getHeight(last.chosen.top, maxBottom);
+ last.chosen.height = getMaxLength(last.chosen.height, maxHeight);
+ }
+
+ for (let i = 0; i < candidates.length - 1; i++) {
+ const upper = candidates[i];
+ const lower = candidates[i + 1];
+
+ upper.chosen = upper.chosen ?? upper.relaxBottom ?? upper.full;
+ lower.chosen = lower.chosen ?? lower.relaxTop ?? lower.full;
+
+ if (!upper.chosen || !lower.chosen) {
+ continue;
+ }
+
+ let upperRelativeToCaptureArea = {
+ top: fromViewportToCaptureArea(upper.chosen.top, upper.chunk.anchorTop),
+ height: upper.chosen.height,
+ };
+ let upperBottomRelativeToCaptureArea = getBottom(upperRelativeToCaptureArea);
+ let lowerTopRelativeToCaptureArea = fromViewportToCaptureArea(lower.chosen.top, lower.chunk.anchorTop);
+
+ if (upperBottomRelativeToCaptureArea >= lowerTopRelativeToCaptureArea) {
+ continue;
+ }
+
+ const relaxedUpperBottom = getBottom(upper.relaxBottom ?? upper.full ?? upper.chosen);
+ if (relaxedUpperBottom > getBottom(upper.chosen)) {
+ upper.chosen.height = getHeight(upper.chosen.top, relaxedUpperBottom);
+ }
+
+ upperRelativeToCaptureArea = {
+ top: fromViewportToCaptureArea(upper.chosen.top, upper.chunk.anchorTop),
+ height: upper.chosen.height,
+ };
+ upperBottomRelativeToCaptureArea = getBottom(upperRelativeToCaptureArea);
+ lowerTopRelativeToCaptureArea = fromViewportToCaptureArea(lower.chosen.top, lower.chunk.anchorTop);
+
+ if (upperBottomRelativeToCaptureArea >= lowerTopRelativeToCaptureArea) {
+ continue;
+ }
+
+ const relaxedLowerStart = lower.relaxTop?.top ?? lower.full?.top ?? lower.chosen.top;
+ if (relaxedLowerStart < lower.chosen.top) {
+ const originalBottom = getBottom(lower.chosen);
+ lower.chosen.top = relaxedLowerStart;
+ lower.chosen.height = getHeight(lower.chosen.top, originalBottom);
+ }
+ }
+ }
+
+ /** Expansion for cases when capture elements are far apart and not fit one viewport. */
+ private _expandCandidatesToFullArea(candidates: SegmentCandidate[]): void {
+ if (candidates.some(candidate => this._doesVisibleAreaCoverFullArea(candidate.chunk))) {
+ return;
+ }
+
+ for (const candidate of candidates) {
+ if (!candidate.chosen) {
+ continue;
+ }
+
+ const safeAreaBand = candidate.chunk.safeArea;
+
+ const fullCoveringRect = getCoveringRect(candidate.chunk.captureSpecs.map(spec => spec.full));
+ const safeAreaBottom = getBottom(safeAreaBand);
+ const fullBottom = getBottom(fullCoveringRect);
+ const chosenBottom = getBottom(candidate.chosen);
+
+ let top = candidate.chosen.top;
+ let bottom = chosenBottom;
+
+ if (fullCoveringRect.top < safeAreaBand.top && top > safeAreaBand.top) {
+ top = safeAreaBand.top;
+ }
+
+ if (fullBottom > safeAreaBottom && bottom < safeAreaBottom) {
+ bottom = safeAreaBottom;
+ }
+
+ if (top !== candidate.chosen.top || bottom !== chosenBottom) {
+ candidate.chosen = {
+ top,
+ height: getHeight(top, bottom),
+ };
+ candidate.expanded = true;
+ }
+ }
+ }
+
+ /** Checks whether visible capture-spec pixels cover the complete requested capture area. */
+ private _doesVisibleAreaCoverFullArea(chunk: AnchoredChunk): boolean {
+ const visibleCoveringRect = this._getVisibleCoveringRect(chunk);
+
+ if (!visibleCoveringRect) {
+ return false;
+ }
+
+ const fullCoveringRect = getCoveringRect(chunk.captureSpecs.map(spec => spec.full));
+
+ return (
+ visibleCoveringRect.top <= fullCoveringRect.top &&
+ getBottom(visibleCoveringRect) >= getBottom(fullCoveringRect)
+ );
+ }
+
+ /** Given a list of best possible segments, builds a list of image pieces, inserting gaps when needed,
+ * ensuring resulting array is a vertically continuous sequence of pieces. */
+ private _buildRenderPieces(candidates: SegmentCandidate[]): RenderPiece[] {
+ const pieces: RenderPiece[] = [];
+
+ const sortedCandidates = candidates
+ .filter(candidate => Boolean(candidate.chosen))
+ .sort((a, b) =>
+ subtractCoords(
+ fromViewportToCaptureArea(a.chosen!.top, a.chunk.anchorTop),
+ fromViewportToCaptureArea(b.chosen!.top, b.chunk.anchorTop),
+ ),
+ );
+
+ let cursor = 0 as Coord<"capture", "device", "y">;
+ let hasStartedRendering = false;
+
+ for (const candidate of sortedCandidates) {
+ const chosen = candidate.chosen!;
+ const chosenRelativeToCaptureArea: YBand<"capture", "device"> = {
+ top: fromViewportToCaptureArea(chosen.top, candidate.chunk.anchorTop),
+ height: chosen.height,
+ };
+ const bottomRelativeToCaptureArea = getBottom(chosenRelativeToCaptureArea);
+
+ if (bottomRelativeToCaptureArea <= cursor) {
+ continue;
+ }
+
+ if (!hasStartedRendering) {
+ cursor = chosenRelativeToCaptureArea.top;
+ hasStartedRendering = true;
+ }
+
+ const topRelativeToCaptureArea = getMaxCoord(chosenRelativeToCaptureArea.top, cursor);
+
+ if (topRelativeToCaptureArea > cursor) {
+ pieces.push(...this._buildGapPieces(candidates, cursor, topRelativeToCaptureArea));
+ }
+
+ const cursorRelativeToViewport = fromCaptureAreaToViewport(cursor, candidate.chunk.anchorTop);
+ const topRelativeToViewport = getMaxCoord(chosen.top, cursorRelativeToViewport);
+ const bottomRelativeToViewport = getBottom(chosen);
+ pieces.push({
+ type: "chunk",
+ chunk: candidate.chunk,
+ verticalArea: {
+ top: topRelativeToViewport,
+ height: getHeight(topRelativeToViewport, bottomRelativeToViewport),
+ },
+ });
+
+ cursor = bottomRelativeToCaptureArea;
+ }
+
+ return pieces;
+ }
+
+ /** Fills an uncovered capture-area gap with usable chunk areas or black fallback slices. */
+ private _buildGapPieces(
+ candidates: SegmentCandidate[],
+ gapTop: Coord<"capture", "device", "y">,
+ gapBottom: Coord<"capture", "device", "y">,
+ ): RenderPiece[] {
+ const pieces: RenderPiece[] = [];
+ const usableAreas = candidates
+ .map(candidate => {
+ const safeArea = candidate.chunk.safeArea;
+
+ return {
+ chunk: candidate.chunk,
+ area: {
+ top: fromViewportToCaptureArea(safeArea.top, candidate.chunk.anchorTop),
+ height: safeArea.height,
+ } as YBand<"capture", "device">,
+ };
+ })
+ .sort((a, b) => subtractCoords(a.area.top, b.area.top));
+
+ let cursor = gapTop;
+
+ for (const { chunk, area } of usableAreas) {
+ const areaBottom = getBottom(area);
+
+ if (areaBottom <= cursor || area.top >= gapBottom) {
+ continue;
+ }
+
+ const top = getMaxCoord(area.top, cursor);
+ const bottom = getMinCoord(areaBottom, gapBottom);
+
+ if (top > cursor) {
+ pieces.push({ type: "black", height: getHeight(cursor, top) });
+ }
+
+ if (bottom > top) {
+ pieces.push({
+ type: "chunk",
+ chunk,
+ verticalArea: {
+ top: fromCaptureAreaToViewport(top, chunk.anchorTop),
+ height: getHeight(top, bottom),
+ },
+ });
+ cursor = bottom;
+ }
+
+ if (cursor >= gapBottom) {
+ break;
+ }
+ }
+
+ if (cursor < gapBottom) {
+ pieces.push({ type: "black", height: getHeight(cursor, gapBottom) });
+ }
+
+ return pieces;
+ }
+
+ /** Returns the horizontal viewport band occupied by visible capture-spec pixels. */
+ private _getChunkHorizontalArea(
+ chunk: Pick,
+ ): XBand<"viewport", "device"> | null {
+ const viewportHorizontalArea = {
+ left: 0 as Coord<"viewport", "device", "x">,
+ width: chunk.imageSize.width as number as Length<"device", "x">,
+ };
+ const visibleCoveringRect = this._getVisibleCoveringRect(chunk);
+ if (!visibleCoveringRect) {
+ return null;
+ }
+
+ return intersectXBands(viewportHorizontalArea, visibleCoveringRect);
+ }
+
+ /** Computes a shared horizontal crop band when chunks cannot safely use the original width. */
+ private _computeCommonHorizontalAreaIfNeeded(
+ candidates: SegmentCandidate[],
+ captureWidth: Length<"device", "x">,
+ ): XBand<"viewport", "device"> | null {
+ const chunkHorizontalAreas = candidates
+ .map(candidate => this._getCandidateHorizontalArea(candidate))
+ .filter((area): area is XBand<"viewport", "device"> => Boolean(area));
+
+ if (chunkHorizontalAreas.length === 0) {
+ return null;
+ }
+
+ const hasMultipleCaptureSpecs = candidates.some(candidate => candidate.chunk.captureSpecs.length > 1);
+ const hasExpandedCandidate = candidates.some(candidate => candidate.expanded);
+ const hasWidthMismatch = chunkHorizontalAreas.some(area => area.width !== captureWidth);
+
+ if (!hasMultipleCaptureSpecs && !hasExpandedCandidate && !hasWidthMismatch) {
+ return null;
+ }
+
+ const left = Math.min(...chunkHorizontalAreas.map(area => area.left));
+ const right = Math.max(...chunkHorizontalAreas.map(area => (area.left as number) + (area.width as number)));
+
+ return {
+ left: left as Coord<"viewport", "device", "x">,
+ width: (right - left) as Length<"device", "x">,
+ };
+ }
+
+ /** Selects the horizontal band that should be used for a candidate chunk. */
+ private _getCandidateHorizontalArea(candidate: SegmentCandidate): XBand<"viewport", "device"> | null {
+ if (!candidate.expanded) {
+ return this._getChunkHorizontalArea(candidate.chunk);
+ }
+
+ return this._getExpandedChunkHorizontalArea(candidate.chunk) ?? this._getChunkHorizontalArea(candidate.chunk);
+ }
+
+ /** Computes a horizontal band for expanded chunks using requested full rects and clip bounds. */
+ private _getExpandedChunkHorizontalArea(chunk: AnchoredChunk): XBand<"viewport", "device"> | null {
+ const viewportHorizontalArea = {
+ left: 0 as Coord<"viewport", "device", "x">,
+ width: chunk.imageSize.width as number as Length<"device", "x">,
+ };
+ const fullCoveringRect = getCoveringRect(chunk.captureSpecs.map(spec => spec.full));
+ const clipCoveringRect = getCoveringRect(chunk.captureSpecs.map(spec => spec.clip));
+ const fullHorizontalArea = intersectXBands(viewportHorizontalArea, fullCoveringRect);
+
+ return intersectXBands(fullHorizontalArea, clipCoveringRect);
+ }
+
+ /** Crops one viewport chunk into a render piece after clearing ignored regions. */
+ private async _createChunkPiece(
+ chunk: AnchoredChunk,
+ verticalArea: YBand<"viewport", "device">,
+ captureWidth: Length<"device", "x">,
+ commonHorizontalArea: XBand<"viewport", "device"> | null,
+ ): Promise {
+ const viewportHorizontalArea = {
+ left: 0 as Coord<"viewport", "device", "x">,
+ width: chunk.imageSize.width as number as Length<"device", "x">,
+ };
+ const horizonalArea = commonHorizontalArea
+ ? intersectXBands(viewportHorizontalArea, commonHorizontalArea)
+ : this._getChunkHorizontalArea(chunk);
+
+ if (
+ !horizonalArea ||
+ horizonalArea.width <= 0 ||
+ verticalArea.height <= 0 ||
+ horizonalArea.width !== captureWidth
+ ) {
+ debug(
+ "Chunk crop area is invalid or doesn't match capture width, using black fallback.\n verticalArea: %O\n horizonalArea: %O \n captureWidth: %d",
+ verticalArea,
+ horizonalArea,
+ captureWidth,
+ );
+ return this._createBlackPiece(verticalArea.height, captureWidth);
+ }
+
+ const cropArea: Rect<"image", "device"> = {
+ top: verticalArea.top as number as Coord<"image", "device", "y">,
+ height: verticalArea.height,
+ left: horizonalArea.left as number as Coord<"image", "device", "x">,
+ width: horizonalArea.width,
+ };
+
+ const image = await chunk.image.clone();
+
+ for (const ignoreRect of chunk.boundingRectsToIgnore) {
+ await image.addClear(ignoreRect);
+ }
+ await image.applyJoin();
+ await image.crop(cropArea);
+
+ return image;
+ }
+
+ /** Creates a black fallback image piece for areas that no chunk can provide. */
+ private async _createBlackPiece(height: Length<"device", "y">, width: Length<"device", "x">): Promise {
+ return new Image({ width, height });
+ }
+}
diff --git a/src/browser/screen-shooter/constants.ts b/src/browser/screen-shooter/constants.ts
new file mode 100644
index 000000000..0235a67d1
--- /dev/null
+++ b/src/browser/screen-shooter/constants.ts
@@ -0,0 +1 @@
+export const COMPOSITING_ITERATIONS_LIMIT = 50;
diff --git a/src/browser/screen-shooter/debug.ts b/src/browser/screen-shooter/debug.ts
new file mode 100644
index 000000000..831b8aa30
--- /dev/null
+++ b/src/browser/screen-shooter/debug.ts
@@ -0,0 +1,20 @@
+import makeDebug from "debug";
+
+export const isVerboseScreenshotsDebugEnabled = (): boolean => Boolean(process.env.TESTPLANE_DEBUG_SCREENSHOTS);
+
+export function makeVerboseScreenshotsDebug(namespace: string): ReturnType {
+ const debug = makeDebug(namespace);
+ const verboseDebug = ((...args: Parameters) => {
+ if (isVerboseScreenshotsDebugEnabled()) {
+ debug(...args);
+ }
+ }) as ReturnType;
+
+ Object.assign(verboseDebug, debug);
+ Object.defineProperty(verboseDebug, "enabled", {
+ get: () => isVerboseScreenshotsDebugEnabled() && debug.enabled,
+ configurable: true,
+ });
+
+ return verboseDebug;
+}
diff --git a/src/browser/screen-shooter/elements-screen-shooter.ts b/src/browser/screen-shooter/elements-screen-shooter.ts
new file mode 100644
index 000000000..e981866a9
--- /dev/null
+++ b/src/browser/screen-shooter/elements-screen-shooter.ts
@@ -0,0 +1,757 @@
+import makeDebug from "debug";
+import { CompositeImage } from "./composite-image";
+import { Image } from "../../image";
+import { assertCorrectCaptureAreaBounds } from "./validation";
+import type { AssertViewOpts } from "../../config/types";
+import { runWithoutHistory } from "../history";
+import {
+ disableIframeAnimations,
+ cleanupPageAnimations,
+ cleanupPointerEvents,
+ cleanupScrolls,
+ preparePointerForScreenshot,
+ waitForSelectorsToSettle,
+} from "./operations";
+import { NEW_ISSUE_LINK } from "../../constants/help";
+import { Coord, Length, getBottom } from "../isomorphic/geometry";
+import { WdioBrowser } from "../../types";
+import { Camera } from "../camera";
+import type * as browserSideScreenshooterImplementation from "../client-scripts/screen-shooter/implementation";
+import { ClientBridge } from "../client-bridge";
+import type {
+ CaptureState,
+ PrepareScreenshotOptions,
+ PrepareScreenshotSuccess,
+} from "../client-scripts/screen-shooter/types";
+import { isBrowserSideError } from "../isomorphic/types";
+import { COMPOSITING_ITERATIONS_LIMIT } from "./constants";
+import { makeVerboseScreenshotsDebug } from "./debug";
+
+class CaptureAreaSizeChangeError extends Error {
+ constructor() {
+ super("Capture area size changed unexpectedly during capture");
+ this.name = "CaptureAreaSizeChangeError";
+ }
+}
+
+const debug = makeVerboseScreenshotsDebug("testplane:screenshots:elements-screen-shooter");
+const SCROLL_OVERLAP_PX = 1;
+const formatDuration = (duration: number): string => `${duration.toFixed(1)}ms`;
+
+interface ScreenShooterOpts extends AssertViewOpts {
+ debugId?: string;
+}
+
+interface CaptureImageResult {
+ image: Image;
+ meta: PrepareScreenshotSuccess;
+}
+
+interface ScreenShooterBrowserProperties {
+ isWebdriverProtocol: boolean;
+ shouldUsePixelRatio: boolean;
+ needsCompatLib: boolean;
+}
+
+interface ScreenShooterInputParams {
+ camera: Camera;
+ browser: WdioBrowser;
+ browserProperties: ScreenShooterBrowserProperties;
+}
+
+interface ScreenShooterFullParams extends ScreenShooterInputParams {
+ browserSideScreenshooter: ClientBridge;
+}
+
+function getMedian(values: number[]): number | null {
+ if (values.length === 0) {
+ return null;
+ }
+
+ const sorted = values.slice().sort((a, b) => a - b);
+ const middleIndex = Math.floor(sorted.length / 2);
+
+ if (sorted.length % 2 === 1) {
+ return sorted[middleIndex];
+ }
+
+ return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2;
+}
+
+function getExpectedTotalMoveFromBaseline(
+ baselineCaptureSpecs: CaptureState["captureSpecs"],
+ currentCaptureSpecs: CaptureState["captureSpecs"],
+): number {
+ const sharedSpecsCount = Math.min(baselineCaptureSpecs.length, currentCaptureSpecs.length);
+ const shifts: number[] = [];
+
+ for (let index = 0; index < sharedSpecsCount; index++) {
+ const baselineSpec = baselineCaptureSpecs[index];
+ const currentSpec = currentCaptureSpecs[index];
+
+ if (!baselineSpec || !currentSpec) {
+ continue;
+ }
+
+ const shift = (currentSpec.full.top as number) - (baselineSpec.full.top as number);
+ if (shift !== 0) {
+ shifts.push(shift);
+ }
+ }
+
+ return getMedian(shifts) ?? 0;
+}
+
+function getMovingCaptureSpecs(currentState: CaptureState, lastState: CaptureState): CaptureState["captureSpecs"] {
+ if (currentState.scrollOffset === lastState.scrollOffset) {
+ return currentState.captureSpecs;
+ }
+
+ return currentState.captureSpecs.filter((spec, index) => {
+ const lastSpec = lastState.captureSpecs[index];
+
+ return lastSpec && spec.full.top !== lastSpec.full.top;
+ });
+}
+
+function getRemainingCaptureAreaHeight(
+ captureSpecs: CaptureState["captureSpecs"],
+ safeArea: CaptureState["safeArea"],
+): Length<"device", "y"> {
+ if (captureSpecs.length === 0) {
+ return 0 as Length<"device", "y">;
+ }
+
+ const safeAreaBottom = getBottom(safeArea);
+ const captureAreaBottom = Math.max(...captureSpecs.map(spec => getBottom(spec.full)));
+
+ return Math.max(0, captureAreaBottom - safeAreaBottom) as Length<"device", "y">;
+}
+
+function getScrollDelta(
+ safeAreaHeight: Length<"device", "y">,
+ remainingCaptureAreaHeight: Length<"device", "y">,
+): Length<"device", "y"> {
+ if (remainingCaptureAreaHeight <= safeAreaHeight) {
+ return remainingCaptureAreaHeight;
+ }
+
+ return (safeAreaHeight > SCROLL_OVERLAP_PX ? safeAreaHeight - SCROLL_OVERLAP_PX : safeAreaHeight) as Length<
+ "device",
+ "y"
+ >;
+}
+
+function getCaptureAreaTop(captureSpecs: CaptureState["captureSpecs"]): Coord<"viewport", "device", "y"> | null {
+ if (captureSpecs.length === 0) {
+ return null;
+ }
+
+ return Math.min(...captureSpecs.map(spec => spec.full.top as number)) as Coord<"viewport", "device", "y">;
+}
+
+function getSafeAreaRollbackDistance(lastState: CaptureState, currentState: CaptureState): Length<"device", "y"> {
+ const previousCaptureAreaTop = getCaptureAreaTop(lastState.captureSpecs);
+ const currentCaptureAreaTop = getCaptureAreaTop(currentState.captureSpecs);
+
+ if (previousCaptureAreaTop === null || currentCaptureAreaTop === null) {
+ return 0 as Length<"device", "y">;
+ }
+
+ const previousVisibleBottom = Math.max(...lastState.captureSpecs.map(spec => getBottom(spec.visible)));
+ const previousSafeBottom = getBottom(lastState.safeArea);
+ const previousCoveredBottom =
+ Math.min(previousVisibleBottom, previousSafeBottom) - (previousCaptureAreaTop as number);
+ const currentSafeAreaTop = (currentState.safeArea.top as number) - (currentCaptureAreaTop as number);
+
+ return Math.max(0, currentSafeAreaTop - previousCoveredBottom) as Length<"device", "y">;
+}
+
+function getEmptyCaptureSpecsErrorMessage(selectorsToCapture: string[]): string {
+ return (
+ `Failed to capture element screenshot for selectors: ${selectorsToCapture.join("; ")}.\n` +
+ `Could not determine coordinates of the matched elements.\n` +
+ `Most likely the matched element became hidden, zero-sized, detached, moved offscreen, ` +
+ `or was clipped after scrolling/waiting for layout to settle.\n` +
+ `If you are capturing element sensitive to scrolling, like a tooltip, it could be hidden due to auto-scrolling on our side.\n` +
+ `Make sure the selector stays visible during the screenshot or disable scrolling via compositeImage/captureElementFromTop options.`
+ );
+}
+
+export class ElementsScreenShooter {
+ private _browser: WdioBrowser;
+ private _camera: Camera;
+ private _browserProperties: ScreenShooterBrowserProperties;
+ private _browserSideScreenshooter: ClientBridge;
+
+ static async create(params: ScreenShooterInputParams): Promise {
+ const browserSideScreenshooter = await ClientBridge.create(
+ params.browser,
+ "screen-shooter",
+ { needsCompatLib: params.browserProperties.needsCompatLib },
+ );
+
+ return new this({ ...params, browserSideScreenshooter });
+ }
+
+ constructor({ browser, camera, browserProperties, browserSideScreenshooter }: ScreenShooterFullParams) {
+ this._browser = browser;
+ this._camera = camera;
+ this._browserProperties = browserProperties;
+ this._browserSideScreenshooter = browserSideScreenshooter;
+ }
+
+ async capture(selectorOrSelectors: string | string[], opts: ScreenShooterOpts = {}): Promise {
+ const globalStartedAt = performance.now();
+ const perfDebug = makeDebug("testplane:screenshots:perf:" + opts.debugId);
+
+ const selectorsToCapture = ([] as string[]).concat(selectorOrSelectors);
+ const selectorsToIgnore = ([] as string[]).concat(opts.ignoreElements ?? []);
+
+ if (selectorsToCapture.length === 0) {
+ throw new Error("No selectors to capture passed to ElementsScreenShooter.capture");
+ }
+
+ try {
+ perfDebug("capture: begin");
+
+ const page = await this._prepareScreenshot(selectorsToCapture, {
+ ignoreSelectors: selectorsToIgnore,
+ allowViewportOverflow: opts.allowViewportOverflow,
+ captureElementFromTop: opts.captureElementFromTop,
+ selectorToScroll: opts.selectorToScroll,
+ disableAnimation: opts.disableAnimation,
+ disableHover: opts.disableHover,
+ compositeImage: opts.compositeImage,
+ });
+
+ assertCorrectCaptureAreaBounds(
+ JSON.stringify(selectorsToCapture),
+ page.viewportSize,
+ page.viewportOffset,
+ page.captureSpecs.map(s => s.full),
+ opts,
+ );
+
+ await preparePointerForScreenshot(this._browser, {
+ disableHover: opts.disableHover,
+ pointerEventsDisabled: page.pointerEventsDisabled,
+ });
+
+ let compositeImage: CompositeImage;
+ try {
+ compositeImage = await this._performCaptureAttempt(
+ selectorsToCapture,
+ selectorsToIgnore,
+ page,
+ opts,
+ true,
+ );
+ } catch (error) {
+ if (!(error instanceof CaptureAreaSizeChangeError)) {
+ throw error;
+ }
+
+ perfDebug("capture: retrying in best-effort mode");
+ await this._preloadCaptureArea(selectorsToCapture, selectorsToIgnore, page, opts);
+ compositeImage = await this._performCaptureAttempt(
+ selectorsToCapture,
+ selectorsToIgnore,
+ page,
+ opts,
+ false,
+ );
+ }
+
+ const renderedImage = await compositeImage.render();
+
+ perfDebug(`capture: end in ${formatDuration(performance.now() - globalStartedAt)}`);
+
+ return {
+ image: renderedImage,
+ meta: page,
+ };
+ } finally {
+ try {
+ await this._cleanupScreenshot(opts);
+ } catch (cleanupError) {
+ const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
+ console.warn(
+ `Warning: failed to cleanup after screenshot for selectors: ${JSON.stringify(
+ selectorsToCapture,
+ )}\n` + `Cleanup error: ${cleanupMessage}`,
+ );
+ }
+ }
+ }
+
+ private async _prepareScreenshot(
+ selectorsToCapture: string[],
+ opts: PrepareScreenshotOptions = {},
+ ): Promise {
+ return runWithoutHistory({}, async () => {
+ const enabledDebugTopics: string[] = [];
+ const browserPrepareScreenshotDebug = makeVerboseScreenshotsDebug(
+ "testplane:screenshots:browser:prepareScreenshot",
+ );
+ if (browserPrepareScreenshotDebug.enabled) {
+ enabledDebugTopics.push("prepareElementsScreenshot");
+ }
+
+ const extendedOpts = {
+ ...opts,
+ debug: enabledDebugTopics,
+ usePixelRatio: this._browserProperties.shouldUsePixelRatio,
+ };
+
+ const result = await this._browserSideScreenshooter.call("prepareElementsScreenshot", [
+ selectorsToCapture,
+ extendedOpts,
+ ]);
+
+ const { debugLog, ...resultRest } = result;
+ browserPrepareScreenshotDebug(debugLog);
+ debug("prepareElementsScreenshot result: %O", resultRest);
+
+ if (isBrowserSideError(result)) {
+ throw new Error(
+ `Failed to perform the visual check, because we couldn't compute screenshot area to capture.\n\n` +
+ `What happened:\n` +
+ `- You called assertView command with the following selectors: ${JSON.stringify(
+ selectorsToCapture,
+ )}\n` +
+ `- You passed the following options: ${JSON.stringify(extendedOpts)}\n` +
+ `- We tried to determine positions of these elements, but failed with the '${result.errorCode}' error: ${result.message}\n\n` +
+ `What you can do:\n` +
+ `- Check that passed selectors are valid and exist on the page\n` +
+ `- If you believe this is a bug on our side, re-run this test with DEBUG=testplane:screenshots* and file an issue with this log at ${NEW_ISSUE_LINK}\n`,
+ );
+ }
+
+ // https://github.com/webdriverio/webdriverio/issues/11396
+ if (this._browserProperties.isWebdriverProtocol && opts.disableAnimation) {
+ await disableIframeAnimations(this._browser, this._browserSideScreenshooter);
+ }
+
+ return result;
+ });
+ }
+
+ private async _cleanupScreenshot(opts: ScreenShooterOpts = {}): Promise {
+ return runWithoutHistory({}, async () => {
+ await cleanupScrolls(this._browserSideScreenshooter);
+
+ if (opts.disableAnimation) {
+ await cleanupPageAnimations(
+ this._browser,
+ this._browserSideScreenshooter,
+ this._browserProperties.isWebdriverProtocol,
+ );
+ }
+ if (opts.disableHover && opts.disableHover !== "never") {
+ await cleanupPointerEvents(this._browserSideScreenshooter);
+ }
+ });
+ }
+
+ /** Scrolls through the entire capture area to trigger lazy loading, then restores scroll and records anchor baselines. */
+ private async _preloadCaptureArea(
+ selectorsToCapture: string[],
+ selectorsToIgnore: string[],
+ page: PrepareScreenshotSuccess,
+ opts: ScreenShooterOpts,
+ ): Promise {
+ const perfDebug = makeDebug("testplane:screenshots:perf:" + opts.debugId);
+
+ perfDebug("preload capture area: begin");
+ try {
+ await this._scrollThroughCaptureArea(selectorsToCapture, selectorsToIgnore, page, opts, async () => {});
+
+ await this._browserSideScreenshooter.call("scrollTo", [
+ selectorsToCapture,
+ page.scrollOffset,
+ opts.selectorToScroll ?? null,
+ ]);
+
+ await this._browserSideScreenshooter.call("captureAnchorBaseline", [selectorsToCapture]);
+ } finally {
+ perfDebug("preload capture area: end");
+ }
+ }
+
+ private async _scrollThroughCaptureArea(
+ selectorsToCapture: string[],
+ selectorsToIgnore: string[],
+ page: PrepareScreenshotSuccess,
+ opts: ScreenShooterOpts,
+ onNextScroll: (currentState: CaptureState) => Promise,
+ ): Promise {
+ const perfDebug = makeDebug("testplane:screenshots:perf:" + opts.debugId);
+ let iterations = 0;
+ let lastState: CaptureState = {
+ captureSpecs: page.captureSpecs,
+ viewportOffset: page.viewportOffset,
+ scrollOffset: page.scrollOffset,
+ safeArea: page.safeArea,
+ ignoreAreas: page.ignoreAreas,
+ anchorShift: null,
+ };
+ let hasReachedScrollLimit = false;
+ let hasCapturedTheWholeArea = false;
+
+ const startTime = performance.now();
+ let waitForSettleTime = 0,
+ recomputeTime = 0,
+ scrollTime = 0,
+ callbackTime = 0;
+
+ try {
+ while (iterations < COMPOSITING_ITERATIONS_LIMIT && !hasCapturedTheWholeArea && !hasReachedScrollLimit) {
+ debug(`========== Starting compositing iteration #${iterations} ==========`);
+
+ const waitForSettleStartTime = performance.now();
+ await waitForSelectorsToSettle(this._browser, selectorsToCapture);
+ waitForSettleTime += performance.now() - waitForSettleStartTime;
+
+ const recomputeStartTime = performance.now();
+
+ const enabledScrollDebugTopics: string[] = [];
+ const browserScrollDebug = makeVerboseScreenshotsDebug("testplane:screenshots:browser:getCaptureState");
+ if (browserScrollDebug.enabled) {
+ enabledScrollDebugTopics.push("getCaptureState");
+ }
+
+ const currentStateOrError = await this._browserSideScreenshooter.call("getCaptureState", [
+ selectorsToCapture,
+ selectorsToIgnore,
+ opts.selectorToScroll,
+ enabledScrollDebugTopics,
+ ]);
+ recomputeTime += performance.now() - recomputeStartTime;
+ const recomputeDebugLog = currentStateOrError.debugLog;
+ delete currentStateOrError.debugLog;
+ browserScrollDebug(recomputeDebugLog);
+
+ debug("currentState: %O", currentStateOrError);
+
+ if (isBrowserSideError(currentStateOrError)) {
+ throw new Error(
+ `Failed to recompute areas while compositing image of selectors: ${selectorsToCapture.join(
+ ", ",
+ )}, error type '${currentStateOrError.errorCode}' and error message: ${
+ currentStateOrError.message
+ }`,
+ );
+ }
+
+ let currentState = currentStateOrError;
+
+ const safeAreaShrink = (lastState.safeArea.height - currentState.safeArea.height) as Length<
+ "device",
+ "y"
+ >;
+
+ if (safeAreaShrink > 0) {
+ // Roll back only if the new safe-area start would leave a gap after the previous chunk.
+ const rollbackDistance = getSafeAreaRollbackDistance(lastState, currentState);
+
+ debug("safe area shrank after scroll", {
+ previousSafeArea: lastState.safeArea,
+ newSafeArea: currentState.safeArea,
+ safeAreaShrink,
+ rollbackDistance,
+ previousOffset: lastState.scrollOffset,
+ scrolledOffset: currentState.scrollOffset,
+ });
+
+ if (rollbackDistance > 0) {
+ await this._browserSideScreenshooter.call("scrollBy", [
+ selectorsToCapture,
+ -rollbackDistance as Coord<"page", "device", "y">,
+ opts.selectorToScroll,
+ ]);
+ const afterRollbackState = await this._browserSideScreenshooter.call("getCaptureState", [
+ selectorsToCapture,
+ selectorsToIgnore,
+ opts.selectorToScroll,
+ ]);
+
+ if (isBrowserSideError(afterRollbackState)) {
+ throw new Error(
+ `Failed to rollback and recompute areas while compositing image of selectors: ${selectorsToCapture.join(
+ ", ",
+ )}, error type '${afterRollbackState.errorCode}' and error message: ${
+ afterRollbackState.message
+ }`,
+ );
+ }
+
+ if (!afterRollbackState.safeArea || !afterRollbackState.ignoreAreas) {
+ throw new Error(
+ `Failed to rollback and recompute full areas while compositing image of selectors: ${selectorsToCapture.join(
+ ", ",
+ )}`,
+ );
+ }
+
+ currentState = afterRollbackState;
+ }
+ }
+
+ const callbackStartTime = performance.now();
+ await onNextScroll(currentState);
+ callbackTime += performance.now() - callbackStartTime;
+
+ const movingCaptureSpecs = getMovingCaptureSpecs(currentState, lastState);
+ hasCapturedTheWholeArea = movingCaptureSpecs.every(
+ s => getBottom(s.full) <= getBottom(currentState.safeArea),
+ );
+
+ if (hasCapturedTheWholeArea) {
+ break;
+ }
+
+ if (!opts.compositeImage) {
+ debug("compositeImage is false, exiting after the first iteration");
+ break;
+ }
+
+ hasReachedScrollLimit = iterations > 0 && currentState.scrollOffset <= lastState.scrollOffset;
+ if (hasReachedScrollLimit) {
+ break;
+ }
+
+ const remainingCaptureAreaHeight = getRemainingCaptureAreaHeight(
+ movingCaptureSpecs,
+ currentState.safeArea,
+ );
+ const scrollDelta = getScrollDelta(currentState.safeArea.height, remainingCaptureAreaHeight);
+
+ if (scrollDelta <= 0) {
+ hasCapturedTheWholeArea = true;
+ break;
+ }
+
+ debug(
+ "asking to scroll by %dpx (safeArea.height: %d, remaining moving capture area: %d)",
+ scrollDelta,
+ currentState.safeArea.height,
+ remainingCaptureAreaHeight,
+ );
+
+ const scrollStartTime = performance.now();
+ const scrollResult = await this._browserSideScreenshooter.call("scrollBy", [
+ selectorsToCapture,
+ scrollDelta,
+ opts.selectorToScroll,
+ enabledScrollDebugTopics,
+ ]);
+ scrollTime += performance.now() - scrollStartTime;
+ const scrollDebugLog = scrollResult.debugLog;
+ delete scrollResult.debugLog;
+ browserScrollDebug(scrollDebugLog);
+
+ debug("scrollResult: %O", scrollResult);
+
+ if (isBrowserSideError(scrollResult)) {
+ throw new Error(
+ `Failed to scroll once while compositing image of selectors: ${selectorsToCapture.join(
+ ", ",
+ )}, error type '${scrollResult.errorCode}' and error message: ${scrollResult.message}`,
+ );
+ }
+
+ lastState = currentState;
+ iterations++;
+ }
+ } finally {
+ perfDebug(` wait for stable capture area: ${formatDuration(waitForSettleTime)}`);
+ perfDebug(` recompute capture areas: ${formatDuration(recomputeTime)}`);
+ perfDebug(` scroll between chunks: ${formatDuration(scrollTime)}`);
+ perfDebug(` process current chunk: ${formatDuration(callbackTime)}`);
+ perfDebug(
+ ` loop overhead: ${formatDuration(
+ performance.now() - startTime - (waitForSettleTime + recomputeTime + scrollTime + callbackTime),
+ )}`,
+ );
+ perfDebug(` scroll loop total: ${formatDuration(performance.now() - startTime)}`);
+
+ debug(
+ `Scrolling finished after ${iterations} iterations, hasCapturedTheWholeArea: ${hasCapturedTheWholeArea}, hasReachedScrollLimit: ${hasReachedScrollLimit}`,
+ );
+ }
+ }
+
+ private async _performCaptureAttempt(
+ selectorsToCapture: string[],
+ selectorsToIgnore: string[],
+ page: PrepareScreenshotSuccess,
+ opts: ScreenShooterOpts,
+ shouldThrowOnCaptureAreaSizeChange: boolean,
+ ): Promise {
+ const perfDebug = makeDebug("testplane:screenshots:perf:" + opts.debugId);
+ const attemptMode = shouldThrowOnCaptureAreaSizeChange ? "strict" : "best-effort";
+ const image = CompositeImage.create();
+
+ let timeSpentOnCapture = 0;
+
+ let iterations = 0;
+ let isOverflowingViewport = false;
+ let hasReachedScrollLimit = false;
+ let hasCapturedTheWholeArea = false;
+ let restoreScrollPositionError: Error | null = null;
+
+ let lastState: CaptureState = {
+ viewportOffset: page.viewportOffset,
+ captureSpecs: page.captureSpecs,
+ scrollOffset: page.scrollOffset,
+ safeArea: page.safeArea,
+ ignoreAreas: page.ignoreAreas,
+ anchorShift: null,
+ };
+
+ let shouldRestoreScrollPosition = false;
+
+ perfDebug(`capture attempt (${attemptMode}): begin`);
+ try {
+ await this._scrollThroughCaptureArea(
+ selectorsToCapture,
+ selectorsToIgnore,
+ page,
+ opts,
+ async currentState => {
+ if (currentState.captureSpecs.length === 0) {
+ throw new Error(getEmptyCaptureSpecsErrorMessage(selectorsToCapture));
+ }
+
+ const hasCaptureAreaSizeChanged =
+ lastState.captureSpecs.length !== currentState.captureSpecs.length ||
+ lastState.captureSpecs.some(
+ (spec, index) =>
+ spec.full.width !== currentState.captureSpecs[index]?.full.width ||
+ spec.full.height !== currentState.captureSpecs[index]?.full.height,
+ );
+
+ if (hasCaptureAreaSizeChanged && shouldThrowOnCaptureAreaSizeChange) {
+ throw new CaptureAreaSizeChangeError();
+ }
+
+ const {
+ captureSpecs: newCaptureSpecs,
+ ignoreAreas: newIgnoreAreas,
+ safeArea: newSafeArea,
+ } = currentState;
+
+ const captureStartTime = performance.now();
+
+ const viewportImage = await this._camera.captureViewportImage({
+ viewportSize: page.viewportSize,
+ viewportOffset: currentState.viewportOffset,
+ screenshotDelay: opts.screenshotDelay,
+ cropMargins: opts.cropMargins,
+ });
+
+ timeSpentOnCapture += performance.now() - captureStartTime;
+
+ const expectedTotalMove = getExpectedTotalMoveFromBaseline(page.captureSpecs, newCaptureSpecs);
+ const observedTotalMove = currentState.anchorShift;
+
+ let correctionDelta = 0;
+ if (!shouldThrowOnCaptureAreaSizeChange && observedTotalMove !== null) {
+ correctionDelta = expectedTotalMove - observedTotalMove;
+ }
+
+ if (correctionDelta !== 0) {
+ debug("correctionDelta: %d (raw)", correctionDelta);
+ }
+
+ const correctionDeltaForComposite = Math.round(correctionDelta);
+ const correctionDeltaToApply = correctionDeltaForComposite === 0 ? 0 : -correctionDeltaForComposite;
+
+ await image.registerViewportImageAtOffset(
+ viewportImage,
+ newSafeArea,
+ newCaptureSpecs,
+ newIgnoreAreas,
+ correctionDeltaToApply,
+ );
+
+ hasReachedScrollLimit = iterations > 0 && currentState.scrollOffset <= lastState.scrollOffset;
+ const movingCaptureSpecs = getMovingCaptureSpecs(currentState, lastState);
+ hasCapturedTheWholeArea = movingCaptureSpecs.every(
+ s => getBottom(s.full) <= getBottom(newSafeArea),
+ );
+ isOverflowingViewport = newCaptureSpecs.some(s => getBottom(s.full) > page.viewportSize.height);
+
+ if (currentState.scrollOffset !== page.scrollOffset) {
+ shouldRestoreScrollPosition = true;
+ }
+
+ debug("newCaptureSpecs: %O", newCaptureSpecs);
+ debug("newSafeArea: %O", newSafeArea);
+ debug("lastState.captureSpecs: %O", lastState.captureSpecs);
+
+ lastState = currentState;
+ iterations++;
+ },
+ );
+ } finally {
+ perfDebug(` raw viewport screenshots: ${formatDuration(timeSpentOnCapture)}`);
+ if (shouldRestoreScrollPosition) {
+ const enabledScrollDebugTopics: string[] = [];
+ const browserScrollDebug = makeDebug("testplane:screenshots:browser:scrollTo");
+ if (browserScrollDebug.enabled) {
+ enabledScrollDebugTopics.push("scrollTo");
+ }
+
+ const restoreScrollResult = await this._browserSideScreenshooter.call("scrollTo", [
+ selectorsToCapture,
+ page.scrollOffset,
+ opts.selectorToScroll,
+ enabledScrollDebugTopics,
+ ]);
+ const restoreScrollDebugLog = restoreScrollResult.debugLog;
+ delete restoreScrollResult.debugLog;
+ browserScrollDebug(restoreScrollDebugLog);
+
+ if (isBrowserSideError(restoreScrollResult)) {
+ restoreScrollPositionError = new Error(
+ `Failed to restore scroll position after compositing image of selectors: ${selectorsToCapture.join(
+ ", ",
+ )}, error type '${restoreScrollResult.errorCode}' and error message: ${
+ restoreScrollResult.message
+ }`,
+ );
+ }
+ }
+ perfDebug(`capture attempt (${attemptMode}): end`);
+ }
+
+ if (restoreScrollPositionError) {
+ throw restoreScrollPositionError;
+ }
+
+ debug(
+ `Compositing finished after ${iterations} iterations, hasCapturedTheWholeArea: ${hasCapturedTheWholeArea}, hasReachedScrollLimit: ${hasReachedScrollLimit}`,
+ );
+
+ if (isOverflowingViewport && !opts.allowViewportOverflow) {
+ console.warn(
+ `Warning: when capturing the ${
+ opts.debugId ?? selectorsToCapture.join(", ")
+ } screenshot, we failed to capture the whole area.\n` +
+ `Here's what happened:\n` +
+ `- you requested to capture the following selectors: ${selectorsToCapture.join("; ")}\n` +
+ (opts.selectorToScroll
+ ? `- you requested to scroll the following selector: ${opts.selectorToScroll}`
+ : `- we auto-detected element to scroll ${page.readableSelectorToScrollDescr} and tried scrolling it\n`) +
+ `- we reached the scroll limit, but weren't able to capture the whole area\n\n` +
+ `Here's what you can do:\n` +
+ `- set allowViewportOverflow to true in assertView options to silence this warning\n` +
+ `- check and adjust selectors that you want to capture or selectorToScroll`,
+ );
+ }
+
+ return image;
+ }
+}
diff --git a/src/browser/screen-shooter/errors/horizontal-overflow-error.ts b/src/browser/screen-shooter/errors/horizontal-overflow-error.ts
new file mode 100644
index 000000000..2cc6a7dbe
--- /dev/null
+++ b/src/browser/screen-shooter/errors/horizontal-overflow-error.ts
@@ -0,0 +1,30 @@
+import type { Rect, Size } from "../../../image";
+
+export const getHorizontalOverflowErrorMessage = (
+ readableCaptureAreaDescr: string,
+ captureArea: Rect,
+ viewport: Size,
+): string => {
+ return `Could not capture ${readableCaptureAreaDescr} in full, because it is outside of horizontal viewport bounds.
+
+Tried to capture region: left=${captureArea.left}, top=${captureArea.top}, width=${captureArea.width}, height=${captureArea.height}
+Viewport size: ${viewport.width}, ${viewport.height}
+
+If this is expected, set "allowViewportOverflow" option of "assertView" command to true.
+
+Otherwise, check that this area:
+ - is not larger than browser viewport width
+ - is inside viewport (at least horizontally) before performing assertView
+Note that you can increase browser window size using "setWindowSize" command or "windowSize" option in the config file.
+You may use browser.scroll(x, y) or element.scrollIntoView() to scroll the page before performing assertView.`;
+};
+
+export class HorizontalOverflowError extends Error {
+ constructor(readableCaptureAreaDescr: string, captureArea: Rect, viewport: Size) {
+ const message = getHorizontalOverflowErrorMessage(readableCaptureAreaDescr, captureArea, viewport);
+
+ super(message);
+
+ this.name = this.constructor.name;
+ }
+}
diff --git a/src/browser/screen-shooter/errors/vertical-overflow-error.ts b/src/browser/screen-shooter/errors/vertical-overflow-error.ts
new file mode 100644
index 000000000..f12a9c2e2
--- /dev/null
+++ b/src/browser/screen-shooter/errors/vertical-overflow-error.ts
@@ -0,0 +1,26 @@
+import { Size } from "@testplane/webdriverio/build/commands/element";
+import { Rect } from "../../../image";
+
+export const getVerticalOverflowErrorMessage = (
+ readableCaptureAreaDescr: string,
+ captureArea: Rect,
+ viewport: Size,
+): string => {
+ return `Could not capture ${readableCaptureAreaDescr}, because it is larger than viewport height.
+
+Tried to capture region: left=${captureArea.left}, top=${captureArea.top}, width=${captureArea.width}, height=${captureArea.height}
+Viewport size: ${viewport.width}, ${viewport.height}
+
+If you want to capture the entire area, set "compositeImage" option to true in the config file or assertView command options.
+If you want to capture only the visible part and crop the rest, set "compositeImage" option to false and "allowViewportOverflow" to true.`;
+};
+
+export class VerticalOverflowError extends Error {
+ constructor(readableCaptureAreaDescr: string, captureArea: Rect, viewport: Size) {
+ const message = getVerticalOverflowErrorMessage(readableCaptureAreaDescr, captureArea, viewport);
+
+ super(message);
+
+ this.name = this.constructor.name;
+ }
+}
diff --git a/src/browser/screen-shooter/full-page-screen-shooter.ts b/src/browser/screen-shooter/full-page-screen-shooter.ts
new file mode 100644
index 000000000..ad2f7aa8f
--- /dev/null
+++ b/src/browser/screen-shooter/full-page-screen-shooter.ts
@@ -0,0 +1,275 @@
+import { CompositeImage } from "./composite-image";
+import { Image } from "../../image";
+import { Coord, Rect, Size, Point } from "../isomorphic/geometry";
+import { DisableHoverMode } from "../isomorphic/types";
+import type { WdioBrowser } from "../../types";
+import { Camera, type CropMargins } from "../camera";
+import type * as browserSideScreenshooterImplementation from "../client-scripts/screen-shooter/implementation";
+import type { ElementPositionsProbe } from "../client-scripts/screen-shooter/types";
+import { ClientBridge } from "../client-bridge";
+import { isBrowserSideError } from "../isomorphic/types";
+import {
+ disableIframeAnimations,
+ cleanupPageAnimations,
+ cleanupPointerEvents,
+ cleanupScrolls,
+ preparePointerForScreenshot,
+} from "./operations";
+import { runWithoutHistory } from "../history";
+import { COMPOSITING_ITERATIONS_LIMIT } from "./constants";
+import { makeVerboseScreenshotsDebug } from "./debug";
+
+const debug = makeVerboseScreenshotsDebug("testplane:screenshots:full-page-screen-shooter");
+
+interface ScreenShooterBrowserProperties {
+ isWebdriverProtocol: boolean;
+ shouldUsePixelRatio: boolean;
+ needsCompatLib: boolean;
+}
+
+interface FullPageScreenShooterInputParams {
+ camera: Camera;
+ browser: WdioBrowser;
+ browserProperties: ScreenShooterBrowserProperties;
+}
+
+interface FullPageScreenShooterFullParams extends FullPageScreenShooterInputParams {
+ browserSideScreenshooter: ClientBridge;
+}
+
+interface FullPageCaptureOpts {
+ screenshotDelay?: number;
+ disableAnimation?: boolean;
+ disableHover?: DisableHoverMode;
+ cropMargins?: CropMargins;
+}
+
+export class FullPageScreenShooter {
+ private _browser: WdioBrowser;
+ private _camera: Camera;
+ private _browserProperties: ScreenShooterBrowserProperties;
+ private _browserSideScreenshooter: ClientBridge;
+
+ static async create(params: FullPageScreenShooterInputParams): Promise {
+ const browserSideScreenshooter = await ClientBridge.create(
+ params.browser,
+ "screen-shooter",
+ { needsCompatLib: params.browserProperties.needsCompatLib },
+ );
+
+ return new this({ ...params, browserSideScreenshooter });
+ }
+
+ constructor({ browser, camera, browserProperties, browserSideScreenshooter }: FullPageScreenShooterFullParams) {
+ this._browser = browser;
+ this._camera = camera;
+ this._browserProperties = browserProperties;
+ this._browserSideScreenshooter = browserSideScreenshooter;
+ }
+
+ async capture(opts: FullPageCaptureOpts = {}): Promise {
+ if (!opts.disableHover) {
+ opts.disableHover = DisableHoverMode.WhenScrollingNeeded;
+ }
+
+ opts.disableAnimation ??= true;
+
+ try {
+ return await this._captureImpl(opts);
+ } finally {
+ try {
+ await this._cleanup(opts);
+ } catch (cleanupError) {
+ const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
+ console.warn(
+ `Warning: failed to cleanup after full page screenshot.\nCleanup error: ${cleanupMessage}`,
+ );
+ }
+ }
+ }
+
+ private async _captureImpl(opts: FullPageCaptureOpts): Promise {
+ const prepareResult = await this._browserSideScreenshooter.call("prepareFullPageScreenshot", [
+ {
+ usePixelRatio: this._browserProperties.shouldUsePixelRatio,
+ disableAnimation: opts.disableAnimation,
+ disableHover: opts.disableHover,
+ },
+ ]);
+
+ if (isBrowserSideError(prepareResult)) {
+ throw new Error(
+ `Failed to prepare full page screenshot: error '${prepareResult.errorCode}': ${prepareResult.message}`,
+ );
+ }
+
+ // https://github.com/webdriverio/webdriverio/issues/11396
+ if (this._browserProperties.isWebdriverProtocol && opts.disableAnimation) {
+ await disableIframeAnimations(this._browser, this._browserSideScreenshooter);
+ }
+
+ await preparePointerForScreenshot(this._browser, {
+ disableHover: opts.disableHover,
+ pointerEventsDisabled: prepareResult.pointerEventsDisabled,
+ });
+
+ const { documentSize, viewportSize, safeArea } = prepareResult;
+ let lastElementPositionsProbe = prepareResult.elementPositionsProbe;
+ let { viewportOffset } = prepareResult;
+
+ debug(
+ "Prepared full page screenshot.\n documentSize: %O\n viewportSize: %O\n viewportOffset: %O\n safeArea: %O",
+ documentSize,
+ viewportSize,
+ viewportOffset,
+ safeArea,
+ );
+
+ const fullPageRect = this._buildFullPageRect(documentSize, viewportOffset);
+
+ const viewportImage = await this._camera.captureViewportImage({
+ viewportSize,
+ viewportOffset,
+ screenshotDelay: opts.screenshotDelay,
+ cropMargins: opts.cropMargins,
+ });
+
+ const compositeImage = CompositeImage.create();
+ await compositeImage.registerViewportImageAtOffset(
+ viewportImage,
+ safeArea,
+ [{ full: fullPageRect, clip: fullPageRect, visible: fullPageRect }],
+ [],
+ );
+
+ let hasCapturedWholePage =
+ (viewportOffset.top as number) + (viewportSize.height as number) >= (documentSize.height as number);
+ let iterations = 0;
+
+ while (!hasCapturedWholePage && iterations < COMPOSITING_ITERATIONS_LIMIT) {
+ const scrollResult = await this._browserSideScreenshooter.call("scrollFullPage", [
+ safeArea.height,
+ { usePixelRatio: this._browserProperties.shouldUsePixelRatio },
+ ]);
+
+ if (isBrowserSideError(scrollResult)) {
+ throw new Error(
+ `Failed to scroll during full page capture: error '${scrollResult.errorCode}': ${scrollResult.message}`,
+ );
+ }
+
+ const hasReachedScrollLimit =
+ scrollResult.viewportOffset.top === viewportOffset.top &&
+ scrollResult.viewportOffset.left === viewportOffset.left;
+
+ if (hasReachedScrollLimit) {
+ debug("Reached scroll limit at viewportOffset: %O", viewportOffset);
+ break;
+ }
+
+ const hasElementPositionsChanged = !this._isElementPositionsProbeEqual(
+ lastElementPositionsProbe,
+ scrollResult.elementPositionsProbe,
+ );
+ if (!hasElementPositionsChanged) {
+ debug(
+ "Element positions probe did not change after scroll; falling back to initial viewport screenshot",
+ );
+ return viewportImage;
+ }
+
+ lastElementPositionsProbe = scrollResult.elementPositionsProbe;
+
+ viewportOffset = scrollResult.viewportOffset;
+
+ const updatedFullPageRect = this._buildFullPageRect(documentSize, viewportOffset);
+ const chunkImage = await this._camera.captureViewportImage({
+ viewportSize,
+ viewportOffset,
+ screenshotDelay: opts.screenshotDelay,
+ cropMargins: opts.cropMargins,
+ });
+
+ await compositeImage.registerViewportImageAtOffset(
+ chunkImage,
+ safeArea,
+ [{ full: updatedFullPageRect, clip: updatedFullPageRect, visible: updatedFullPageRect }],
+ [],
+ );
+
+ hasCapturedWholePage =
+ (viewportOffset.top as number) + (viewportSize.height as number) >= (documentSize.height as number);
+ iterations++;
+ }
+
+ debug("Full page capture complete. Iterations: %d, captured whole page: %s", iterations, hasCapturedWholePage);
+
+ return compositeImage.render();
+ }
+
+ private _isElementPositionsProbeEqual(
+ leftProbes: ElementPositionsProbe<"device">[],
+ rightProbes: ElementPositionsProbe<"device">[],
+ ): boolean {
+ if (leftProbes.length !== rightProbes.length) {
+ return false;
+ }
+
+ for (let i = 0; i < leftProbes.length; i++) {
+ const left = leftProbes[i];
+ const right = rightProbes[i];
+
+ if (left === null || right === null) {
+ if (left !== right) {
+ return false;
+ }
+ continue;
+ }
+
+ if (
+ left.left !== right.left ||
+ left.top !== right.top ||
+ left.width !== right.width ||
+ left.height !== right.height
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private async _cleanup(opts: FullPageCaptureOpts): Promise {
+ return runWithoutHistory({}, async () => {
+ await cleanupScrolls(this._browserSideScreenshooter);
+
+ if (opts.disableAnimation) {
+ await cleanupPageAnimations(
+ this._browser,
+ this._browserSideScreenshooter,
+ this._browserProperties.isWebdriverProtocol,
+ );
+ }
+ if (opts.disableHover && opts.disableHover !== "never") {
+ await cleanupPointerEvents(this._browserSideScreenshooter);
+ }
+ });
+ }
+
+ /**
+ * Builds the full-page rect in viewport coordinates.
+ * The rect always has the full document dimensions, but its top shifts
+ * upward as we scroll (the document "moves up" relative to the viewport).
+ */
+ private _buildFullPageRect(
+ documentSize: Size<"device">,
+ viewportOffset: Point<"page", "device">,
+ ): Rect<"viewport", "device"> {
+ return {
+ left: -(viewportOffset.left as number) as Coord<"viewport", "device", "x">,
+ top: -(viewportOffset.top as number) as Coord<"viewport", "device", "y">,
+ width: documentSize.width,
+ height: documentSize.height,
+ };
+ }
+}
diff --git a/src/browser/screen-shooter/index.js b/src/browser/screen-shooter/index.js
deleted file mode 100644
index 3434e805a..000000000
--- a/src/browser/screen-shooter/index.js
+++ /dev/null
@@ -1,50 +0,0 @@
-"use strict";
-
-const Viewport = require("./viewport");
-
-module.exports = class ScreenShooter {
- static create(browser) {
- return new this(browser);
- }
-
- constructor(browser) {
- this._browser = browser;
- }
-
- async capture(page, opts = {}) {
- const { allowViewportOverflow, compositeImage, screenshotDelay, selectorToScroll } = opts;
- const viewportOpts = { allowViewportOverflow, compositeImage };
- const cropImageOpts = { screenshotDelay, compositeImage, selectorToScroll };
-
- const capturedImage = await this._browser.captureViewportImage(page, screenshotDelay);
- const viewport = Viewport.create(page, capturedImage, viewportOpts);
- await viewport.handleImage(capturedImage);
-
- return this._extendScreenshot(viewport, page, cropImageOpts);
- }
-
- async _extendScreenshot(viewport, page, opts) {
- let shouldExtend = viewport.validate(this._browser);
-
- while (shouldExtend) {
- await this._extendImage(viewport, page, opts);
-
- shouldExtend = viewport.validate(this._browser);
- }
-
- return viewport.composite();
- }
-
- async _extendImage(viewport, page, opts) {
- const physicalScrollHeight = Math.min(viewport.getVerticalOverflow(), page.viewport.height);
- const logicalScrollHeight = Math.ceil(physicalScrollHeight / page.pixelRatio);
-
- await this._browser.scrollBy({ x: 0, y: logicalScrollHeight, selector: opts.selectorToScroll });
-
- page.viewport.top += physicalScrollHeight;
-
- const newImage = await this._browser.captureViewportImage(page, opts.screenshotDelay);
-
- await viewport.extendBy(physicalScrollHeight, newImage);
- }
-};
diff --git a/src/browser/screen-shooter/operations/animations.ts b/src/browser/screen-shooter/operations/animations.ts
new file mode 100644
index 000000000..37ea36381
--- /dev/null
+++ b/src/browser/screen-shooter/operations/animations.ts
@@ -0,0 +1,37 @@
+import { ClientBridge } from "../../client-bridge";
+import type * as browserSideScreenshooterImplementation from "../../client-scripts/screen-shooter/implementation";
+import { isBrowserSideError } from "../../isomorphic/types";
+import { runInEachDisplayedIframe } from "./iframe";
+import type { WdioBrowser } from "../../../types";
+
+type BrowserSideScreenshooter = ClientBridge;
+
+export async function disableIframeAnimations(
+ browser: WdioBrowser,
+ screenshooter: BrowserSideScreenshooter,
+): Promise {
+ await runInEachDisplayedIframe(browser, async () => {
+ const result = await screenshooter.call("disableFrameAnimations", []);
+
+ if (isBrowserSideError(result)) {
+ throw new Error(
+ `Disable animations failed with error type '${result.errorCode}' and error message: ${result.message}`,
+ );
+ }
+ });
+}
+
+export async function cleanupPageAnimations(
+ browser: WdioBrowser,
+ screenshooter: BrowserSideScreenshooter,
+ isWebdriverProtocol: boolean,
+): Promise