diff --git a/.eslintrc.js b/.eslintrc.js
index 64f949094..e694a78f8 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -45,7 +45,14 @@ module.exports = {
{
patterns: [
{
- group: ["../../**", "!../../isomorphic", "!../../isomorphic/**", "!../../..", "!../../../isomorphic", "!../../../isomorphic/**"],
+ group: [
+ "../../**",
+ "!../../isomorphic",
+ "!../../isomorphic/**",
+ "!../../..",
+ "!../../../isomorphic",
+ "!../../../isomorphic/**",
+ ],
message: "Client-scripts cannot import server-side code, except isomorphic modules.",
},
],
@@ -66,5 +73,13 @@ module.exports = {
"@typescript-eslint/no-var-requires": "off",
},
},
+ {
+ files: ["test/**"],
+ rules: {
+ "@typescript-eslint/no-empty-function": "off",
+ // For convenient casting of test objects
+ "@typescript-eslint/no-explicit-any": "off",
+ },
+ },
],
};
diff --git a/.github/workflows/browser-env.yml b/.github/workflows/browser-env.yml
index 22090b80b..05f7324b0 100644
--- a/.github/workflows/browser-env.yml
+++ b/.github/workflows/browser-env.yml
@@ -91,7 +91,7 @@ jobs:
uses: thollander/actions-comment-pull-request@v3
with:
message: ${{ env.PR_COMMENT }}
- comment-tag: testplane_results
+ comment-tag: testplane_browser_env_results
- name: Fail the job if any Testplane job is failed
if: ${{ steps.testplane.outcome != 'success' }}
diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml
index ec5237ccc..fe3d0c1b4 100644
--- a/.github/workflows/node.js.yml
+++ b/.github/workflows/node.js.yml
@@ -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') }}
run: npx pkg-pr-new publish
diff --git a/.github/workflows/standalone-e2e.yml b/.github/workflows/standalone-e2e.yml
index 6a87d7582..1cd6e20b5 100644
--- a/.github/workflows/standalone-e2e.yml
+++ b/.github/workflows/standalone-e2e.yml
@@ -7,6 +7,9 @@ on:
jobs:
integration-test:
runs-on: ubuntu-latest
+ env:
+ DOCKER_IMAGE_NAME: html-reporter-browsers
+
strategy:
matrix:
node-version: [20.18.1]
@@ -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 0c27a1d03..83d2c85da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,5 +27,3 @@ testplane/**
testplane-report/**
*.tsbuildinfo
test/browser-env/report/**
-test/src/browser/screen-shooter/composite-image/fixtures/**
-!test/src/browser/screen-shooter/composite-image/fixtures/generate.ts
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 69bfa5405..4a7e938d9 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -18,3 +18,5 @@ coverage/**
test/browser-env/report/**
*.png
*.DS_Store
+.claude
+tsc-out
diff --git a/package-lock.json b/package-lock.json
index 6baeb68f4..17f40ac7a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "testplane",
- "version": "9.0.0-rc.3",
+ "version": "9.0.0-rc.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "testplane",
- "version": "9.0.0-rc.3",
+ "version": "9.0.0-rc.4",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "7.24.2",
@@ -114,7 +114,7 @@
"eslint-config-prettier": "8.7.0",
"execa": "5.1.1",
"glob-extra": "5.0.2",
- "html-reporter": "11.8.3",
+ "html-reporter": "11.9.3",
"husky": "0.11.4",
"js-levenshtein": "1.1.6",
"jsdom": "^24.0.0",
@@ -6830,6 +6830,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,
@@ -9377,9 +9387,9 @@
"license": "MIT"
},
"node_modules/html-reporter": {
- "version": "11.8.3",
- "resolved": "https://registry.npmjs.org/html-reporter/-/html-reporter-11.8.3.tgz",
- "integrity": "sha512-wJh7JxRuT+xvgLRUZ+iIXSFhwoSK1KVbsIkscaetB4eVZN0+PDpUzQhrFLGgRT2VXGrJmNZJDPZJ4AClxydBfg==",
+ "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": [
@@ -9412,7 +9422,7 @@
"looks-same": "^10.0.1",
"nested-error-stacks": "^2.1.0",
"npm-which": "^3.0.1",
- "opener": "^1.4.3",
+ "open": "^8.4.2",
"ora": "^5.4.1",
"p-queue": "^5.0.0",
"qs": "^6.9.1",
@@ -11984,14 +11994,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/opener": {
- "version": "1.5.2",
- "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
- "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
+ "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": "(WTFPL OR MIT)",
- "bin": {
- "opener": "bin/opener-bin.js"
+ "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": {
@@ -20738,6 +20756,12 @@
"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
@@ -22394,9 +22418,9 @@
"dev": true
},
"html-reporter": {
- "version": "11.8.3",
- "resolved": "https://registry.npmjs.org/html-reporter/-/html-reporter-11.8.3.tgz",
- "integrity": "sha512-wJh7JxRuT+xvgLRUZ+iIXSFhwoSK1KVbsIkscaetB4eVZN0+PDpUzQhrFLGgRT2VXGrJmNZJDPZJ4AClxydBfg==",
+ "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",
@@ -22422,7 +22446,7 @@
"looks-same": "^10.0.1",
"nested-error-stacks": "^2.1.0",
"npm-which": "^3.0.1",
- "opener": "^1.4.3",
+ "open": "^8.4.2",
"ora": "^5.4.1",
"p-queue": "^5.0.0",
"qs": "^6.9.1",
@@ -24083,11 +24107,16 @@
"mimic-fn": "^2.1.0"
}
},
- "opener": {
- "version": "1.5.2",
- "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
- "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
- "dev": true
+ "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",
diff --git a/package.json b/package.json
index 78ff6259b..5d61e469c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "testplane",
- "version": "9.0.0-rc.3",
+ "version": "9.0.0-rc.4",
"description": "Tests framework based on mocha and wdio",
"main": "build/src/index.js",
"files": [
@@ -30,17 +30,17 @@
"lint": "eslint --cache . && prettier --check .",
"reformat": "eslint --fix . && prettier --write .",
"prettier-watch": "onchange '**' --exclude-path .prettierignore -- prettier --write {{changed}}",
- "test-unit": "npm run test-unit:generate-fixtures && _mocha \"test/!(integration|e2e|browser-env)/**/*.[jt]s\"",
- "test-unit:coverage": "c8 --all --src=src --reporter=html --reporter=text-summary --exclude=\"build/**\" --exclude=\"test/**\" --exclude=\"**/*.d.ts\" _mocha \"test/!(integration|e2e)/**/*.[jt]s\"",
- "test-unit:generate-fixtures": "TS_NODE_PROJECT=test/tsconfig.json node -r ts-node/register -r tsconfig-paths/register test/src/browser/screen-shooter/composite-image/fixtures/generate.ts",
+ "test-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": "TS_NODE_TRANSPILE_ONLY=1 mocha -r ts-node/register 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:generate-fixtures && npm run test-e2e:run-tests",
"test-e2e:run-tests": "node bin/testplane --config test/e2e/testplane.config.ts",
"test-e2e:generate-fixtures": "node bin/testplane --config test/e2e/fixtures/basic-report/testplane.config.ts || true",
"test-e2e:gui": "node bin/testplane --config test/e2e/testplane.config.ts gui",
- "test-browser-env": "NODE_OPTIONS='-r tsconfig-paths/register' TS_NODE_PROJECT=./test/browser-env/tsconfig.json node bin/testplane --config test/browser-env/testplane.config.ts",
- "test-browser-env:gui": "NODE_OPTIONS='-r tsconfig-paths/register' TS_NODE_PROJECT=./test/browser-env/tsconfig.json node bin/testplane gui --config test/browser-env/testplane.config.ts",
+ "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",
@@ -173,7 +173,7 @@
"eslint-config-prettier": "8.7.0",
"execa": "5.1.1",
"glob-extra": "5.0.2",
- "html-reporter": "11.8.3",
+ "html-reporter": "11.9.3",
"husky": "0.11.4",
"js-levenshtein": "1.1.6",
"jsdom": "^24.0.0",
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/browser/calibrator.ts b/src/browser/calibrator.ts
index bed954cdf..abbfecb6f 100644
--- a/src/browser/calibrator.ts
+++ b/src/browser/calibrator.ts
@@ -4,9 +4,12 @@ import looksSame from "looks-same";
import { CoreError } from "./core-error";
import { ExistingBrowser } from "./existing-browser";
import type { Image } from "../image";
-import { Coord, Length, Rect, XBand, getHeight, getIntersection, getWidth } from "./isomorphic";
+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 debug = makeDebug("testplane:screenshots:calibrator");
interface BrowserFeatures {
needsCompatLib: boolean;
@@ -16,6 +19,7 @@ interface BrowserFeatures {
export interface CalibrationResult extends BrowserFeatures {
viewportArea: Rect<"image", "device">;
+ screenshotSize: Size<"device">;
usePixelRatio: boolean;
}
@@ -33,12 +37,16 @@ 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 screenshotSize = (await image.getSize()) as Size<"device">;
const imageFeatures = await this._findMarkerAreaInImage(image);
if (!imageFeatures) {
@@ -57,6 +65,7 @@ export class Calibrator {
const calibratedFeatures: CalibrationResult = {
...features,
viewportArea: imageFeatures,
+ screenshotSize,
usePixelRatio: hasPixelRatio && imageFeatures.width > innerWidth,
};
@@ -93,7 +102,7 @@ export class Calibrator {
top: 0,
left: result.left,
width: result.width,
- height: getHeight(0 as Coord<"image", "device", "y">, y),
+ height: getHeight(0 as Coord<"image", "device", "y">, (y + 1) as Coord<"image", "device", "y">),
};
return getIntersection(topPart, bottomPart);
@@ -122,7 +131,7 @@ async function findMarkerXBandInRow(
return {
left: markerStart,
- width: getWidth(markerStart, markerEnd),
+ width: getWidth(markerStart, (markerEnd + 1) as Coord<"image", "device", "x">),
};
}
diff --git a/src/browser/camera/index.ts b/src/browser/camera/index.ts
index d9fc7e184..74df153dd 100644
--- a/src/browser/camera/index.ts
+++ b/src/browser/camera/index.ts
@@ -1,6 +1,5 @@
import os from "node:os";
import path from "node:path";
-import _ from "lodash";
import makeDebug from "debug";
import { Image } from "../../image";
@@ -33,6 +32,7 @@ export class Camera {
private _screenshotMode: ScreenshotMode;
private _takeScreenshot: () => Promise;
private _calibratedArea: Rect<"image", "device"> | null;
+ private _calibrationScreenshotSize: Size<"device"> | null;
private _debugTmpDir: string | null = null;
static create(screenshotMode: ScreenshotMode, takeScreenshot: () => Promise): Camera {
@@ -43,6 +43,7 @@ export class Camera {
this._screenshotMode = screenshotMode;
this._takeScreenshot = takeScreenshot;
this._calibratedArea = null;
+ this._calibrationScreenshotSize = null;
if (process.env.TESTPLANE_DEBUG_SCREENSHOTS) {
this._debugTmpDir = path.join(
@@ -53,9 +54,10 @@ export class Camera {
}
}
- calibrate(calibratedArea: Rect<"image", "device">): void {
- debug("Setting calibrated area: %O", calibratedArea);
+ 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(opts?: CaptureViewportImageOpts): Promise {
@@ -74,10 +76,21 @@ export class Camera {
height,
};
- const calibratedArea = this._cropAreaToCalibratedArea(imageArea);
+ const shouldApplyCalibration =
+ this._calibrationScreenshotSize !== null &&
+ this._calibrationScreenshotSize.width === width &&
+ this._calibrationScreenshotSize.height === height;
+ const calibrationArea = shouldApplyCalibration ? this._calibratedArea : null;
- const viewportCroppedArea = this._cropAreaToViewport(calibratedArea, { width, height }, opts);
- await utils.saveViewportImageForDebugIfNeeded(image, calibratedArea, this._debugTmpDir);
+ const calibratedImageArea = this._cropAreaToCalibratedArea(imageArea, calibrationArea);
+
+ const viewportCroppedArea = this._cropAreaToViewport(
+ calibratedImageArea,
+ { width, height },
+ calibrationArea,
+ opts,
+ );
+ await utils.saveViewportImageForDebugIfNeeded(image, calibratedImageArea, this._debugTmpDir);
if (viewportCroppedArea.width !== width || viewportCroppedArea.height !== height) {
await image.crop(viewportCroppedArea);
@@ -86,18 +99,19 @@ export class Camera {
return image;
}
- private _cropAreaToCalibratedArea(imageArea: Rect<"image", "device">): Rect<"image", "device"> {
- if (!this._calibratedArea) {
+ private _cropAreaToCalibratedArea(
+ imageArea: Rect<"image", "device">,
+ calibrationArea: Rect<"image", "device"> | null,
+ ): Rect<"image", "device"> {
+ if (!calibrationArea) {
return imageArea;
}
- const intersection = getIntersection(imageArea, this._calibratedArea);
+ const intersection = getIntersection(imageArea, calibrationArea);
if (intersection === null) {
logger.warn(
`No intersection found between image area and calibrated viewport area, falling back to original image area.\n` +
- `imageArea: ${prettyRect(imageArea)}, calibratedViewportArea: ${prettyRect(
- this._calibratedArea,
- )}\n` +
+ `imageArea: ${prettyRect(imageArea)}, calibratedViewportArea: ${prettyRect(calibrationArea)}\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.`,
);
@@ -113,6 +127,7 @@ export class Camera {
private _cropAreaToViewport(
imageAreaToCrop: Rect<"image", "device">,
originalImageSize: Size<"device">,
+ calibrationArea: Rect<"image", "device"> | null,
opts?: CaptureViewportImageOpts,
): Rect<"image", "device"> {
if (!opts?.viewportSize || !opts?.viewportOffset) {
@@ -122,7 +137,7 @@ export class Camera {
const isFullPage = utils.isFullPage(
imageAreaToCrop,
originalImageSize,
- this._calibratedArea ?? imageAreaToCrop,
+ calibrationArea ?? imageAreaToCrop,
this._screenshotMode,
);
const cropArea = { ...opts.viewportSize, ...opts.viewportOffset };
diff --git a/src/browser/client-scripts/calibrate.js b/src/browser/client-scripts/calibrate.js
index 3eb443ff7..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);
}
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
index 234a4e0ff..d7c7a37d6 100644
--- a/src/browser/client-scripts/screen-shooter/errors/outside-of-viewport.ts
+++ b/src/browser/client-scripts/screen-shooter/errors/outside-of-viewport.ts
@@ -7,7 +7,7 @@ export class OutsideOfViewportError extends Error {
constructor(debugLog?: string) {
super(
"Can not capture element, because it is completely outside of viewport with no intersection. " +
- 'Try to set "captureElementFromTop=true" to scroll to it before capture.'
+ 'Try to set "captureElementFromTop=true" assertView option to scroll to it before capture.'
);
this.name = "OutsideOfViewportError";
this.errorCode = BrowserSideErrorCode.OUTSIDE_OF_VIEWPORT;
diff --git a/src/browser/client-scripts/screen-shooter/implementation.ts b/src/browser/client-scripts/screen-shooter/implementation.ts
index 26f85042f..e5b4c3f62 100644
--- a/src/browser/client-scripts/screen-shooter/implementation.ts
+++ b/src/browser/client-scripts/screen-shooter/implementation.ts
@@ -6,11 +6,13 @@ import {
Length,
ceilCoords,
floorCoords,
+ fromBcrToRect,
fromCssToDevice,
fromCssToDeviceNumber,
fromDeviceToCssNumber,
getBottom,
getCoveringRect,
+ getIntersection,
roundCoords
} from "@isomorphic";
import {
@@ -21,7 +23,8 @@ import {
PrepareViewportScreenshotResult,
ScrollFullPageResult,
ScrollResult,
- GetCaptureStateResult
+ GetCaptureStateResult,
+ TrackedElementData
} from "./types";
import { createDebugLogger } from "../shared/logger";
import {
@@ -51,6 +54,101 @@ declare global {
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,
@@ -86,8 +184,8 @@ export function scrollBy(
debug?: string[]
): ScrollResult {
return safeCall((): ScrollResult => {
- const logger = createDebugLogger({ debug }, "scrollAndRecomputeAreas:scroll");
- const pixelRatio = computePixelRatio().pixelRatio;
+ const logger = createDebugLogger({ debug }, "scrollBy");
+ const pixelRatio = computePixelRatio();
const scrollTarget = selectorToScroll ? document.querySelector(selectorToScroll) : null;
const scrollElement = scrollTarget ?? getCommonScrollParent(selectorsToCapture);
@@ -117,8 +215,16 @@ export function scrollTo(
debug?: string[]
): ScrollResult {
return safeCall((): ScrollResult => {
- const logger = createDebugLogger({ debug }, "scrollAndRecomputeAreas:scroll");
- const pixelRatio = computePixelRatio().pixelRatio;
+ 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);
@@ -150,8 +256,8 @@ export function getCaptureState(
debug?: string[]
): GetCaptureStateResult {
return safeCall((): GetCaptureStateResult => {
- const logger = createDebugLogger({ debug }, "scrollAndRecomputeAreas:scroll");
- const pixelRatio = computePixelRatio().pixelRatio;
+ const logger = createDebugLogger({ debug }, "getCaptureState");
+ const pixelRatio = computePixelRatio();
const scrollTarget = selectorToScroll ? document.querySelector(selectorToScroll) : null;
const scrollElement = scrollTarget ?? getCommonScrollParent(selectorsToCapture);
const readableAutoScrollElementDescr = getReadableElementDescriptor(scrollElement);
@@ -160,14 +266,18 @@ export function getCaptureState(
? `${selectorToScroll} (${readableAutoScrollElementDescr})`
: `${selectorToScroll} (not found, auto-detected ${readableAutoScrollElementDescr})`
: `auto-detected ${readableAutoScrollElementDescr}`;
- const ignoreAreas = computeIgnoreAreas(selectorsToIgnore).ignoreAreas;
- const safeArea = computeSafeArea(selectorsToCapture, scrollElement, logger).safeArea;
- const captureSpecsAfterCss = computeCaptureSpecs(selectorsToCapture, logger).captureSpecs;
+ 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),
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);
@@ -176,6 +286,8 @@ export function getCaptureState(
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()
};
@@ -188,14 +300,14 @@ export function prepareFullPageScreenshot(
return safeCall((): PrepareFullPageScreenshotResult => {
prepareFullPageScrollCleanup();
- const pixelRatio = computePixelRatio(opts.usePixelRatio).pixelRatio;
+ const pixelRatio = computePixelRatio(opts.usePixelRatio);
window.scrollTo(0, 0);
- const documentSize = computeDocumentSize().documentSize;
- const viewportSize = computeViewportSize().viewportSize;
- const viewportOffset = computeViewportOffset().viewportOffset;
- const safeArea = computeSafeArea(["body"], document.documentElement).safeArea;
+ const documentSize = computeDocumentSize();
+ const viewportSize = computeViewportSize();
+ const viewportOffset = computeViewportOffset();
+ const safeArea = computeSafeArea(["body"], document.documentElement);
if (opts.disableAnimation) {
disableAnimations();
@@ -235,13 +347,13 @@ export function scrollFullPage(
opts: { usePixelRatio?: boolean } = {}
): ScrollFullPageResult {
return safeCall((): ScrollFullPageResult => {
- const pixelRatio = computePixelRatio(opts.usePixelRatio).pixelRatio;
+ 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().viewportOffset;
+ const viewportOffset = computeViewportOffset();
const elementPositionsProbe = computeElementPositionsProbe().map(rect =>
rect ? fromCssToDevice(roundCoords(rect), pixelRatio) : null
);
@@ -257,11 +369,11 @@ export function prepareViewportScreenshot(
opts: { usePixelRatio?: boolean; disableAnimation?: boolean; disableHover?: DisableHoverMode } = {}
): PrepareViewportScreenshotResult {
return safeCall((): PrepareViewportScreenshotResult => {
- const pixelRatio = computePixelRatio(opts.usePixelRatio).pixelRatio;
- const viewportSize = computeViewportSize().viewportSize;
- const viewportOffset = computeViewportOffset().viewportOffset;
- const documentSize = computeDocumentSize().documentSize;
- const canHaveCaret = computeCanHaveCaret().canHaveCaret;
+ const pixelRatio = computePixelRatio(opts.usePixelRatio);
+ const viewportSize = computeViewportSize();
+ const viewportOffset = computeViewportOffset();
+ const documentSize = computeDocumentSize();
+ const canHaveCaret = computeCanHaveCaret();
if (opts.disableAnimation) {
disableAnimations();
@@ -306,14 +418,59 @@ export function cleanupPointerEvents(): void {
}
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, "prepareScreenshot:areas-computation");
+ const logger = createDebugLogger(opts, "prepareElementsScreenshot");
saveScrollPositions(selectorsToCapture, opts.selectorToScroll);
@@ -329,19 +486,19 @@ function prepareElementsScreenshotUnsafe(
disableAnimations();
}
- const pixelRatio = computePixelRatio(opts.usePixelRatio).pixelRatio;
+ const pixelRatio = computePixelRatio(opts.usePixelRatio);
const scrollTarget = opts.selectorToScroll ? document.querySelector(opts.selectorToScroll) : null;
const scrollElement = scrollTarget ?? getCommonScrollParent(selectorsToCapture);
- const ignoreAreas = computeIgnoreAreas(opts.ignoreSelectors).ignoreAreas;
- const captureSpecs = computeCaptureSpecs(selectorsToCapture, logger).captureSpecs;
- const viewportSize = computeViewportSize().viewportSize;
- const viewportOffset = computeViewportOffset().viewportOffset;
- const safeArea = computeSafeArea(selectorsToCapture, scrollElement, logger).safeArea;
+ 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().documentSize;
- const canHaveCaret = computeCanHaveCaret().canHaveCaret;
+ const documentSize = computeDocumentSize();
+ const canHaveCaret = computeCanHaveCaret();
let pointerEventsDisabled = false;
if (opts.disableHover === DisableHoverMode.Always) {
diff --git a/src/browser/client-scripts/screen-shooter/operations.ts b/src/browser/client-scripts/screen-shooter/operations.ts
index b24b919a5..3218ae7ab 100644
--- a/src/browser/client-scripts/screen-shooter/operations.ts
+++ b/src/browser/client-scripts/screen-shooter/operations.ts
@@ -1,7 +1,10 @@
import {
Coord,
Length,
+ Point,
Rect,
+ Size,
+ YBand,
fromBcrToRect,
getBottom,
getCoveringRect,
@@ -9,18 +12,7 @@ import {
getIntersection
} from "@isomorphic";
import { OutsideOfViewportError } from "./errors/outside-of-viewport";
-import {
- ComputeCanHaveCaretResult,
- ComputeDocumentSizeResult,
- ComputeCaptureSpecsResult,
- ComputeIgnoreAreasResult,
- ComputePixelRatioResult,
- ComputeSafeAreaResult,
- ComputeViewportOffsetResult,
- ComputeViewportSizeResult,
- SavedScrollPosition as ElementScrollPosition,
- ScrollToCaptureSpecResult
-} from "./types";
+import { CaptureSpec, SavedScrollPosition as ElementScrollPosition, ScrollToCaptureSpecResult } from "./types";
import { getReadableElementDescriptor } from "./utils/descriptions";
import {
findContainingBlock,
@@ -55,21 +47,17 @@ export function computeScrollOffset(element: Element): Coord<"page", "css", "y">
return (isRootLikeElement(element) ? window.scrollY : element.scrollTop) as Coord<"page", "css", "y">;
}
-export function computeViewportSize(): ComputeViewportSizeResult {
+export function computeViewportSize(): Size<"css"> {
return {
- viewportSize: {
- width: window.innerWidth as Length<"css", "x">,
- height: window.innerHeight as Length<"css", "y">
- }
+ width: window.innerWidth as Length<"css", "x">,
+ height: window.innerHeight as Length<"css", "y">
};
}
-export function computeViewportOffset(): ComputeViewportOffsetResult {
+export function computeViewportOffset(): Point<"page", "css"> {
return {
- viewportOffset: {
- left: window.scrollX as Coord<"page", "css", "x">,
- top: window.scrollY as Coord<"page", "css", "y">
- }
+ left: window.scrollX as Coord<"page", "css", "x">,
+ top: window.scrollY as Coord<"page", "css", "y">
};
}
@@ -95,7 +83,7 @@ function getProbeAxisCoordinates(length: number, gridSize: number): number[] {
export function computeElementPositionsProbe(
gridSize = ELEMENT_POSITIONS_PROBE_GRID_SIZE
): Array | null> {
- const viewportSize = computeViewportSize().viewportSize;
+ const viewportSize = computeViewportSize();
const xCoordinates = getProbeAxisCoordinates(viewportSize.width as number, gridSize);
const yCoordinates = getProbeAxisCoordinates(viewportSize.height as number, gridSize);
const probe: Array | null> = [];
@@ -115,7 +103,7 @@ export function computeElementPositionsProbe(
export function computeCaptureSpecs(
selectors: string[],
logger?: (...args: unknown[]) => unknown
-): ComputeCaptureSpecsResult {
+): CaptureSpec<"viewport", "css">[] {
if (selectors.length === 0) {
throw new Error("No selectors to compute capture area");
}
@@ -156,10 +144,10 @@ export function computeCaptureSpecs(
logger?.("computeCaptureSpecs time taken:", (performance.now() - startTime).toFixed(1) + "ms");
logger?.("========== ==========");
- return { captureSpecs };
+ return captureSpecs;
}
-export function computeIgnoreAreas(selectors: string[] = []): ComputeIgnoreAreasResult {
+export function computeIgnoreAreas(selectors: string[] = []): Rect<"viewport", "css">[] {
const ignoreAreas: Rect<"viewport", "css">[] = [];
for (let s = 0; s < selectors.length; s++) {
@@ -175,18 +163,18 @@ export function computeIgnoreAreas(selectors: string[] = []): ComputeIgnoreAreas
}
}
- return { ignoreAreas };
+ return ignoreAreas;
}
export function computeSafeArea(
selectorsToCapture: string[],
scrollElement?: Element,
logger?: (...args: unknown[]) => unknown
-): ComputeSafeAreaResult {
+): YBand<"viewport", "css"> {
logger?.("========== ==========");
const startTime = performance.now();
- const viewportSize = computeViewportSize().viewportSize;
+ const viewportSize = computeViewportSize();
const viewportRect: Rect<"viewport", "css"> = {
left: 0 as Coord<"viewport", "css", "x">,
top: 0 as Coord<"viewport", "css", "y">,
@@ -196,14 +184,13 @@ export function computeSafeArea(
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 (captureElements.length === 0) {
- return {
- safeArea: { top: viewportRect.top, height: viewportRect.height }
- };
+ if (captureSpecs.length === 0) {
+ return { top: viewportRect.top, height: viewportRect.height };
}
- const captureArea = getCoveringRect(computeCaptureSpecs(selectorsToCapture).captureSpecs.map(s => s.full));
+ const captureArea = getCoveringRect(captureSpecs);
const scrollEl = scrollElement ?? document.documentElement;
// 1. Base safe area equals the visible rectangle of the scroll container
@@ -358,18 +345,18 @@ export function computeSafeArea(
const br = interference.rect;
const safeBottom = getBottom({ top: safeTop, height: safeHeight });
const brBottom = getBottom(br);
- const shrinkTop = brBottom > safeTop ? brBottom - safeTop : null;
- const shrinkBottom = safeBottom > br.top ? safeBottom - br.top : null;
+ const shrinkTop = brBottom - safeTop;
+ const shrinkBottom = safeBottom - br.top;
let resultingTop = safeTop;
let resultingHeight = safeHeight;
- if (shrinkTop && shrinkBottom && shrinkTop < shrinkBottom) {
- resultingTop = brBottom;
+ 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 = getHeight(safeTop, br.top);
+ resultingHeight = Math.min(safeHeight, br.top - safeTop) as Length<"css", "y">;
logger?.("decided to shrink bottom");
}
@@ -405,40 +392,36 @@ export function computeSafeArea(
logger?.("computeSafeArea time taken:", (performance.now() - startTime).toFixed(1) + "ms");
logger?.("========== ==========");
- return {
- safeArea: finalSafeArea
- };
+ return finalSafeArea;
}
-export function computeDocumentSize(): ComputeDocumentSizeResult {
+export function computeDocumentSize(): Size<"css"> {
const mainDocumentElem = getMainDocumentElem();
return {
- documentSize: {
- width: mainDocumentElem.scrollWidth as Length<"css", "x">,
- height: mainDocumentElem.scrollHeight as Length<"css", "y">
- }
+ width: mainDocumentElem.scrollWidth as Length<"css", "x">,
+ height: mainDocumentElem.scrollHeight as Length<"css", "y">
};
}
-export function computeCanHaveCaret(): ComputeCanHaveCaretResult {
+export function computeCanHaveCaret(): boolean {
const el = document.activeElement;
const canHaveCaret = el instanceof HTMLElement && (/^(input|textarea)$/i.test(el.tagName) || el.isContentEditable);
- return { canHaveCaret };
+ return canHaveCaret;
}
-export function computePixelRatio(usePixelRatio: boolean = true): ComputePixelRatioResult {
+export function computePixelRatio(usePixelRatio: boolean = true): number {
if (usePixelRatio === false) {
- return { pixelRatio: 1 };
+ return 1;
}
if (window.devicePixelRatio) {
- return { pixelRatio: window.devicePixelRatio };
+ return window.devicePixelRatio;
}
// for ie6-ie10 (https://developer.mozilla.org/ru/docs/Web/API/Window/devicePixelRatio)
// @ts-expect-error - IE hack
- return { pixelRatio: window.screen.deviceXDPI / window.screen.logicalXDPI || 1 };
+ return window.screen.deviceXDPI / window.screen.logicalXDPI || 1;
}
export function scrollToCaptureAreaIfNeeded(
@@ -448,7 +431,7 @@ export function scrollToCaptureAreaIfNeeded(
selectorToScroll?: string,
logger?: (...args: unknown[]) => unknown
): ScrollToCaptureSpecResult {
- const viewportSize = computeViewportSize().viewportSize;
+ const viewportSize = computeViewportSize();
const viewport = {
top: 0 as Coord<"viewport", "css", "y">,
left: 0 as Coord<"viewport", "css", "x">,
@@ -458,9 +441,8 @@ export function scrollToCaptureAreaIfNeeded(
const captureSpecsResult = computeCaptureSpecs(selectorsToCapture);
if (!captureSpecsResult) return {};
- const captureArea = getCoveringRect(captureSpecsResult.captureSpecs.map(s => s.full));
- // const captureElements = selectorsToCapture.flatMap(s => Array.from(document.querySelectorAll(s)));
- const safeArea = computeSafeArea(selectorsToCapture).safeArea;
+ const captureArea = getCoveringRect(captureSpecsResult.map(s => s.full));
+ const safeArea = computeSafeArea(selectorsToCapture);
const captureAndSafeAreasIntersection = getIntersection(captureArea, safeArea);
const captureAndViewportIntersection = getIntersection(captureArea, viewport);
@@ -518,7 +500,7 @@ export function scrollToCaptureAreaIfNeeded(
}
for (let i = 1; i < scrollChain.length; i++) {
- const currentSafeArea = computeSafeArea(selectorsToCapture, scrollChain[i - 1]).safeArea;
+ const currentSafeArea = computeSafeArea(selectorsToCapture, scrollChain[i - 1]);
const childTop = scrollChain[i].getBoundingClientRect().top;
const scrollDelta = childTop - currentSafeArea.top;
logger?.("scrollToCaptureAreaIfNeeded: scrolling chain element", {
@@ -529,10 +511,10 @@ export function scrollToCaptureAreaIfNeeded(
scrollElementBy(scrollChain[i - 1], scrollDelta as Coord<"page", "css", "y">, logger);
}
- const finalCaptureArea = getCoveringRect(computeCaptureSpecs(selectorsToCapture).captureSpecs.map(s => s.full));
+ const finalCaptureArea = getCoveringRect(computeCaptureSpecs(selectorsToCapture).map(s => s.full));
if (!finalCaptureArea) return {};
- const finalSafeArea = computeSafeArea(selectorsToCapture, initialScrollElem).safeArea;
+ const finalSafeArea = computeSafeArea(selectorsToCapture, initialScrollElem);
const finalScrollDelta = finalCaptureArea.top - finalSafeArea.top;
logger?.("scrollToCaptureAreaIfNeeded: final alignment scroll", {
scrollElement: readableSelectorToScrollDescr,
diff --git a/src/browser/client-scripts/screen-shooter/types.ts b/src/browser/client-scripts/screen-shooter/types.ts
index 9cc9c74fa..91520e7c6 100644
--- a/src/browser/client-scripts/screen-shooter/types.ts
+++ b/src/browser/client-scripts/screen-shooter/types.ts
@@ -7,11 +7,20 @@ export interface CaptureSpec {
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 {
@@ -23,6 +32,7 @@ export interface SavedScrollPosition {
export interface ScreenshooterNamespaceData {
cleanupPointerEventsCb?: () => void;
savedScrollPositions?: SavedScrollPosition[];
+ trackedElementsData?: TrackedElementData[];
}
export interface PrepareScreenshotOptions {
@@ -41,8 +51,6 @@ 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">;
- // Current scroll position of the scroll element, if window is being used, this will always be 0
- // scrollElementOffset: Point;
// 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
@@ -73,42 +81,6 @@ export interface ScrollToCaptureSpecResult {
readableSelectorToScrollDescr?: string;
}
-export interface ComputeCaptureSpecsResult {
- captureSpecs: CaptureSpec<"viewport", "css">[];
-}
-
-export interface ComputeCaptureSpecResult {
- captureArea: Rect<"viewport", "css">;
-}
-
-export interface ComputeIgnoreAreasResult {
- ignoreAreas: Rect<"viewport", "css">[];
-}
-
-export interface ComputeSafeAreaResult {
- safeArea: YBand<"viewport", "css">;
-}
-
-export interface ComputeDocumentSizeResult {
- documentSize: Size<"css">;
-}
-
-export interface ComputeCanHaveCaretResult {
- canHaveCaret: boolean;
-}
-
-export interface ComputePixelRatioResult {
- pixelRatio: number;
-}
-
-export interface ComputeViewportSizeResult {
- viewportSize: Size<"css">;
-}
-
-export interface ComputeViewportOffsetResult {
- viewportOffset: Point<"page", "css">;
-}
-
export type ElementPositionsProbe = Array | null>;
export interface PrepareFullPageScreenshotSuccess {
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
index 7a7f2bc89..8b47463ea 100644
--- a/src/browser/client-scripts/screen-shooter/utils/pseudo-element-rect.ts
+++ b/src/browser/client-scripts/screen-shooter/utils/pseudo-element-rect.ts
@@ -136,32 +136,30 @@ function applyTransformToRect(rect: Rect<"viewport", "css">, css: CSSStyleDeclar
const matrix = parseTransformMatrix(css.transform);
if (!matrix) return rect;
- const w = rect.width as number;
- const h = rect.height as number;
- const origin = resolveTransformOrigin(css.transformOrigin, w, h);
- const ox = (rect.left as number) + origin.x;
- const oy = (rect.top as number) + origin.y;
+ 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 as number, rect.top as number],
- [(rect.left as number) + w, rect.top as number],
- [rect.left as number, (rect.top as number) + h],
- [(rect.left as number) + w, (rect.top as number) + h]
+ [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 [cx, cy] of corners) {
- const dx = cx - ox,
- dy = cy - oy;
- const nx = matrix.a * dx + matrix.c * dy + matrix.tx + ox;
- const ny = matrix.b * dx + matrix.d * dy + matrix.ty + oy;
- minX = Math.min(minX, nx);
- maxX = Math.max(maxX, nx);
- minY = Math.min(minY, ny);
- maxY = Math.max(maxY, ny);
+ 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 {
diff --git a/src/browser/client-scripts/shared/polyfills/getComputedStyle.ts b/src/browser/client-scripts/shared/polyfills/getComputedStyle.ts
index 6ea638bb4..e97d7357a 100644
--- a/src/browser/client-scripts/shared/polyfills/getComputedStyle.ts
+++ b/src/browser/client-scripts/shared/polyfills/getComputedStyle.ts
@@ -3,9 +3,10 @@
*/
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-next-line @typescript-eslint/no-explicit-any
+ /* 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];
@@ -139,8 +140,9 @@ export { CSSStyleDeclaration };
// .getComputedStyle
export function getComputedStyle(element: Element, pseudoEl: string): CSSStyleDeclaration {
// IE9 needs matchMedia support but already support getComputedStyle
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ /* 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/commands/assert-view/index.js b/src/browser/commands/assert-view/index.js
index 55e29f157..27a5612fa 100644
--- a/src/browser/commands/assert-view/index.js
+++ b/src/browser/commands/assert-view/index.js
@@ -32,12 +32,9 @@ const getIgnoreDiffPixelCountRatio = value => {
};
module.exports.default = browser => {
- const browserProperties = {
- isWebdriverProtocol: browser.isWebdriverProtocol,
- shouldUsePixelRatio: browser.shouldUsePixelRatio,
- needsCompatLib: browser.needsCompatLib,
- };
- const screenShooterPromise = ElementsScreenShooter.create({
+ const { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib } = browser;
+ const browserProperties = { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib };
+ const elementsScreenShooterPromise = ElementsScreenShooter.create({
camera: browser.camera,
browser: browser.publicAPI,
browserProperties,
@@ -173,7 +170,7 @@ module.exports.default = browser => {
debug(`[${debugId}] assertView selectors: %O`, selectors);
debug(`[${debugId}] assertView opts: %O`, opts);
- const screenShooter = await screenShooterPromise;
+ const screenShooter = await elementsScreenShooterPromise;
const { image, meta } = await screenShooter.capture(selectors, opts);
return compareScreenshot(state, image, meta, opts);
diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts
index 1212cb562..47a888f8a 100644
--- a/src/browser/existing-browser.ts
+++ b/src/browser/existing-browser.ts
@@ -179,11 +179,7 @@ export class ExistingBrowser extends Browser {
return this._calibration ? this._calibration.needsCompatLib : false;
}
- async captureViewportImage(opts?: CaptureViewportImageOpts, screenshotDelay?: number): Promise {
- if (screenshotDelay) {
- await new Promise(resolve => setTimeout(resolve, screenshotDelay));
- }
-
+ async captureViewportImage(opts?: CaptureViewportImageOpts): Promise {
return this._camera.captureViewportImage(opts);
}
@@ -468,7 +464,7 @@ export class ExistingBrowser extends Browser {
return calibrator.calibrate(this).then(calibration => {
this._calibration = calibration;
- this._camera.calibrate(calibration.viewportArea);
+ this._camera.calibrate(calibration.viewportArea, calibration.screenshotSize);
});
}
diff --git a/src/browser/isomorphic/geometry.ts b/src/browser/isomorphic/geometry.ts
index fc6b9a216..2ea4f8f60 100644
--- a/src/browser/isomorphic/geometry.ts
+++ b/src/browser/isomorphic/geometry.ts
@@ -117,6 +117,12 @@ export const getIntersection = <
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>;
};
diff --git a/src/browser/isomorphic/tsconfig.json b/src/browser/isomorphic/tsconfig.json
index fe7f3e52d..85e2d4ee8 100644
--- a/src/browser/isomorphic/tsconfig.json
+++ b/src/browser/isomorphic/tsconfig.json
@@ -3,6 +3,6 @@
"include": ["."],
"compilerOptions": {
"composite": true,
- "declaration": true,
+ "declaration": true
}
-}
\ No newline at end of file
+}
diff --git a/src/browser/screen-shooter/README.md b/src/browser/screen-shooter/README.md
index 05027043e..5efd9e260 100644
--- a/src/browser/screen-shooter/README.md
+++ b/src/browser/screen-shooter/README.md
@@ -7,6 +7,12 @@

+### Debugging
+
+Screenshots logic is heavily covered by debug logs. You can turn them on by setting `DEBUG` environment variable to `testplane:screenshots*`, various namespaces are available.
+
+You can also use `TESTPLANE_DEBUG_SCREENSHOTS` environment variable to save viewport images with debug rectangles to a directory (the directory will be created and logged to console).
+
### Algorithm overview
Overall, we have two stages: coordinates computation and screenshot capturing itself.
diff --git a/src/browser/screen-shooter/composite-image/debug-utils.ts b/src/browser/screen-shooter/composite-image/debug-utils.ts
index b5c6f8040..cef6593f7 100644
--- a/src/browser/screen-shooter/composite-image/debug-utils.ts
+++ b/src/browser/screen-shooter/composite-image/debug-utils.ts
@@ -13,12 +13,15 @@ export type ViewportDebugRect = {
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 */
+/*
+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 },
- captureSpecVisible: { r: 255, g: 0, b: 0, a: 255 },
- visibleCoveringRect: { r: 255, g: 105, b: 180, a: 255 },
+ 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 => {
diff --git a/src/browser/screen-shooter/composite-image/index.ts b/src/browser/screen-shooter/composite-image/index.ts
index 68efe9a87..e8681bd0b 100644
--- a/src/browser/screen-shooter/composite-image/index.ts
+++ b/src/browser/screen-shooter/composite-image/index.ts
@@ -35,6 +35,8 @@ interface CompositeChunk {
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. */
@@ -91,6 +93,7 @@ export class CompositeImage {
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));
@@ -129,6 +132,7 @@ export class CompositeImage {
safeArea,
captureSpecs,
boundingRectsToIgnore: ignoreBoundingRects,
+ anchorShift,
});
}
@@ -208,10 +212,17 @@ export class CompositeImage {
}
/**
- * Computes anchor tops for all chunks by comparing element rects against a reference chunk
- * (the one with the highest covering rect top). Reference chunk gets anchorTop = its covering rect top.
- * Other chunks get anchorTop = referenceCoveringRectTop - maxDelta, where maxDelta is the max
- * downward movement of any element rect compared to the reference.
+ * 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;
@@ -225,7 +236,9 @@ export class CompositeImage {
}
}
- const referenceCaptureSpecs = this._compositeChunks[referenceIndex].captureSpecs;
+ const referenceChunk = this._compositeChunks[referenceIndex];
+ const referenceCaptureSpecs = referenceChunk.captureSpecs;
+ const referenceAnchorShift = referenceChunk.anchorShift;
const anchoredChunks = this._compositeChunks.map((chunk, index) => {
if (index === referenceIndex) {
@@ -248,76 +261,25 @@ export class CompositeImage {
}
}
+ 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: ((referenceCoveringRectTop as number) - maxDelta) as Coord<"viewport", "device", "y">,
+ anchorTop: (anchorTopFromSpecs + shiftCorrection) as Coord<"viewport", "device", "y">,
};
});
debug("Anchored chunks: %O", anchoredChunks);
- this._applyHeightChangeAnchorFixups(anchoredChunks);
-
return anchoredChunks;
}
- private _applyHeightChangeAnchorFixups(chunks: AnchoredChunk[]): void {
- if (chunks.length < 2) {
- return;
- }
-
- debug("Applying height change anchor fixups");
-
- const sortedChunks = chunks.slice().sort((a, b) => subtractCoords(b.anchorTop, a.anchorTop));
-
- for (let i = 1; i < sortedChunks.length; i++) {
- const previousChunk = sortedChunks[i - 1];
- const currentChunk = sortedChunks[i];
- const heightDelta = this._getHeightDeltaFromDifferingCaptureSpec(previousChunk, currentChunk);
-
- if (!this._shouldTreatHeightChangeAsStartShift(heightDelta)) {
- continue;
- }
-
- debug(
- "Shifting anchor top for chunk %d by %d. Old anchor top: %d, new anchor top: %d",
- i,
- heightDelta,
- currentChunk.anchorTop,
- currentChunk.anchorTop - heightDelta,
- );
- currentChunk.anchorTop = ((currentChunk.anchorTop as number) - heightDelta) as Coord<
- "viewport",
- "device",
- "y"
- >;
- }
- }
-
- private _getHeightDeltaFromDifferingCaptureSpec(
- previousChunk: AnchoredChunk,
- currentChunk: AnchoredChunk,
- ): number | null {
- const minLength = Math.min(previousChunk.captureSpecs.length, currentChunk.captureSpecs.length);
-
- for (let i = 0; i < minLength; i++) {
- const previousSpecHeight = previousChunk.captureSpecs[i].full.height as number;
- const currentSpecHeight = currentChunk.captureSpecs[i].full.height as number;
-
- if (previousSpecHeight !== currentSpecHeight) {
- return previousSpecHeight - currentSpecHeight;
- }
- }
-
- return null;
- }
-
- private _shouldTreatHeightChangeAsStartShift(heightDelta: number | null): heightDelta is number {
- const shouldShiftFromStart = true;
-
- return shouldShiftFromStart && typeof heightDelta === "number" && heightDelta > 0;
- }
-
private _isRenderableCaptureSpec(spec: CaptureSpec<"viewport", "device">): boolean {
return spec.visible.width > 0 && spec.visible.height > 0;
}
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/elements-screen-shooter.ts b/src/browser/screen-shooter/elements-screen-shooter.ts
index 6d6d7a9ae..c43f5eb9f 100644
--- a/src/browser/screen-shooter/elements-screen-shooter.ts
+++ b/src/browser/screen-shooter/elements-screen-shooter.ts
@@ -18,17 +18,21 @@ import { Camera } from "../camera";
import type * as browserSideScreenshooterImplementation from "../client-scripts/screen-shooter/implementation";
import { ClientBridge } from "../client-bridge";
import type {
- CaptureSpec,
CaptureState,
PrepareScreenshotOptions,
PrepareScreenshotSuccess,
} from "../client-scripts/screen-shooter/types";
import { isBrowserSideError } from "../isomorphic/types";
-import { CaptureAreaMovedError } from "./errors/capture-area-moved-error";
+import { COMPOSITING_ITERATIONS_LIMIT } from "./constants";
-const debug = makeDebug("testplane:screenshots:screen-shooter");
+class CaptureAreaSizeChangeError extends Error {
+ constructor() {
+ super("Capture area size changed unexpectedly during capture");
+ this.name = "CaptureAreaSizeChangeError";
+ }
+}
-const COMPOSITING_ITERATIONS_LIMIT = 50;
+const debug = makeDebug("testplane:screenshots:elements-screen-shooter");
interface ScreenShooterOpts extends AssertViewOpts {
debugId?: string;
@@ -55,28 +59,40 @@ interface ScreenShooterFullParams extends ScreenShooterInputParams {
browserSideScreenshooter: ClientBridge;
}
-function areCaptureSpecsEqual(
- left: CaptureSpec<"viewport", "device">[],
- right: CaptureSpec<"viewport", "device">[],
-): boolean {
- if (left.length !== right.length) {
- return false;
+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 left.every((spec, index) => {
- const otherSpec = right[index];
+ 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 (!otherSpec) {
- return false;
+ if (!baselineSpec || !currentSpec) {
+ continue;
}
- return (
- spec.full.left === otherSpec.full.left &&
- spec.full.top === otherSpec.full.top &&
- spec.full.width === otherSpec.full.width &&
- spec.full.height === otherSpec.full.height
- );
- });
+ shifts.push((currentSpec.full.top as number) - (baselineSpec.full.top as number));
+ }
+
+ return getMedian(shifts) ?? 0;
}
export class ElementsScreenShooter {
@@ -102,19 +118,9 @@ export class ElementsScreenShooter {
this._browserSideScreenshooter = browserSideScreenshooter;
}
- async capture(
- selectorOrSelectors: string | string[],
- opts: ScreenShooterOpts = {},
- retriesLimit = 3,
- ): Promise {
+ async capture(selectorOrSelectors: string | string[], opts: ScreenShooterOpts = {}): Promise {
const globalStartedAt = performance.now();
const perfDebug = makeDebug("testplane:screenshots:perf:" + opts.debugId);
- let startedAt,
- prepareScreenshotTime = 0,
- validateCaptureAreaStabilityTime = 0,
- captureAttemptTime = 0,
- renderImageTime = 0,
- cleanupTime = 0;
const selectorsToCapture = ([] as string[]).concat(selectorOrSelectors);
const selectorsToIgnore = ([] as string[]).concat(opts.ignoreElements ?? []);
@@ -123,133 +129,77 @@ export class ElementsScreenShooter {
throw new Error("No selectors to capture passed to ElementsScreenShooter.capture");
}
- let retriesCount = 0;
- // This error may never be thrown, but just in case.
- let originalError: unknown = new Error(
- `An unknown error happened while capturing screenshot for selectors: ${JSON.stringify(selectorsToCapture)}`,
- );
-
- while (retriesCount < retriesLimit) {
- retriesCount++;
- startedAt = performance.now();
- try {
- perfDebug(`Starting capture attempt.`);
-
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- 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,
- );
+ try {
+ perfDebug(`Starting capture.`);
+
+ 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,
+ });
- const captureScreenshotStartTime = performance.now();
-
- await preparePointerForScreenshot(this._browser, {
- disableHover: opts.disableHover,
- pointerEventsDisabled: page.pointerEventsDisabled,
- });
-
- prepareScreenshotTime = performance.now() - captureScreenshotStartTime;
- perfDebug(`Prepare screenshot finished. Time spent on prepare screenshot: ${prepareScreenshotTime}ms`);
-
- // For the first attempt, we take optimistic approach and don't verify if the whole area is stable in size,
- // because in majority of cases, it is stable and it's better to not spend time on scrolling.
- // If it's not stable on first try, it will throw and we will pre-load the whole area & verify it here,
- // optimising the "unstable" case - it's faster to discard early during scrolling than during actual capturing.
- if (retriesCount > 1) {
- const validateCaptureAreaStabilityStartTime = performance.now();
- await this._validateCaptureAreaStability(selectorsToCapture, selectorsToIgnore, page, opts);
- // await this._preloadCaptureArea(selectorsToCapture, selectorsToIgnore, page, opts);
- validateCaptureAreaStabilityTime = performance.now() - validateCaptureAreaStabilityStartTime;
- perfDebug(
- `Capture area stab>ility validated. Time spent on validate capture area stability: ${validateCaptureAreaStabilityTime}ms`,
- );
- }
+ assertCorrectCaptureAreaBounds(
+ JSON.stringify(selectorsToCapture),
+ page.viewportSize,
+ page.viewportOffset,
+ page.captureSpecs.map(s => s.full),
+ opts,
+ );
- const shouldThrowOnCaptureAreaSizeChange = retriesCount === 1;
+ await preparePointerForScreenshot(this._browser, {
+ disableHover: opts.disableHover,
+ pointerEventsDisabled: page.pointerEventsDisabled,
+ });
- const captureAttemptStartTime = performance.now();
- const compositeImage = await this._performCaptureAttempt(
+ let compositeImage: CompositeImage;
+ try {
+ compositeImage = await this._performCaptureAttempt(
selectorsToCapture,
selectorsToIgnore,
page,
opts,
- shouldThrowOnCaptureAreaSizeChange,
- );
-
- captureAttemptTime = performance.now() - captureAttemptStartTime;
- perfDebug(
- `All areas captured. Proceeding to render image. Time spent on capture attempt: ${captureAttemptTime}ms`,
+ true,
);
- const renderImageStartTime = performance.now();
-
- const renderedImage = await compositeImage.render();
-
- renderImageTime = performance.now() - renderImageStartTime;
- perfDebug(`Rendering finished. Time spent on rendering: ${renderImageTime}ms`);
-
- perfDebug(`Total time spent on capture (all attempts): ${performance.now() - globalStartedAt}ms`);
-
- return {
- image: renderedImage,
- meta: page,
- };
} catch (error) {
- originalError = error;
- const isRetriable = (error as null | { retriable?: boolean })?.retriable === true;
-
- if (isRetriable && retriesCount < retriesLimit) {
- perfDebug(`Capture attempt failed. Going to retry. Retry # ${retriesCount}`);
- continue;
+ if (!(error instanceof CaptureAreaSizeChangeError)) {
+ throw error;
}
- perfDebug(`Total time spent on capture (all attempts): ${performance.now() - globalStartedAt}ms`);
+ perfDebug(`Capture area size changed. Preloading area and switching to best-effort pass.`);
+ await this._preloadCaptureArea(selectorsToCapture, selectorsToIgnore, page, opts);
+ compositeImage = await this._performCaptureAttempt(
+ selectorsToCapture,
+ selectorsToIgnore,
+ page,
+ opts,
+ false,
+ );
+ }
- throw error;
- } finally {
- const cleanupStartTime = performance.now();
- try {
- await this._cleanupScreenshot(opts);
+ const renderedImage = await compositeImage.render();
- cleanupTime = performance.now() - cleanupStartTime;
- perfDebug(`[${opts.debugId}] Cleanup finished. Time spent on cleanup: ${cleanupTime}ms`);
- } 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}`,
- );
- }
+ perfDebug(`Total time spent on capture: ${performance.now() - globalStartedAt}ms`);
- const totalTimeSpent = performance.now() - startedAt;
- const timeSpentOnKnownOperations =
- prepareScreenshotTime +
- validateCaptureAreaStabilityTime +
- captureAttemptTime +
- renderImageTime +
- cleanupTime;
- perfDebug(`Other time during capture attempt: ${totalTimeSpent - timeSpentOnKnownOperations}ms`);
- perfDebug(`Attempt finished. Starting cleanup. Time spent on this attempt: ${totalTimeSpent}ms\n\n`);
+ 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}`,
+ );
}
}
-
- perfDebug(`Total time spent on capture (all attempts): ${performance.now() - globalStartedAt}ms`);
-
- throw originalError;
}
private async _prepareScreenshot(
@@ -260,7 +210,7 @@ export class ElementsScreenShooter {
const enabledDebugTopics: string[] = [];
const browserPrepareScreenshotDebug = makeDebug("testplane:screenshots:browser:prepareScreenshot");
if (browserPrepareScreenshotDebug.enabled) {
- enabledDebugTopics.push("prepareScreenshot:areas-computation");
+ enabledDebugTopics.push("prepareElementsScreenshot");
}
const extendedOpts = {
@@ -358,6 +308,24 @@ export class ElementsScreenShooter {
}, selectorsToCapture);
}
+ /** 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 {
+ 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]);
+ }
+
private async _scrollThroughCaptureArea(
selectorsToCapture: string[],
selectorsToIgnore: string[],
@@ -369,9 +337,11 @@ export class ElementsScreenShooter {
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;
@@ -382,12 +352,6 @@ export class ElementsScreenShooter {
scrollTime = 0,
callbackTime = 0;
- const enabledScrollDebugTopics: string[] = [];
- const browserScrollDebug = makeDebug("testplane:screenshots:browser:scrollAndRecomputeAreas");
- if (browserScrollDebug.enabled) {
- enabledScrollDebugTopics.push("scrollAndRecomputeAreas:scroll");
- }
-
try {
while (iterations < COMPOSITING_ITERATIONS_LIMIT && !hasCapturedTheWholeArea && !hasReachedScrollLimit) {
debug(`========== Starting compositing iteration #${iterations} ==========`);
@@ -397,6 +361,13 @@ export class ElementsScreenShooter {
waitForSettleTime += performance.now() - waitForSettleStartTime;
const recomputeStartTime = performance.now();
+
+ const enabledScrollDebugTopics: string[] = [];
+ const browserScrollDebug = makeDebug("testplane:screenshots:browser:getCaptureState");
+ if (browserScrollDebug.enabled) {
+ enabledScrollDebugTopics.push("getCaptureState");
+ }
+
const currentStateOrError = await this._browserSideScreenshooter.call("getCaptureState", [
selectorsToCapture,
selectorsToIgnore,
@@ -535,198 +506,6 @@ export class ElementsScreenShooter {
}
}
- /** Scrolls through the capture area twice and validates if these two passes resulted in the same capture areas, thus checking if the area is stable. */
- private async _validateCaptureAreaStability(
- selectorsToCapture: string[],
- selectorsToIgnore: string[],
- page: PrepareScreenshotSuccess,
- opts: ScreenShooterOpts,
- ): Promise {
- const perfDebug = makeDebug("testplane:screenshots:perf:" + opts.debugId);
- perfDebug(`Starting capture area stability validation.`);
- const startedAt = performance.now();
-
- const enabledScrollDebugTopics: string[] = [];
- const browserScrollDebug = makeDebug("testplane:screenshots:browser:scrollAndRecomputeAreas");
- if (browserScrollDebug.enabled) {
- enabledScrollDebugTopics.push("scrollAndRecomputeAreas:scroll");
- }
-
- const beforeCheckpointsValidationState = await this._browserSideScreenshooter.call("getCaptureState", [
- selectorsToCapture,
- selectorsToIgnore,
- opts.selectorToScroll,
- enabledScrollDebugTopics,
- ]);
- const beforeValidationDebugLog = beforeCheckpointsValidationState.debugLog;
- delete beforeCheckpointsValidationState.debugLog;
- browserScrollDebug(beforeValidationDebugLog);
-
- if (isBrowserSideError(beforeCheckpointsValidationState)) {
- throw new Error(
- `Failed to recompute areas before checkpoints validation while compositing image of selectors: ${selectorsToCapture.join(
- ", ",
- )}, error type '${beforeCheckpointsValidationState.errorCode}' and error message: ${
- beforeCheckpointsValidationState.message
- }`,
- );
- }
-
- const baselineCheckpoints: CaptureState[] = [];
- const currentCheckpoints: CaptureState[] = [];
- let shouldRestoreScrollPosition = false;
- let restoreScrollPositionError: Error | null = null;
-
- try {
- await this._scrollThroughCaptureArea(
- selectorsToCapture,
- selectorsToIgnore,
- page,
- opts,
- async currentState => {
- baselineCheckpoints.push(currentState);
- },
- );
-
- // If the whole area fits viewport there is no scrolling and no point in a second pass.
- if (baselineCheckpoints.length <= 1) {
- return;
- }
-
- shouldRestoreScrollPosition = true;
-
- const restoreToInitialScrollOffsetResult = await this._browserSideScreenshooter.call("scrollTo", [
- selectorsToCapture,
- page.scrollOffset,
- opts.selectorToScroll,
- ]);
-
- if (isBrowserSideError(restoreToInitialScrollOffsetResult)) {
- throw new Error(
- `Failed to restore the initial state before checkpoints validation while compositing image of selectors: ${selectorsToCapture.join(
- ", ",
- )}, error type '${restoreToInitialScrollOffsetResult.errorCode}' and error message: ${
- restoreToInitialScrollOffsetResult.message
- }`,
- );
- }
-
- const collectCurrentCheckpointsStartTime = performance.now();
-
- for (const checkpoint of baselineCheckpoints) {
- const scrollToCheckpointResult = await this._browserSideScreenshooter.call("scrollTo", [
- selectorsToCapture,
- checkpoint.scrollOffset,
- opts.selectorToScroll,
- enabledScrollDebugTopics,
- ]);
- const scrollToCheckpointDebugLog = scrollToCheckpointResult.debugLog;
- delete scrollToCheckpointResult.debugLog;
- browserScrollDebug(scrollToCheckpointDebugLog);
-
- if (isBrowserSideError(scrollToCheckpointResult)) {
- throw new Error(
- `Failed to scroll to checkpoint offset while compositing image of selectors: ${selectorsToCapture.join(
- ", ",
- )}, error type '${scrollToCheckpointResult.errorCode}' and error message: ${
- scrollToCheckpointResult.message
- }`,
- );
- }
-
- const currentCheckpoint = await this._browserSideScreenshooter.call("getCaptureState", [
- selectorsToCapture,
- selectorsToIgnore,
- opts.selectorToScroll,
- enabledScrollDebugTopics,
- ]);
- const currentCheckpointDebugLog = currentCheckpoint.debugLog;
- delete currentCheckpoint.debugLog;
- browserScrollDebug(currentCheckpointDebugLog);
-
- if (isBrowserSideError(currentCheckpoint)) {
- throw new Error(
- `Failed to recompute checkpoint capture specs for selectors: ${selectorsToCapture.join(
- ", ",
- )}, error type '${currentCheckpoint.errorCode}' and error message: ${
- currentCheckpoint.message
- }`,
- );
- }
-
- currentCheckpoints.push(currentCheckpoint);
- }
-
- perfDebug(
- `[${opts.debugId}] Collected current checkpoints. Time spent: ${
- performance.now() - collectCurrentCheckpointsStartTime
- }ms`,
- );
-
- debug("captureCheckpoints: %O", baselineCheckpoints);
- debug("currentCheckpoints: %O", currentCheckpoints);
-
- const mismatchIndex = baselineCheckpoints.findIndex((checkpoint, index) => {
- const currentCheckpoint = currentCheckpoints[index];
-
- if (!currentCheckpoint || checkpoint.scrollOffset !== currentCheckpoint.scrollOffset) {
- return true;
- }
-
- return !areCaptureSpecsEqual(checkpoint.captureSpecs, currentCheckpoint.captureSpecs);
- });
-
- if (mismatchIndex !== -1 || baselineCheckpoints.length !== currentCheckpoints.length) {
- const safeIndex = mismatchIndex === -1 ? baselineCheckpoints.length - 1 : mismatchIndex;
- const expectedCheckpoint = baselineCheckpoints[safeIndex];
- const currentCheckpoint = currentCheckpoints[safeIndex];
-
- const lastFullRects = expectedCheckpoint.captureSpecs.map(s => s.full);
- const newFullRects = currentCheckpoint ? currentCheckpoint.captureSpecs.map(s => s.full) : [];
-
- debug(
- `Checkpoints mismatch, mismatchIndex: ${mismatchIndex}, expected checkpoint: %O, current checkpoint: %O, interrupting and starting over.`,
- expectedCheckpoint,
- currentCheckpoint,
- );
-
- throw new CaptureAreaMovedError(selectorsToCapture, lastFullRects, newFullRects);
- }
- } finally {
- if (shouldRestoreScrollPosition) {
- const restoreScrollResult = await this._browserSideScreenshooter.call("scrollTo", [
- selectorsToCapture,
- beforeCheckpointsValidationState.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 checkpoints validation while compositing image of selectors: ${selectorsToCapture.join(
- ", ",
- )}, error type '${restoreScrollResult.errorCode}' and error message: ${
- restoreScrollResult.message
- }`,
- );
- }
- }
-
- perfDebug(
- `[${opts.debugId}] Capture area stability validation finished. Time taken: ${
- performance.now() - startedAt
- }ms`,
- );
- }
-
- if (restoreScrollPositionError) {
- throw restoreScrollPositionError;
- }
- }
-
private async _performCaptureAttempt(
selectorsToCapture: string[],
selectorsToIgnore: string[],
@@ -746,20 +525,16 @@ export class ElementsScreenShooter {
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;
- const enabledScrollDebugTopics: string[] = [];
- const browserScrollDebug = makeDebug("testplane:screenshots:browser:scrollAndRecomputeAreas");
- if (browserScrollDebug.enabled) {
- enabledScrollDebugTopics.push("scrollAndRecomputeAreas:scroll");
- }
-
try {
await this._scrollThroughCaptureArea(
selectorsToCapture,
@@ -776,9 +551,7 @@ export class ElementsScreenShooter {
);
if (hasCaptureAreaSizeChanged && shouldThrowOnCaptureAreaSizeChange) {
- const lastFullRects = lastState.captureSpecs.map(s => s.full);
- const newFullRects = currentState.captureSpecs.map(s => s.full);
- throw new CaptureAreaMovedError(selectorsToCapture, lastFullRects, newFullRects);
+ throw new CaptureAreaSizeChangeError();
}
const {
@@ -789,20 +562,35 @@ export class ElementsScreenShooter {
const captureStartTime = performance.now();
- // const viewport = { ...page.viewportSize, ...page.viewportOffset };
const viewportImage = await this._camera.captureViewportImage({
viewportSize: page.viewportSize,
- viewportOffset: page.viewportOffset,
+ viewportOffset: currentState.viewportOffset,
screenshotDelay: opts.screenshotDelay,
});
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;
@@ -824,6 +612,12 @@ export class ElementsScreenShooter {
} finally {
perfDebug(`Done capturing composite image. Time spent on raw viewport captures: ${timeSpentOnCapture}ms`);
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,
diff --git a/src/browser/screen-shooter/errors/capture-area-moved-error.ts b/src/browser/screen-shooter/errors/capture-area-moved-error.ts
deleted file mode 100644
index 8e5913f2d..000000000
--- a/src/browser/screen-shooter/errors/capture-area-moved-error.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Rect, prettyRect } from "../../isomorphic";
-
-export class CaptureAreaMovedError extends Error {
- retriable: boolean;
-
- constructor(
- selectorsToCapture: string[],
- lastCaptureAreas: Rect<"viewport", "device">[],
- newCaptureAreas: Rect<"viewport", "device">[],
- ) {
- const message = `The capture area moved unexpectedly during scrolling while capturing long screenshot. What happened:
-- you requested to capture the following selectors: ${selectorsToCapture.join("; ")}
-- last capture areas: ${lastCaptureAreas.map(prettyRect).join(", ")}
-- new capture areas: ${newCaptureAreas.map(prettyRect).join(", ")}
-- we tried multiple times, but still couldn't capture the whole area
-
-What you can do:
-- Check that the page is stable before taking the screenshot
-- Check that there's no content that loads dynamically
-`;
- super(message);
- this.name = "CaptureAreaMovedError";
- this.retriable = true;
- }
-}
diff --git a/src/browser/screen-shooter/full-page-screen-shooter.ts b/src/browser/screen-shooter/full-page-screen-shooter.ts
index 46e8b3e55..2f163152c 100644
--- a/src/browser/screen-shooter/full-page-screen-shooter.ts
+++ b/src/browser/screen-shooter/full-page-screen-shooter.ts
@@ -17,6 +17,7 @@ import {
preparePointerForScreenshot,
} from "./operations";
import { runWithoutHistory } from "../history";
+import { COMPOSITING_ITERATIONS_LIMIT } from "./constants";
const debug = makeDebug("testplane:screenshots:full-page-screen-shooter");
@@ -81,8 +82,6 @@ export class FullPageScreenShooter {
}
private async _captureImpl(opts: FullPageCaptureOpts): Promise {
- const COMPOSITING_ITERATIONS_LIMIT = 50;
-
const prepareResult = await this._browserSideScreenshooter.call("prepareFullPageScreenshot", [
{
usePixelRatio: this._browserProperties.shouldUsePixelRatio,
diff --git a/src/config/defaults.js b/src/config/defaults.js
index e831888c1..e0d5f3e60 100644
--- a/src/config/defaults.js
+++ b/src/config/defaults.js
@@ -4,7 +4,6 @@ const { DisableHoverMode } = require("../browser/isomorphic");
const { WEBDRIVER_PROTOCOL, SAVE_HISTORY_MODE, NODEJS_TEST_RUN_ENV } = require("../constants/config");
const { TimeTravelMode } = require("./types");
-
module.exports = {
baseUrl: "http://localhost",
gridUrl: "http://localhost:4444/wd/hub",
diff --git a/src/image.ts b/src/image.ts
index 8ec419633..2b951847c 100644
--- a/src/image.ts
+++ b/src/image.ts
@@ -139,10 +139,10 @@ export class Image {
let bufferPointer = 0;
let sourceOffset = (rect.top * this._width + rect.left) * RGBA_CHANNELS;
- const actualWIdth = Math.min(rect.width, this._width - rect.left);
+ const actualWidth = Math.min(rect.width, this._width - rect.left);
const actualHeight = Math.min(rect.height, this._height - rect.top);
- const bytesToCopy = actualWIdth * RGBA_CHANNELS;
+ const bytesToCopy = actualWidth * RGBA_CHANNELS;
const bytesToIterate = this._width * RGBA_CHANNELS;
for (let i = 0; i < actualHeight; i++) {
@@ -153,7 +153,7 @@ export class Image {
}
this._imgData = imgData.subarray(0, bufferPointer);
- this._width = actualWIdth;
+ this._width = actualWidth;
this._height = actualHeight;
}
diff --git a/test/browser-env/testplane.config.ts b/test/browser-env/testplane.config.ts
index c3990b2e1..2cc562027 100644
--- a/test/browser-env/testplane.config.ts
+++ b/test/browser-env/testplane.config.ts
@@ -8,7 +8,7 @@ export default {
gridUrl: shouldUseLocalBrowser ? "local" : "http://127.0.0.1:4444/",
baseUrl: shouldUseLocalBrowser ? "http://localhost:5173" : "http://host.docker.internal:5173",
sessionsPerBrowser: 1,
- testsPerSession: 10,
+ testsPerSession: 50,
screenshotsDir: "test/browser-env/screens",
@@ -33,10 +33,11 @@ export default {
},
},
+ headless: !shouldUseLocalBrowser,
+
browsers: {
chrome: {
windowSize: { width: 1280, height: 1000 },
- headless: !shouldUseLocalBrowser,
desiredCapabilities: {
browserName: "chrome",
"goog:chromeOptions": {
@@ -47,7 +48,6 @@ export default {
waitTimeout: 3000,
},
"chrome-mobile-dpr3": {
- headless: !shouldUseLocalBrowser,
desiredCapabilities: {
browserName: "chrome",
"goog:chromeOptions": {
diff --git a/test/browser-env/tests/desktop/screenshooter/computeCaptureSpecs.testplane.ts b/test/browser-env/tests/desktop/screenshooter/computeCaptureSpecs.testplane.ts
index 7ff539de8..cc23bc3f2 100644
--- a/test/browser-env/tests/desktop/screenshooter/computeCaptureSpecs.testplane.ts
+++ b/test/browser-env/tests/desktop/screenshooter/computeCaptureSpecs.testplane.ts
@@ -21,7 +21,7 @@ describe("computeCaptureSpecs", () => {
describe("empty results", () => {
it("should return empty array when selector matches nothing", () => {
const result = computeCaptureSpecs([".nonexistent"]);
- expect(result.captureSpecs).toEqual([]);
+ expect(result).toEqual([]);
});
it("should return empty array when all matched elements are hidden", async () => {
@@ -34,7 +34,7 @@ describe("computeCaptureSpecs", () => {
".hidden-opacity",
".hidden-zero-size",
]);
- expect(result.captureSpecs).toEqual([]);
+ expect(result).toEqual([]);
});
});
@@ -44,9 +44,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".target"]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("single-element");
});
@@ -55,9 +55,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".shadow-target"]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("box-shadow");
});
@@ -66,9 +66,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".inset-shadow-target"]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("inset-box-shadow");
});
@@ -77,9 +77,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".outline-target"]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("outline");
});
@@ -89,7 +89,7 @@ describe("computeCaptureSpecs", () => {
const elementResult = computeCaptureSpecs([".pseudo-target"]);
- visualizeCaptureSpecs(elementResult.captureSpecs);
+ visualizeCaptureSpecs(elementResult);
await browser.assertView("pseudo-elements");
});
@@ -98,9 +98,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".pseudo-target::before", ".pseudo-target::after"]);
- expect(result.captureSpecs).toHaveLength(2);
+ expect(result).toHaveLength(2);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("pseudo-elements");
});
@@ -111,9 +111,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".parent::before", ".parent::after"]);
- expect(result.captureSpecs).toHaveLength(2);
+ expect(result).toHaveLength(2);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("pseudo-elements-ancestor-cb");
});
@@ -124,9 +124,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".target::before", ".target::after"]);
- expect(result.captureSpecs).toHaveLength(2);
+ expect(result).toHaveLength(2);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("transformed-pseudo-elements");
});
@@ -137,9 +137,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".rotated", ".scaled", ".translated", ".skewed", ".combined"]);
- expect(result.captureSpecs).toHaveLength(5);
+ expect(result).toHaveLength(5);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("transformed-elements");
});
});
@@ -150,9 +150,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".a", ".b", ".c"]);
- expect(result.captureSpecs).toHaveLength(3);
+ expect(result).toHaveLength(3);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("multiple-selectors");
});
@@ -161,9 +161,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".item"]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("multiple-matches");
});
@@ -173,10 +173,10 @@ describe("computeCaptureSpecs", () => {
// Both selectors match the same .target element
const result = computeCaptureSpecs([".target", "div.target"]);
- expect(result.captureSpecs).toHaveLength(2);
+ expect(result).toHaveLength(2);
// Both rects should be identical
- expect(result.captureSpecs[0]).toEqual(result.captureSpecs[1]);
+ expect(result[0]).toEqual(result[1]);
});
it("should only return visible elements from a mix of visible and hidden", async ({ browser }) => {
@@ -190,9 +190,9 @@ describe("computeCaptureSpecs", () => {
".hidden-opacity",
".hidden-zero-size",
]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("mixed-visibility");
});
});
@@ -206,9 +206,9 @@ describe("computeCaptureSpecs", () => {
container.scrollTop = 200;
const result = computeCaptureSpecs([".target"]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("scrollable-container-scrolled");
});
});
@@ -219,9 +219,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".box-model-target"]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("margin-padding-border");
});
@@ -230,9 +230,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".offscreen"]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("offscreen");
});
@@ -243,9 +243,9 @@ describe("computeCaptureSpecs", () => {
window.scrollTo(0, 560);
const result = computeCaptureSpecs([".long-target"]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("partially-visible-after-scroll");
});
@@ -254,9 +254,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".fractional", ".transformed"]);
- expect(result.captureSpecs).toHaveLength(2);
+ expect(result).toHaveLength(2);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("fractional-positions");
});
});
@@ -268,7 +268,7 @@ describe("computeCaptureSpecs", () => {
const result = computeCaptureSpecs([".parent", ".child"]);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("nested-elements");
});
});
@@ -281,9 +281,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".target"]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("overflow-hidden");
});
@@ -293,9 +293,9 @@ describe("computeCaptureSpecs", () => {
const logger = createDebugLogger({ debug: ["screen-shooter"] }, "screen-shooter");
const result = computeCaptureSpecs([".target"], logger);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("overflow-scroll");
});
@@ -304,14 +304,14 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".target"]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- const spec = result.captureSpecs[0];
+ const spec = result[0];
// Fixed element escapes overflow clipping — full and visible should be the same
expect(spec.full.width).toBe(spec.visible.width);
expect(spec.full.height).toBe(spec.visible.height);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("fixed-in-overflow");
});
@@ -320,9 +320,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".target"]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("fixed-parent-in-overflow-hidden-external-containing-block");
});
@@ -335,9 +335,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".target"]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("target");
});
@@ -348,9 +348,9 @@ describe("computeCaptureSpecs", () => {
document.body.innerHTML = html;
const result = computeCaptureSpecs([".target"]);
- expect(result.captureSpecs).toHaveLength(1);
+ expect(result).toHaveLength(1);
- visualizeCaptureSpecs(result.captureSpecs);
+ visualizeCaptureSpecs(result);
await browser.assertView("target");
});
});
diff --git a/test/browser-env/tests/desktop/screenshooter/computePixelRatio.testplane.ts b/test/browser-env/tests/desktop/screenshooter/computePixelRatio.testplane.ts
index 92ba9e049..d2c479068 100644
--- a/test/browser-env/tests/desktop/screenshooter/computePixelRatio.testplane.ts
+++ b/test/browser-env/tests/desktop/screenshooter/computePixelRatio.testplane.ts
@@ -3,13 +3,13 @@ import { computePixelRatio } from "../../../../../src/browser/client-scripts/scr
describe("computePixelRatio", () => {
testplane.only.in("chrome-mobile-dpr3");
it("returns emulated mobile pixel ratio from window.devicePixelRatio", () => {
- const pixelRatio = computePixelRatio().pixelRatio;
+ const pixelRatio = computePixelRatio();
expect(pixelRatio).toBe(3);
});
testplane.only.in("chrome-mobile-dpr3");
it("returns 1 when usePixelRatio is disabled", () => {
- const pixelRatio = computePixelRatio(false).pixelRatio;
+ const pixelRatio = computePixelRatio(false);
expect(pixelRatio).toBe(1);
});
});
diff --git a/test/browser-env/tests/desktop/screenshooter/computeSafeArea.testplane.ts b/test/browser-env/tests/desktop/screenshooter/computeSafeArea.testplane.ts
index fbdd46730..39e4f4c58 100644
--- a/test/browser-env/tests/desktop/screenshooter/computeSafeArea.testplane.ts
+++ b/test/browser-env/tests/desktop/screenshooter/computeSafeArea.testplane.ts
@@ -17,8 +17,8 @@ describe("computeSafeArea", () => {
document.body.innerHTML = html;
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors).safeArea;
- const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+ const safeArea = computeSafeArea(selectors);
+ const captureSpecs = computeCaptureSpecs(selectors);
expect(captureSpecs).toHaveLength(1);
expect(safeArea.top).toBeGreaterThan(0);
@@ -33,8 +33,8 @@ describe("computeSafeArea", () => {
document.body.innerHTML = html;
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors).safeArea;
- const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+ const safeArea = computeSafeArea(selectors);
+ const captureSpecs = computeCaptureSpecs(selectors);
expect(captureSpecs).toHaveLength(1);
@@ -48,8 +48,8 @@ describe("computeSafeArea", () => {
document.body.innerHTML = html;
const selectors = [".target-modal"];
- const safeArea = computeSafeArea(selectors).safeArea;
- const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+ const safeArea = computeSafeArea(selectors);
+ const captureSpecs = computeCaptureSpecs(selectors);
visualizeCaptureSpecs(captureSpecs);
visualizeSafeArea(safeArea.top, safeArea.height);
@@ -61,8 +61,8 @@ describe("computeSafeArea", () => {
document.body.innerHTML = html;
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors).safeArea;
- const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+ const safeArea = computeSafeArea(selectors);
+ const captureSpecs = computeCaptureSpecs(selectors);
visualizeCaptureSpecs(captureSpecs);
visualizeSafeArea(safeArea.top, safeArea.height);
@@ -79,8 +79,8 @@ describe("computeSafeArea", () => {
}
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors, panel).safeArea;
- const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+ const safeArea = computeSafeArea(selectors, panel);
+ const captureSpecs = computeCaptureSpecs(selectors);
expect(captureSpecs).toHaveLength(1);
@@ -101,8 +101,8 @@ describe("computeSafeArea", () => {
}
const selectors = [".content"];
- const safeArea = computeSafeArea(selectors, panel).safeArea;
- const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+ const safeArea = computeSafeArea(selectors, panel);
+ const captureSpecs = computeCaptureSpecs(selectors);
visualizeCaptureSpecs(captureSpecs);
visualizeSafeArea(safeArea.top, safeArea.height);
@@ -119,14 +119,12 @@ describe("computeSafeArea", () => {
);
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors, undefined, logger).safeArea;
- const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+ const safeArea = computeSafeArea(selectors, undefined, logger);
+ const captureSpecs = computeCaptureSpecs(selectors);
visualizeCaptureSpecs(captureSpecs);
visualizeSafeArea(safeArea.top, safeArea.height);
await browser.assertView("compute-safe-area-target-element-inside-fixed");
-
- console.log(logger.log);
});
it("should handle sticky header with shadow", async ({ browser }) => {
@@ -134,7 +132,7 @@ describe("computeSafeArea", () => {
document.body.innerHTML = html;
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors).safeArea;
+ const safeArea = computeSafeArea(selectors);
visualizeSafeArea(safeArea.top, safeArea.height);
await browser.assertView("compute-safe-area-sticky-header-with-shadow");
@@ -150,8 +148,8 @@ describe("computeSafeArea", () => {
);
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors, undefined, logger).safeArea;
- const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+ const safeArea = computeSafeArea(selectors, undefined, logger);
+ const captureSpecs = computeCaptureSpecs(selectors);
visualizeCaptureSpecs(captureSpecs);
visualizeSafeArea(safeArea.top, safeArea.height);
@@ -163,8 +161,8 @@ describe("computeSafeArea", () => {
document.body.innerHTML = html;
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors).safeArea;
- const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+ const safeArea = computeSafeArea(selectors);
+ const captureSpecs = computeCaptureSpecs(selectors);
visualizeCaptureSpecs(captureSpecs);
visualizeSafeArea(safeArea.top, safeArea.height);
@@ -174,8 +172,8 @@ describe("computeSafeArea", () => {
it("should return full viewport when no selectors match", () => {
document.body.innerHTML = "some content
";
- const { viewportSize } = computeViewportSize();
- const safeArea = computeSafeArea([".does-not-exist"]).safeArea;
+ const viewportSize = computeViewportSize();
+ const safeArea = computeSafeArea([".does-not-exist"]);
expect(safeArea.top).toBe(0);
expect(safeArea.height).toBe(viewportSize.height);
@@ -191,8 +189,8 @@ describe("computeSafeArea", () => {
}
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors, panel).safeArea;
- const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+ const safeArea = computeSafeArea(selectors, panel);
+ const captureSpecs = computeCaptureSpecs(selectors);
expect(captureSpecs).toHaveLength(1);
@@ -211,8 +209,8 @@ describe("computeSafeArea", () => {
}
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors, panel).safeArea;
- const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+ const safeArea = computeSafeArea(selectors, panel);
+ const captureSpecs = computeCaptureSpecs(selectors);
visualizeCaptureSpecs(captureSpecs);
visualizeSafeArea(safeArea.top, safeArea.height);
@@ -224,8 +222,8 @@ describe("computeSafeArea", () => {
document.body.innerHTML = html;
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors).safeArea;
- const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+ const safeArea = computeSafeArea(selectors);
+ const captureSpecs = computeCaptureSpecs(selectors);
const headerBcr = document.querySelector(".header")!.getBoundingClientRect();
const footerBcr = document.querySelector(".footer")!.getBoundingClientRect();
@@ -249,8 +247,8 @@ describe("computeSafeArea", () => {
}
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors, panel).safeArea;
- const captureSpecs = computeCaptureSpecs(selectors).captureSpecs;
+ const safeArea = computeSafeArea(selectors, panel);
+ const captureSpecs = computeCaptureSpecs(selectors);
expect(captureSpecs).toHaveLength(1);
@@ -267,10 +265,10 @@ describe("computeSafeArea", () => {
document.body.innerHTML = html;
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors).safeArea;
+ const safeArea = computeSafeArea(selectors);
// overlay: z:5 / target's container: z:10 -> isChainBehind returns true -> no shrink
- const { viewportSize } = computeViewportSize();
+ const viewportSize = computeViewportSize();
expect(safeArea.top).toBe(0);
expect(safeArea.height).toBe(viewportSize.height);
@@ -290,7 +288,7 @@ describe("computeSafeArea", () => {
window.scrollTo(0, document.documentElement.scrollHeight - document.documentElement.clientHeight - 200);
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors, undefined, logger).safeArea;
+ const safeArea = computeSafeArea(selectors, undefined, logger);
visualizeSafeArea(safeArea.top, safeArea.height);
await browser.assertView("compute-safe-area-stacking-context-filter-in-front");
@@ -305,7 +303,7 @@ describe("computeSafeArea", () => {
window.scrollTo(0, 5000);
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors).safeArea;
+ const safeArea = computeSafeArea(selectors);
// header: z:10 / target: z:0 -> isChainBehind returns false -> does shrink
const headerBcr = document.querySelector(".header")!.getBoundingClientRect();
@@ -322,10 +320,10 @@ describe("computeSafeArea", () => {
document.body.innerHTML = html;
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors).safeArea;
+ const safeArea = computeSafeArea(selectors);
// overlay: z:50 / app-shell: z:100 -> common ctx = documentElement -> 50 < 100 -> behind -> no shrink
- const { viewportSize } = computeViewportSize();
+ const viewportSize = computeViewportSize();
expect(safeArea.top).toBe(0);
expect(safeArea.height).toBe(viewportSize.height);
@@ -340,7 +338,7 @@ describe("computeSafeArea", () => {
document.body.innerHTML = html;
const selectors = [".target"];
- const safeArea = computeSafeArea(selectors).safeArea;
+ const safeArea = computeSafeArea(selectors);
// overlay: z:50 / app-shell: z:10 -> common ctx = documentElement -> 50 < 10 is false -> in front -> does shrink
const overlayBcr = document.querySelector(".fixed-overlay")!.getBoundingClientRect();
diff --git a/test/browser-env/tests/desktop/screenshooter/computeViewportSize.testplane.ts b/test/browser-env/tests/desktop/screenshooter/computeViewportSize.testplane.ts
index fa836217b..9c07085a9 100644
--- a/test/browser-env/tests/desktop/screenshooter/computeViewportSize.testplane.ts
+++ b/test/browser-env/tests/desktop/screenshooter/computeViewportSize.testplane.ts
@@ -4,7 +4,7 @@ describe("computeViewportSize", () => {
it("should return the viewport size", () => {
const viewportSize = computeViewportSize();
- expect(viewportSize.viewportSize.width).toBe(1280);
- expect(viewportSize.viewportSize.height).toBe(1000);
+ expect(viewportSize.width).toBe(1280);
+ expect(viewportSize.height).toBe(1000);
});
});
diff --git a/test/browser-env/tests/desktop/screenshooter/scrollToCaptureAreaIfNeeded.testplane.ts b/test/browser-env/tests/desktop/screenshooter/scrollToCaptureAreaIfNeeded.testplane.ts
index 42b5e0ce6..078c892b8 100644
--- a/test/browser-env/tests/desktop/screenshooter/scrollToCaptureAreaIfNeeded.testplane.ts
+++ b/test/browser-env/tests/desktop/screenshooter/scrollToCaptureAreaIfNeeded.testplane.ts
@@ -14,23 +14,23 @@ function clientRectSnapshot(el: Element): { top: number; left: number; width: nu
function expectClientRectClose(
a: { top: number; left: number; width: number; height: number },
b: { top: number; left: number; width: number; height: number },
- tol = 1,
+ tolerance = 1,
): void {
- expect(a.top).toBeCloseTo(b.top, tol);
- expect(a.left).toBeCloseTo(b.left, tol);
- expect(a.width).toBeCloseTo(b.width, tol);
- expect(a.height).toBeCloseTo(b.height, tol);
+ expect(a.top).toBeCloseTo(b.top, tolerance);
+ expect(a.left).toBeCloseTo(b.left, tolerance);
+ expect(a.width).toBeCloseTo(b.width, tolerance);
+ expect(a.height).toBeCloseTo(b.height, tolerance);
}
function coveringFullTop(selectors: string[]): number {
- const specs = computeCaptureSpecs(selectors).captureSpecs;
+ const specs = computeCaptureSpecs(selectors);
const area = getCoveringRect(specs.map(s => s.full));
return area.top as number;
}
function expectCaptureAlignedToSafeArea(selectors: string[], scrollElement: Element | undefined): void {
const top = coveringFullTop(selectors);
- const safe = computeSafeArea(selectors, scrollElement).safeArea;
+ const safe = computeSafeArea(selectors, scrollElement);
expect(top).toBeCloseTo(safe.top as number, 0);
}
@@ -45,11 +45,16 @@ describe("scrollToCaptureAreaIfNeeded", () => {
const { default: html } = await import("./fixtures/scroll-to-capture/below-fold-window.html?raw");
document.body.innerHTML = html;
+ expect(() => scrollToCaptureAreaIfNeeded([".target"], false)).toThrow(OutsideOfViewportError);
+ });
+
+ it("should not try to scroll when the target is outside the viewport and captureElementFromTop is false", async () => {
+ const { default: html } = await import("./fixtures/scroll-to-capture/below-fold-window.html?raw");
+ document.body.innerHTML = html;
+
const target = document.querySelector(".target")!;
const before = clientRectSnapshot(target);
- expect(() => scrollToCaptureAreaIfNeeded([".target"], false)).toThrow(OutsideOfViewportError);
-
expectClientRectClose(clientRectSnapshot(target), before);
expect(window.scrollY).toBe(0);
});
diff --git a/test/browser-env/tests/high-pixel-ratio/prepareViewportScreenshot.testplane.ts b/test/browser-env/tests/high-pixel-ratio/prepareViewportScreenshot.testplane.ts
index 0ba4b201a..0c0593abf 100644
--- a/test/browser-env/tests/high-pixel-ratio/prepareViewportScreenshot.testplane.ts
+++ b/test/browser-env/tests/high-pixel-ratio/prepareViewportScreenshot.testplane.ts
@@ -25,12 +25,12 @@ describe("prepareViewportScreenshot in high pixel ratio mode", () => {
window.scrollTo(0, 0);
});
- it("returns viewport and document dimensions translated to device pixels", async ({ browser }) => {
+ it("returns viewport and document dimensions translated to device pixels", async () => {
window.scrollTo(0, 245);
- const cssViewportSize = computeViewportSize().viewportSize;
- const cssViewportOffset = computeViewportOffset().viewportOffset;
- const cssDocumentSize = computeDocumentSize().documentSize;
+ const cssViewportSize = computeViewportSize();
+ const cssViewportOffset = computeViewportOffset();
+ const cssDocumentSize = computeDocumentSize();
const result = getPrepareResult({ usePixelRatio: true });
diff --git a/test/browser-env/types.d.ts b/test/browser-env/types.d.ts
index c4cbfdc01..d29bb7d59 100644
--- a/test/browser-env/types.d.ts
+++ b/test/browser-env/types.d.ts
@@ -1,3 +1,5 @@
+///
+
declare module "*.html?raw" {
const content: string;
export default content;
diff --git a/test/e2e/screens/788fe47/calibrated-chrome/after.png b/test/e2e/screens/788fe47/calibrated-chrome/after.png
new file mode 100644
index 000000000..1c811bdd5
Binary files /dev/null and b/test/e2e/screens/788fe47/calibrated-chrome/after.png differ
diff --git a/test/e2e/screens/788fe47/calibrated-chrome/before.png b/test/e2e/screens/788fe47/calibrated-chrome/before.png
new file mode 100644
index 000000000..ad67df965
Binary files /dev/null and b/test/e2e/screens/788fe47/calibrated-chrome/before.png differ
diff --git a/test/e2e/screens/7e93836/chrome/test-block.png b/test/e2e/screens/7e93836/chrome/test-block.png
new file mode 100644
index 000000000..c5fbd3388
Binary files /dev/null and b/test/e2e/screens/7e93836/chrome/test-block.png differ
diff --git a/test/e2e/screens/89919a4/chrome/viewport.png b/test/e2e/screens/89919a4/chrome/viewport.png
new file mode 100644
index 000000000..3a3681829
Binary files /dev/null and b/test/e2e/screens/89919a4/chrome/viewport.png differ
diff --git a/test/e2e/screens/9616746/chrome/basic-report-page-screenshot.png b/test/e2e/screens/9616746/chrome/basic-report-page-screenshot.png
index 7dc3959ec..7760756cd 100644
Binary files a/test/e2e/screens/9616746/chrome/basic-report-page-screenshot.png and b/test/e2e/screens/9616746/chrome/basic-report-page-screenshot.png differ
diff --git a/test/e2e/screens/a2b6a7e/chrome/test-block.png b/test/e2e/screens/a2b6a7e/chrome/test-block.png
new file mode 100644
index 000000000..a94826482
Binary files /dev/null and b/test/e2e/screens/a2b6a7e/chrome/test-block.png differ
diff --git a/test/e2e/screens/c1dabd5/chrome/text-block.png b/test/e2e/screens/c1dabd5/chrome/text-block.png
new file mode 100644
index 000000000..0add39e36
Binary files /dev/null and b/test/e2e/screens/c1dabd5/chrome/text-block.png differ
diff --git a/test/e2e/screens/e065453/chrome/test-block.png b/test/e2e/screens/e065453/chrome/test-block.png
index 35c4d3ba2..387008db8 100644
Binary files a/test/e2e/screens/e065453/chrome/test-block.png and b/test/e2e/screens/e065453/chrome/test-block.png differ
diff --git a/test/e2e/static/just-sticky-element.html b/test/e2e/static/just-sticky-element.html
new file mode 100644
index 000000000..76bd220d9
--- /dev/null
+++ b/test/e2e/static/just-sticky-element.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+ Sticky Element Test
+
+
+
+ Sticky Element Demo
+
+
+
I'm a sticky element (red border) - I'll stick to the top when you scroll!
+
+
+
diff --git a/test/e2e/static/overlapping-blocks-at-y2000.html b/test/e2e/static/overlapping-blocks-at-y2000.html
index aa34d3076..f331d4fe5 100644
--- a/test/e2e/static/overlapping-blocks-at-y2000.html
+++ b/test/e2e/static/overlapping-blocks-at-y2000.html
@@ -106,7 +106,9 @@ Test Block Content
// After page loads, scroll to position the text block perfectly under the fixed overlay
// Scroll to the calculated position
setTimeout(() => {
- window.scrollTo(0, 1197);
+ const overlayBlock = document.querySelector(".overlay-block");
+ const targetBlock = document.querySelector(".text-block");
+ window.scrollTo(0, targetBlock.getBoundingClientRect().y - overlayBlock.getBoundingClientRect().y);
}, 10);
+ Scrollable Container with Bottom Block
+
+
+
diff --git a/test/e2e/static/small-block-at-the-bottom-of-scrollable-container.html b/test/e2e/static/small-block-at-the-bottom-of-scrollable-container.html
new file mode 100644
index 000000000..218b2835d
--- /dev/null
+++ b/test/e2e/static/small-block-at-the-bottom-of-scrollable-container.html
@@ -0,0 +1,50 @@
+
+
+