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 @@ ![](./terminology.svg) +### 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); 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 @@ + + + + + + Small Block at Bottom + + + +

Scrollable Container with Bottom Block

+ +
+
+

This is very long content. Scroll down to see the block at the bottom.

+

Keep scrolling...

+
+ +
+ I'm a block at the bottom of the scrollable container (red border) +
+
+ + diff --git a/test/e2e/static/viewport-sized-block.html b/test/e2e/static/viewport-sized-block.html new file mode 100644 index 000000000..6e1817ed9 --- /dev/null +++ b/test/e2e/static/viewport-sized-block.html @@ -0,0 +1,65 @@ + + + + + + Viewport-sized Block + + + +
This is before the viewport-sized block
+ +
Some header
+ + +
This block is of viewport size
+ +
This is after the viewport-sized block
+ + diff --git a/test/e2e/testplane.config.ts b/test/e2e/testplane.config.ts index 53002c7d8..c7ce8558f 100644 --- a/test/e2e/testplane.config.ts +++ b/test/e2e/testplane.config.ts @@ -15,9 +15,15 @@ export default { sets: { assertView: { files: path.join(__dirname, "tests/assert-view.testplane.js"), + browsers: ["chrome"], }, reportPageScreenshot: { files: path.join(__dirname, "tests/report-page-screenshot.testplane.js"), + browsers: ["chrome"], + }, + calibrationResize: { + files: path.join(__dirname, "tests/calibration-resize.testplane.js"), + browsers: ["calibrated-chrome"], }, }, @@ -41,6 +47,21 @@ export default { }, waitTimeout: 3000, }, + "calibrated-chrome": { + assertViewOpts: { + ignoreDiffPixelCount: 4, + }, + calibrate: true, + windowSize: "360x640", + desiredCapabilities: { + browserName: "chrome", + "goog:chromeOptions": { + args: ["headless", "no-sandbox", "hide-scrollbars", "disable-dev-shm-usage"], + binary: "/usr/bin/chromium", + }, + }, + waitTimeout: 3000, + }, }, devServer: { diff --git a/test/e2e/tests/assert-view.testplane.js b/test/e2e/tests/assert-view.testplane.js index eddf18cf7..7ed20db4a 100644 --- a/test/e2e/tests/assert-view.testplane.js +++ b/test/e2e/tests/assert-view.testplane.js @@ -300,13 +300,20 @@ describe("assertView", () => { it("should resume animations after assertView failure", async ({ browser }) => { await browser.url("animation-cleanup.html"); - await expect(() => - browser.assertView("animation-cleanup-fail", "[data-testid=too-tall]", { - disableAnimation: true, - compositeImage: false, - captureElementFromTop: true, - }), - ).rejects.toThrow(); + const originalTakeScreenshot = browser.takeScreenshot.bind(browser); + browser.overwriteCommand("takeScreenshot", async () => { + throw new Error("Forced screenshot failure"); + }); + + try { + await expect( + browser.assertView("animation-cleanup-fail", "[data-testid=animated-block]", { + disableAnimation: true, + }), + ).rejects.toThrow("Forced screenshot failure"); + } finally { + browser.overwriteCommand("takeScreenshot", async () => originalTakeScreenshot()); + } const state = await browser.execute(() => { const targetElement = document.querySelector("[data-testid=animated-block]"); @@ -318,9 +325,13 @@ describe("assertView", () => { return { animationDuration, someElementHasAnimationStoppedStyle: hasStyle, + animationStyleInserted: window.__animationStyleInserted, + animationStyleRemoved: window.__animationStyleRemoved, }; }); + expect(state.animationStyleInserted).toBe(true); + expect(state.animationStyleRemoved).toBe(true); expect(state.someElementHasAnimationStoppedStyle).toBe(false); expect(state.animationDuration).toBe("0.2s"); }); @@ -329,8 +340,38 @@ describe("assertView", () => { it("should work fine when capturing elements that are overlapping", async ({ browser }) => { await browser.url("overlapping-blocks-at-y2000.html"); - await expect(() => - browser.assertView("text-block", "[data-testid=text-block]", { captureElementFromTop: false }), - ).rejects.toThrow("The element is completely obscured by fixed or sticky elements"); + await browser.assertView("text-block", "[data-testid=text-block]", { captureElementFromTop: false }); + }); + + it("should work fine when capturing elements that contain only sticky element", async ({ browser }) => { + await browser.url("just-sticky-element.html"); + + await browser.assertView("test-block", "[data-testid=parent-block]"); + }); + + it("should be able to scroll to a block inside a scrollable container", async ({ browser }) => { + await browser.url("small-block-at-the-bottom-of-scrollable-container.html"); + + await browser.execute(() => { + document.querySelector("[data-testid=bottom-block]").scrollIntoView(); + }); + + await browser.assertView("test-block", "[data-testid=bottom-block]"); + }); + + it("should screenshot exactly the viewport when compositeImage=false and allowViewportOverflow=true", async ({ + browser, + }) => { + await browser.url("viewport-sized-block.html"); + + await browser.execute(() => { + document.querySelector("[data-testid=viewport-block]").scrollIntoView(); + }); + + await browser.assertView("viewport", "body", { + captureElementFromTop: false, + compositeImage: false, + allowViewportOverflow: true, + }); }); }); diff --git a/test/e2e/tests/calibration-resize.testplane.js b/test/e2e/tests/calibration-resize.testplane.js new file mode 100644 index 000000000..1064202ed --- /dev/null +++ b/test/e2e/tests/calibration-resize.testplane.js @@ -0,0 +1,11 @@ +describe("calibration", () => { + it("should work correctly when window size changes", async ({ browser }) => { + await browser.url("viewport-sized-block.html"); + + await browser.assertView("before", '[data-testid="viewport-block"]', { compositeImage: false }); + + await browser.setWindowSize(900, 900); + + await browser.assertView("after", '[data-testid="viewport-block"]', { compositeImage: false }); + }); +}); diff --git a/test/integration/screen-shooter/screen-shooter.test.ts b/test/integration/screen-shooter/screen-shooter.test.ts index f6d9fc73c..287857a6b 100644 --- a/test/integration/screen-shooter/screen-shooter.test.ts +++ b/test/integration/screen-shooter/screen-shooter.test.ts @@ -18,6 +18,10 @@ import { closeServer, startFixtureServer } from "./utils"; const SCREENSHOTS_PATH = path.join(__dirname, "screens"); const HORIZONTAL_OVERFLOW_WARNING_PART = "outside of horizontal viewport bounds"; const TEMP_DIR_PREFIX = "testplane-elements-screen-shooter-"; +const shouldUseLocalBrowser = Boolean(process.env.USE_LOCAL_BROWSER); +const SCREENSHOOTER_BROWSER_CONFIG = shouldUseLocalBrowser + ? BROWSER_CONFIG + : { ...BROWSER_CONFIG, gridUrl: "http://127.0.0.1:4444/" }; const createScreenShooter = async (browser: WdioBrowser): Promise => { const camera = Camera.create("auto", () => browser.takeScreenshot()); @@ -42,10 +46,17 @@ describe("ElementsScreenShooter integration", function () { let pageUrl = ""; let warningSpy: sinon.SinonSpy | null = null; + before(function () { + // Our docker image has chrome only + if (!shouldUseLocalBrowser && BROWSER_CONFIG.desiredCapabilities.browserName !== "chrome") { + this.skip(); + } + }); + beforeEach(async () => { tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), TEMP_DIR_PREFIX)); browser = await launchBrowser({ - ...BROWSER_CONFIG, + ...SCREENSHOOTER_BROWSER_CONFIG, windowSize: "1280x1000", }); }); @@ -56,7 +67,7 @@ describe("ElementsScreenShooter integration", function () { browser = null; } - if (tempDir) { + if (tempDir && !process.env.KEEP_ACTUAL) { await fs.promises.rm(tempDir, { recursive: true, force: true }); tempDir = null; } @@ -79,6 +90,7 @@ describe("ElementsScreenShooter integration", function () { }); it("prints horizontal overflow warning and captures only visible part", async () => { + assert.ok(tempDir); await browser!.url(`${pageUrl}/partially-offscreen.html`); warningSpy = sinon.spy(console, "warn"); @@ -103,7 +115,7 @@ describe("ElementsScreenShooter integration", function () { "Expected warning to contain partially offscreen element selector", ); - const actualImagePath = path.join(tempDir!, "actual.png"); + const actualImagePath = path.join(tempDir, "partially-offscreen.actual.png"); await image.save(actualImagePath); const expectedImagePath = path.join(SCREENSHOTS_PATH, "partially-offscreen.png"); @@ -117,6 +129,7 @@ describe("ElementsScreenShooter integration", function () { }); it("captures long screenshot with deterministic geometry changes", async () => { + assert.ok(tempDir); await browser!.url(`${pageUrl}/deterministic-changing-dimensions.html`); const screenShooter = await createScreenShooter(browser as WdioBrowser); @@ -125,7 +138,7 @@ describe("ElementsScreenShooter integration", function () { selectorToScroll: ".Modal-Wrapper", }); - const actualImagePath = path.join(tempDir!, "deterministic-changing-dimensions.png"); + const actualImagePath = path.join(tempDir, "deterministic-changing-dimensions.png"); await image.save(actualImagePath); const stat = await fs.promises.stat(actualImagePath); @@ -142,6 +155,7 @@ describe("ElementsScreenShooter integration", function () { }); it("captures full page body with dynamic sticky menu fixture", async () => { + assert.ok(tempDir); await browser!.url(`${pageUrl}/dynamic-sticky-menu-safe-area.html`); const screenShooter = await createScreenShooter(browser as WdioBrowser); @@ -152,7 +166,7 @@ describe("ElementsScreenShooter integration", function () { disableAnimation: true, }); - const actualImagePath = path.join(__dirname, "screens", "dynamic-sticky-menu-safe-area.png"); + const actualImagePath = path.join(tempDir, "dynamic-sticky-menu-safe-area.png"); await image.save(actualImagePath); const expectedImagePath = path.join(SCREENSHOTS_PATH, "dynamic-sticky-menu-safe-area.png"); @@ -166,6 +180,7 @@ describe("ElementsScreenShooter integration", function () { }); it("captures only the visible part of a long block when allowViewportOverflow=true and captureElementFromTop=false", async () => { + assert.ok(tempDir); await browser!.url(`${pageUrl}/visible-top-long-block-overflow.html`); const screenShooter = await createScreenShooter(browser as WdioBrowser); @@ -175,7 +190,7 @@ describe("ElementsScreenShooter integration", function () { disableAnimation: true, }); - const actualImagePath = path.join(__dirname, "screens", "visible-top-long-block-overflow.png"); + const actualImagePath = path.join(tempDir, "visible-top-long-block-overflow.png"); await image.save(actualImagePath); const expectedImagePath = path.join(SCREENSHOTS_PATH, "visible-top-long-block-overflow.png"); @@ -195,30 +210,29 @@ describe("ElementsScreenShooter integration", function () { const screenShooter = await createScreenShooter(browser as WdioBrowser); - const { image } = await screenShooter.capture(".Modal-Content", { - compositeImage: true, - selectorToScroll: ".Modal-Wrapper", - }); - - const actualImagePath = path.join(tempDir!, "non-deterministic-changing-dimensions.png"); - await image.save(actualImagePath); - - const expectedImagePath = path.join(SCREENSHOTS_PATH, "non-deterministic-changing-dimensions.png"); - - if (process.env.UPDATE_REFERENCES) { - await fs.promises.copyFile(actualImagePath, expectedImagePath); - } - - const comparison = await looksSame(actualImagePath, expectedImagePath); - assert(comparison.equal, "Expected screenshot to match reference image"); + await assert.doesNotReject(() => + screenShooter.capture(".Modal-Content", { + compositeImage: true, + selectorToScroll: ".Modal-Wrapper", + }), + ); }); it("keeps fractional checkpoint offsets stable during replay", async () => { assert.ok(browser); - const browserConfig = _.cloneDeep(BROWSER_CONFIG); + const browserConfig = _.cloneDeep(SCREENSHOOTER_BROWSER_CONFIG); + // This test is only applicable to Chrome, it's hard to replicate the issue in firefox + if (BROWSER_CONFIG.desiredCapabilities.browserName !== "chrome") { + return; + } + + const chromeOptions = _.get(browserConfig.desiredCapabilities, "goog:chromeOptions", {}); + const chromeArgs = _.get(chromeOptions, "args", []); _.set(browserConfig.desiredCapabilities, "goog:chromeOptions", { + ...chromeOptions, args: [ + ...chromeArgs, "--force-device-scale-factor=3", "--high-dpi-support=1", "--screen-info={devicePixelRatio=3}", @@ -262,7 +276,7 @@ describe("ElementsScreenShooter integration", function () { compositeImage: true, }); - const actualImagePath = path.join(__dirname, "screens", "fixed-block-slightly-off-viewport.png"); + const actualImagePath = path.join(tempDir, "fixed-block-slightly-off-viewport.png"); await image.save(actualImagePath); const expectedImagePath = path.join(SCREENSHOTS_PATH, "fixed-block-slightly-off-viewport.png"); diff --git a/test/integration/screen-shooter/screens/deterministic-changing-dimensions.png b/test/integration/screen-shooter/screens/deterministic-changing-dimensions.png index 3aa4ebba6..44f9fb7be 100644 Binary files a/test/integration/screen-shooter/screens/deterministic-changing-dimensions.png and b/test/integration/screen-shooter/screens/deterministic-changing-dimensions.png differ diff --git a/test/integration/screen-shooter/screens/dynamic-sticky-menu-safe-area.png b/test/integration/screen-shooter/screens/dynamic-sticky-menu-safe-area.png index 878658db2..01ccf7923 100644 Binary files a/test/integration/screen-shooter/screens/dynamic-sticky-menu-safe-area.png and b/test/integration/screen-shooter/screens/dynamic-sticky-menu-safe-area.png differ diff --git a/test/integration/screen-shooter/screens/fixed-block-slightly-off-viewport.png b/test/integration/screen-shooter/screens/fixed-block-slightly-off-viewport.png index 804841176..80bf7f00b 100644 Binary files a/test/integration/screen-shooter/screens/fixed-block-slightly-off-viewport.png and b/test/integration/screen-shooter/screens/fixed-block-slightly-off-viewport.png differ diff --git a/test/integration/screen-shooter/screens/non-deterministic-changing-dimensions.png b/test/integration/screen-shooter/screens/non-deterministic-changing-dimensions.png deleted file mode 100644 index 21c97e9bb..000000000 Binary files a/test/integration/screen-shooter/screens/non-deterministic-changing-dimensions.png and /dev/null differ diff --git a/test/integration/screen-shooter/screens/partially-offscreen.png b/test/integration/screen-shooter/screens/partially-offscreen.png index b2ffe725c..037ec9f1e 100644 Binary files a/test/integration/screen-shooter/screens/partially-offscreen.png and b/test/integration/screen-shooter/screens/partially-offscreen.png differ diff --git a/test/integration/screen-shooter/screens/visible-top-long-block-overflow.png b/test/integration/screen-shooter/screens/visible-top-long-block-overflow.png index b99c2320a..8e7aa9d2b 100644 Binary files a/test/integration/screen-shooter/screens/visible-top-long-block-overflow.png and b/test/integration/screen-shooter/screens/visible-top-long-block-overflow.png differ diff --git a/test/integration/screen-shooter/utils.ts b/test/integration/screen-shooter/utils.ts index d9a7e3cbe..f1513ac88 100644 --- a/test/integration/screen-shooter/utils.ts +++ b/test/integration/screen-shooter/utils.ts @@ -3,6 +3,7 @@ import getPort from "get-port"; import path from "node:path"; import fs from "node:fs"; const FIXTURES_DIR = path.join(__dirname, "fixtures"); +const shouldUseLocalBrowser = Boolean(process.env.USE_LOCAL_BROWSER); export const closeServer = (server: http.Server): Promise => new Promise((resolve, reject) => { @@ -37,11 +38,14 @@ export const startFixtureServer = async (): Promise<{ server: http.Server; pageU await new Promise((resolve, reject) => { const onError = (error: Error): void => reject(error); server.once("error", onError); - server.listen(port, "127.0.0.1", () => { + server.listen(port, shouldUseLocalBrowser ? "127.0.0.1" : "0.0.0.0", () => { server.off("error", onError); resolve(); }); }); - return { server, pageUrl: `http://127.0.0.1:${port}` }; + return { + server, + pageUrl: `http://${shouldUseLocalBrowser ? "127.0.0.1" : "host.docker.internal"}:${port}`, + }; }; diff --git a/test/src/browser/calibrator.js b/test/src/browser/calibrator.js index 42ed97d45..04182b104 100644 --- a/test/src/browser/calibrator.js +++ b/test/src/browser/calibrator.js @@ -56,6 +56,8 @@ describe("calibrator", () => { const result = await calibrator.calibrate(browser); assert.match(result.feature, "value"); + assert.isAbove(result.screenshotSize.width, 0); + assert.isAbove(result.screenshotSize.height, 0); }); it("should not perform the calibration process two times", async () => { diff --git a/test/src/browser/camera/index.js b/test/src/browser/camera/index.js index ce39ed118..bee833dc8 100644 --- a/test/src/browser/camera/index.js +++ b/test/src/browser/camera/index.js @@ -45,7 +45,7 @@ describe("browser/camera", () => { const camera = Camera.create(null, sinon.stub().resolves()); image.getSize.resolves({ width: 10, height: 10 }); - camera.calibrate({ top: 6, left: 4, width: 10, height: 10 }); + camera.calibrate({ top: 6, left: 4, width: 10, height: 10 }, { width: 10, height: 10 }); await camera.captureViewportImage(); assert.calledOnceWith(image.crop, { @@ -55,6 +55,16 @@ describe("browser/camera", () => { height: 10 - 6, }); }); + + it("should not apply calibration when screenshot size differs from calibration screenshot size", async () => { + const camera = Camera.create(null, sinon.stub().resolves()); + image.getSize.resolves({ width: 20, height: 20 }); + + camera.calibrate({ top: 6, left: 4, width: 10, height: 10 }, { width: 10, height: 10 }); + await camera.captureViewportImage(); + + assert.notCalled(image.crop); + }); }); describe("crop to viewport", () => { diff --git a/test/src/browser/cdp/connection.ts b/test/src/browser/cdp/connection.ts index 9c37aad08..08ac1dcfc 100644 --- a/test/src/browser/cdp/connection.ts +++ b/test/src/browser/cdp/connection.ts @@ -1,5 +1,5 @@ import { WebSocket, WebSocketServer } from "ws"; -import sinon, { SinonStub, SinonFakeTimers } from "sinon"; +import sinon, { type SinonStub, type SinonFakeTimers } from "sinon"; import proxyquire from "proxyquire"; import { CDPConnection } from "src/browser/cdp/connection"; import { CDPError, CDPConnectionTerminatedError } from "src/browser/cdp/error"; diff --git a/test/src/browser/cdp/selectivity/css-selectivity.ts b/test/src/browser/cdp/selectivity/css-selectivity.ts index 0d49292e4..ed4453a98 100644 --- a/test/src/browser/cdp/selectivity/css-selectivity.ts +++ b/test/src/browser/cdp/selectivity/css-selectivity.ts @@ -1,4 +1,4 @@ -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import proxyquire from "proxyquire"; describe("CDP/Selectivity/CSSSelectivity", () => { diff --git a/test/src/browser/cdp/selectivity/hash-provider.ts b/test/src/browser/cdp/selectivity/hash-provider.ts index c77bfea6c..acc552257 100644 --- a/test/src/browser/cdp/selectivity/hash-provider.ts +++ b/test/src/browser/cdp/selectivity/hash-provider.ts @@ -1,4 +1,4 @@ -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import proxyquire from "proxyquire"; import { EventEmitter } from "events"; diff --git a/test/src/browser/cdp/selectivity/hash-reader.ts b/test/src/browser/cdp/selectivity/hash-reader.ts index a49023055..f3ef80ee2 100644 --- a/test/src/browser/cdp/selectivity/hash-reader.ts +++ b/test/src/browser/cdp/selectivity/hash-reader.ts @@ -1,4 +1,4 @@ -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import proxyquire from "proxyquire"; describe("CDP/Selectivity/HashReader", () => { diff --git a/test/src/browser/cdp/selectivity/hash-writer.ts b/test/src/browser/cdp/selectivity/hash-writer.ts index da1443bab..dd8a8e249 100644 --- a/test/src/browser/cdp/selectivity/hash-writer.ts +++ b/test/src/browser/cdp/selectivity/hash-writer.ts @@ -1,4 +1,4 @@ -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import proxyquire from "proxyquire"; describe("CDP/Selectivity/HashWriter", () => { diff --git a/test/src/browser/cdp/selectivity/index.ts b/test/src/browser/cdp/selectivity/index.ts index 9aa12a0b5..46facdd55 100644 --- a/test/src/browser/cdp/selectivity/index.ts +++ b/test/src/browser/cdp/selectivity/index.ts @@ -1,4 +1,4 @@ -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import proxyquire from "proxyquire"; import type { ExistingBrowser } from "src/browser/existing-browser"; import type { Test } from "src/types"; diff --git a/test/src/browser/cdp/selectivity/js-selectivity.ts b/test/src/browser/cdp/selectivity/js-selectivity.ts index ab6e69cad..e0cd74e45 100644 --- a/test/src/browser/cdp/selectivity/js-selectivity.ts +++ b/test/src/browser/cdp/selectivity/js-selectivity.ts @@ -1,4 +1,4 @@ -import sinon, { SinonStub, type SinonStubbedInstance } from "sinon"; +import sinon, { type SinonStub, type SinonStubbedInstance } from "sinon"; import proxyquire from "proxyquire"; import type { CDPTarget } from "src/browser/cdp/domains/target"; import type { CDPDebugger } from "src/browser/cdp/domains/debugger"; diff --git a/test/src/browser/cdp/selectivity/runner.ts b/test/src/browser/cdp/selectivity/runner.ts index 01a228ff7..561cf4ecc 100644 --- a/test/src/browser/cdp/selectivity/runner.ts +++ b/test/src/browser/cdp/selectivity/runner.ts @@ -1,4 +1,4 @@ -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import proxyquire from "proxyquire"; import type { SelectivityRunner } from "src/browser/cdp/selectivity/runner"; import type { Test } from "src/types"; diff --git a/test/src/browser/cdp/selectivity/test-dependencies-reader.ts b/test/src/browser/cdp/selectivity/test-dependencies-reader.ts index afedf6e33..814383c56 100644 --- a/test/src/browser/cdp/selectivity/test-dependencies-reader.ts +++ b/test/src/browser/cdp/selectivity/test-dependencies-reader.ts @@ -1,4 +1,4 @@ -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import proxyquire from "proxyquire"; describe("CDP/Selectivity/TestDependenciesReader", () => { diff --git a/test/src/browser/cdp/selectivity/test-dependencies-writer.ts b/test/src/browser/cdp/selectivity/test-dependencies-writer.ts index 6b58f7570..1b947127d 100644 --- a/test/src/browser/cdp/selectivity/test-dependencies-writer.ts +++ b/test/src/browser/cdp/selectivity/test-dependencies-writer.ts @@ -1,4 +1,4 @@ -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import proxyquire from "proxyquire"; describe("CDP/Selectivity/TestDependenciesWriter", () => { diff --git a/test/src/browser/cdp/selectivity/utils.ts b/test/src/browser/cdp/selectivity/utils.ts index 14bfff688..88be96ba8 100644 --- a/test/src/browser/cdp/selectivity/utils.ts +++ b/test/src/browser/cdp/selectivity/utils.ts @@ -1,4 +1,4 @@ -import sinon, { SinonStub, type SinonStubbedInstance } from "sinon"; +import sinon, { type SinonStub, type SinonStubbedInstance } from "sinon"; import proxyquire from "proxyquire"; import type { CDPRuntime } from "src/browser/cdp/domains/runtime"; diff --git a/test/src/browser/client-bridge/index.ts b/test/src/browser/client-bridge/index.ts index f9a989301..8e655eb60 100644 --- a/test/src/browser/client-bridge/index.ts +++ b/test/src/browser/client-bridge/index.ts @@ -115,9 +115,10 @@ describe("ClientBridge", () => { const modulePath = require.resolve("src/browser/client-bridge"); // This is needed to clear the cache of the client-bridge module delete require.cache[modulePath]; - // eslint-disable-next-line @typescript-eslint/no-var-requires + /* eslint-disable @typescript-eslint/no-var-requires */ const { ClientBridge: IsolatedClientBridge } = require("src/browser/client-bridge") as typeof import("src/browser/client-bridge"); + /* eslint-enable @typescript-eslint/no-var-requires */ const readFileStub = fs.promises.readFile as unknown as SinonStub; readFileStub.callsFake(async (filePath: fs.PathOrFileDescriptor) => { diff --git a/test/src/browser/client-scripts/build.ts b/test/src/browser/client-scripts/build.ts index 0166b6462..7cb60edd5 100644 --- a/test/src/browser/client-scripts/build.ts +++ b/test/src/browser/client-scripts/build.ts @@ -5,7 +5,8 @@ import fs from "fs-extra"; describe("client-scripts/build", () => { const sandbox = sinon.createSandbox(); - const targetDir = "build/src/browser/client-scripts"; + const targetDir = path.resolve(process.cwd(), "src", "browser", "client-scripts", "browser-utils"); + const buildDir = path.join(targetDir, "build"); let ensureDirStub: SinonStub; let writeFileStub: SinonStub; @@ -22,32 +23,38 @@ describe("client-scripts/build", () => { const buildClientScripts_ = async (): Promise => { const clearRequire = require("clear-require"); // eslint-disable-line @typescript-eslint/no-var-requires const scriptPath = path.resolve(process.cwd(), "src", "browser", "client-scripts", "build"); + const originalArgv = process.argv; clearRequire(scriptPath); + process.argv = [...process.argv.slice(0, 2), targetDir]; - await require("../../../../src/browser/client-scripts/build"); + try { + await require("../../../../src/browser/client-scripts/build"); + } finally { + process.argv = originalArgv; + } }; const assertForNativeLibrary_ = (): void => { assert.calledWithMatch(transformSpy, { aliases: { - "./lib": { relative: "./lib.native.js" }, + "@lib": "./src/browser/client-scripts/browser-utils/tsc-out/client-scripts/shared/lib.native.js", }, verbose: false, }); - assert.calledWith(ensureDirStub, targetDir); - assert.calledWith(writeFileStub, path.join(targetDir, "bundle.native.js"), sinon.match(Buffer)); + assert.calledWith(ensureDirStub, buildDir); + assert.calledWith(writeFileStub, path.join(buildDir, "bundle.native.js"), sinon.match.string); }; const assertForCompatLibrary_ = (): void => { assert.calledWithMatch(transformSpy, { aliases: { - "./lib": { relative: "./lib.compat.js" }, + "@lib": "./src/browser/client-scripts/browser-utils/tsc-out/client-scripts/shared/lib.compat.js", }, verbose: false, }); - assert.calledWith(ensureDirStub, targetDir); - assert.calledWith(writeFileStub, path.join(targetDir, "bundle.compat.js"), sinon.match(Buffer)); + assert.calledWith(ensureDirStub, buildDir); + assert.calledWith(writeFileStub, path.join(buildDir, "bundle.compat.js"), sinon.match.string); }; it("should build bundles for compat and native library", async function () { diff --git a/test/src/browser/commands/clearSession.ts b/test/src/browser/commands/clearSession.ts index 19f753d1e..098937987 100644 --- a/test/src/browser/commands/clearSession.ts +++ b/test/src/browser/commands/clearSession.ts @@ -1,4 +1,4 @@ -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import { mkExistingBrowser_ as mkBrowser_, mkSessionStub_ } from "../utils"; diff --git a/test/src/browser/commands/openAndWait.ts b/test/src/browser/commands/openAndWait.ts index f691c7ade..7bd475dcb 100644 --- a/test/src/browser/commands/openAndWait.ts +++ b/test/src/browser/commands/openAndWait.ts @@ -1,4 +1,4 @@ -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import proxyquire from "proxyquire"; import FakeTimers from "@sinonjs/fake-timers"; import PageLoader from "src/utils/page-loader"; diff --git a/test/src/browser/commands/switchToRepl.ts b/test/src/browser/commands/switchToRepl.ts index e95118516..66b8c8608 100644 --- a/test/src/browser/commands/switchToRepl.ts +++ b/test/src/browser/commands/switchToRepl.ts @@ -1,4 +1,4 @@ -import repl, { type REPLServer } from "node:repl"; +import { type REPLServer } from "node:repl"; import net from "node:net"; import { PassThrough } from "node:stream"; import { EventEmitter } from "node:events"; @@ -19,10 +19,14 @@ describe('"switchToRepl" command', () => { const originalStdout = process.stdout; let ExistingBrowser: typeof ExistingBrowserOriginal; + let replStart: SinonStub; + let netCreateServerStub: SinonStub; let logStub: SinonStub; let warnStub: SinonStub; let webdriverioAttachStub: SinonStub; let clientBridgeBuildStub; + let replServer: REPLServer; + let netServer: net.Server; let netCreateServerCb: (socket: net.Socket) => void; const initBrowser_ = ({ @@ -41,8 +45,6 @@ describe('"switchToRepl" command', () => { const replServer = new EventEmitter() as REPLServer; (replServer.context as unknown) = {}; - sandbox.stub(repl, "start").returns(replServer); - return replServer; }; @@ -51,11 +53,6 @@ describe('"switchToRepl" command', () => { netServer.listen = sandbox.stub().named("listen").returnsThis(); netServer.close = sandbox.stub().named("close").returnsThis(); - (sandbox.stub(net, "createServer") as SinonStub).callsFake(cb => { - netCreateServerCb = cb; - return netServer; - }); - return netServer; }; @@ -68,13 +65,11 @@ describe('"switchToRepl" command', () => { return socket; }; - const switchToRepl_ = async ({ - session = mkSessionStub_(), - replServer = mkReplServer_(), - ctx = {}, - }): Promise => { + const switchToRepl_ = async ({ session = mkSessionStub_(), ctx = {} }): Promise => { const promise = session.switchToRepl(ctx); + await new Promise(resolve => setImmediate(resolve)); + replServer.emit("exit"); await promise; }; @@ -82,7 +77,11 @@ describe('"switchToRepl" command', () => { beforeEach(() => { logStub = sandbox.stub(); warnStub = sandbox.stub(); + replStart = sandbox.stub(); + replServer = mkReplServer_(); + netServer = mkNetServer_(); + netCreateServerStub = sandbox.stub(); webdriverioAttachStub = sandbox.stub(); clientBridgeBuildStub = sandbox.stub().resolves(); @@ -95,6 +94,15 @@ describe('"switchToRepl" command', () => { }, "../utils/logger": { warn: warnStub, log: logStub }, "./commands/switchToRepl": proxyquire("src/browser/commands/switchToRepl", { + "node:repl": { + start: replStart.returns(replServer), + }, + "node:net": { + createServer: netCreateServerStub.callsFake(cb => { + netCreateServerCb = cb; + return netServer; + }), + }, "../../utils/logger": { warn: warnStub, log: logStub }, }), }).ExistingBrowser; @@ -117,7 +125,7 @@ describe('"switchToRepl" command', () => { sandbox.restore(); Object.defineProperty(process, "stdin", { value: originalStdin }); - Object.defineProperty(process, "sdout", { value: originalStdout }); + Object.defineProperty(process, "stdout", { value: originalStdout }); }); it("should add command", async () => { @@ -141,10 +149,7 @@ describe('"switchToRepl" command', () => { }); describe("in REPL mode", async () => { - let netServer!: net.Server; - beforeEach(() => { - netServer = mkNetServer_(); (RuntimeConfig.getInstance as SinonStub).returns({ replMode: { enabled: true, port: 12345 }, extend: sinon.stub(), @@ -163,7 +168,7 @@ describe('"switchToRepl" command', () => { "You have entered to REPL mode via terminal (test execution timeout is disabled). Port to connect to REPL from other terminals: 12345", ), ); - assert.callOrder(logStub as SinonStub, repl.start as SinonStub); + assert.callOrder(logStub as SinonStub, replStart); }); it("should change cwd to test directory before run repl server", async () => { @@ -173,7 +178,7 @@ describe('"switchToRepl" command', () => { await initBrowser_({ session }); await switchToRepl_({ session }); - assert.callOrder((process.chdir as SinonStub).withArgs("/root/project/dir"), repl.start as SinonStub); + assert.callOrder((process.chdir as SinonStub).withArgs("/root/project/dir"), replStart); }); it("should change cwd to its original value on close repl server", async () => { @@ -182,12 +187,13 @@ describe('"switchToRepl" command', () => { const currCwd = process.cwd(); const onExit = sandbox.spy(); - const replServer = mkReplServer_(); replServer.on("exit", onExit); await initBrowser_({ session }); const promise = session.switchToRepl(); + await new Promise(resolve => setImmediate(resolve)); + replServer.emit("exit"); await promise; @@ -198,31 +204,28 @@ describe('"switchToRepl" command', () => { const runtimeCfg = { replMode: { enabled: true }, extend: sinon.stub() }; (RuntimeConfig.getInstance as SinonStub).returns(runtimeCfg); - const replServer = mkReplServer_(); const session = mkSessionStub_(); await initBrowser_({ session }); - await switchToRepl_({ session, replServer }); + await switchToRepl_({ session }); assert.calledOnceWith(runtimeCfg.extend, { replServer }); }); it("should add browser instance to repl context by default", async () => { const session = mkSessionStub_(); - const replServer = mkReplServer_(); await initBrowser_({ session }); - await switchToRepl_({ session, replServer }); + await switchToRepl_({ session }); assert.deepEqual(replServer.context.browser, session); }); it("should not be able to overwrite browser instance in repl context", async () => { const session = mkSessionStub_(); - const replServer = mkReplServer_(); await initBrowser_({ session }); - await switchToRepl_({ session, replServer }); + await switchToRepl_({ session }); try { replServer.context.browser = "foo"; @@ -233,43 +236,43 @@ describe('"switchToRepl" command', () => { it("should add passed user context to repl server", async () => { const session = mkSessionStub_(); - const replServer = mkReplServer_(); await initBrowser_({ session }); - await switchToRepl_({ session, replServer, ctx: { foo: "bar" } }); + await switchToRepl_({ session, ctx: { foo: "bar" } }); assert.equal(replServer.context.foo, "bar"); }); it("should not create new repl server if old one is already used", async () => { - const replServer = mkReplServer_(); const session = mkSessionStub_(); await initBrowser_({ session }); const promise1 = session.switchToRepl(); + await new Promise(resolve => setImmediate(resolve)); + const promise2 = session.switchToRepl(); + await new Promise(resolve => setImmediate(resolve)); replServer.emit("exit"); await Promise.all([promise1, promise2]); - assert.calledOnce(repl.start as SinonStub); + assert.calledOnce(replStart); assert.calledOnceWith(warnStub, chalk.yellow("Testplane is already in REPL mode")); }); ["const", "let"].forEach(decl => { describe(`"${decl}" declaration to var in order to reassign`, () => { - let replServer: REPLServer; let onLine: SinonSpy; beforeEach(async () => { - replServer = mkReplServer_(); onLine = sandbox.spy(); replServer.on("line", onLine); const session = mkSessionStub_(); await initBrowser_({ session }); - await switchToRepl_({ session, replServer }); + await switchToRepl_({ session }); + await new Promise(resolve => setImmediate(resolve)); }); describe("should modify", () => { @@ -345,6 +348,8 @@ describe('"switchToRepl" command', () => { await initBrowser_({ session }); await switchToRepl_({ session }); + await new Promise(resolve => setImmediate(resolve)); + netCreateServerCb(socket1); netCreateServerCb(socket2); socket1.emit("data", Buffer.from("o.O")); @@ -374,10 +379,10 @@ describe('"switchToRepl" command', () => { it("should close net server on exit from repl", async () => { const session = mkSessionStub_(); - const replServer = mkReplServer_(); await initBrowser_({ session }); const promise = session.switchToRepl(); + await new Promise(resolve => setImmediate(resolve)); replServer.emit("exit"); await promise; @@ -388,15 +393,17 @@ describe('"switchToRepl" command', () => { const socket1 = mkSocket_(); const socket2 = mkSocket_(); const session = mkSessionStub_(); - const replServer = mkReplServer_(); await initBrowser_({ session }); const promise = session.switchToRepl(); + await new Promise(resolve => setImmediate(resolve)); + netCreateServerCb(socket1); netCreateServerCb(socket2); replServer.emit("exit"); + await new Promise(resolve => setImmediate(resolve)); await promise; assert.calledOnceWith(socket1.end, "The server was closed after the REPL was exited"); diff --git a/test/src/browser/commands/waitForStaticToLoad.ts b/test/src/browser/commands/waitForStaticToLoad.ts index 08703d7b0..5d4a50ac4 100644 --- a/test/src/browser/commands/waitForStaticToLoad.ts +++ b/test/src/browser/commands/waitForStaticToLoad.ts @@ -1,4 +1,4 @@ -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import FakeTimers from "@sinonjs/fake-timers"; import { mkExistingBrowser_ as mkBrowser_, mkSessionStub_ as mkSessionStubOrigin_ } from "../utils"; diff --git a/test/src/browser/existing-browser.js b/test/src/browser/existing-browser.js index b581138d8..4709068ec 100644 --- a/test/src/browser/existing-browser.js +++ b/test/src/browser/existing-browser.js @@ -64,7 +64,9 @@ describe("ExistingBrowser", () => { attach: webdriverioAttachStub, }, "./client-bridge": { - build: clientBridgeBuildStub, + ClientBridge: { + create: clientBridgeBuildStub, + }, }, "../utils/logger": { warn: loggerWarnStub, @@ -727,12 +729,14 @@ describe("ExistingBrowser", () => { }); it("should perform calibration if `calibrate` is turn on", async () => { - calibrator.calibrate.withArgs(sinon.match.instanceOf(ExistingBrowser)).resolves({ foo: "bar" }); + calibrator.calibrate + .withArgs(sinon.match.instanceOf(ExistingBrowser)) + .resolves({ viewportArea: { foo: "bar" }, screenshotSize: { width: 100, height: 200 } }); const browser = mkBrowser_({ calibrate: true }); await initBrowser_(browser, {}, calibrator); - assert.calledOnceWith(Camera.prototype.calibrate, { foo: "bar" }); + assert.calledOnceWith(Camera.prototype.calibrate, { foo: "bar" }, { width: 100, height: 200 }); }); it("should not perform calibration if `calibrate` is turn off", async () => { @@ -744,6 +748,9 @@ describe("ExistingBrowser", () => { }); it("should perform calibration after attaching of a session", async () => { + calibrator.calibrate + .withArgs(sinon.match.instanceOf(ExistingBrowser)) + .resolves({ viewportArea: { foo: "bar" }, screenshotSize: { width: 100, height: 200 } }); const browser = mkBrowser_({ calibrate: true }); await initBrowser_(browser, {}, calibrator); @@ -753,14 +760,18 @@ describe("ExistingBrowser", () => { }); }); - it("should build client scripts", async () => { + it("should initialize browser side utils", async () => { const calibrator = sinon.createStubInstance(Calibrator); - calibrator.calibrate.resolves({ foo: "bar" }); + calibrator.calibrate.resolves({ + needsCompatLib: true, + viewportArea: { foo: "bar" }, + screenshotSize: { width: 100, height: 200 }, + }); const browser = mkBrowser_({ calibrate: true }); await initBrowser_(browser, {}, calibrator); - assert.calledOnceWith(clientBridgeBuildStub, browser, { calibration: { foo: "bar" } }); + assert.calledOnceWith(clientBridgeBuildStub, session, "browser-utils", { needsCompatLib: true }); }); }); @@ -796,17 +807,17 @@ describe("ExistingBrowser", () => { describe("captureViewportImage", () => { beforeEach(() => { sandbox.stub(Camera.prototype, "captureViewportImage"); - sandbox.stub(global, "setTimeout").callsFake(fn => fn()); }); - it("should delay capturing on the passed time", () => { - Camera.prototype.captureViewportImage.withArgs({ foo: "bar" }).resolves({ some: "image" }); + it("should pass screenshotDelay to camera object", () => { + Camera.prototype.captureViewportImage + .withArgs({ foo: "bar", screenshotDelay: 2000 }) + .resolves({ some: "image" }); return mkBrowser_({ screenshotDelay: 100500 }) - .captureViewportImage({ foo: "bar" }, 2000) + .captureViewportImage({ foo: "bar", screenshotDelay: 2000 }) .then(() => { - assert.calledOnceWith(global.setTimeout, sinon.match.any, 2000); - assert.callOrder(global.setTimeout, Camera.prototype.captureViewportImage); + assert.calledOnceWith(Camera.prototype.captureViewportImage, { foo: "bar", screenshotDelay: 2000 }); }); }); diff --git a/test/src/browser/isomorphic/geometry.ts b/test/src/browser/isomorphic/geometry.ts index 1a400b0fe..28a7f23db 100644 --- a/test/src/browser/isomorphic/geometry.ts +++ b/test/src/browser/isomorphic/geometry.ts @@ -74,7 +74,7 @@ describe("browser/isomorphic/geometry", () => { it("should format size as text", () => { const size = { width: 10 as Length<"device", "x">, height: 20 as Length<"device", "y"> } as Size<"device">; - assert.equal(prettySize(size), "10 x 20 (width x height)"); + assert.equal(prettySize(size), "{ width: 10, height: 20 }"); }); }); diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/data.json b/test/src/browser/screen-shooter/composite-image/fixtures/data.json new file mode 100644 index 000000000..d6dca3460 --- /dev/null +++ b/test/src/browser/screen-shooter/composite-image/fixtures/data.json @@ -0,0 +1,952 @@ +[ + { + "id": "single-chunk-in-view", + "fullPage": "single-chunk-in-view/full-page.png", + "expected": "single-chunk-in-view/expected.png", + "chunks": [ + { + "file": "single-chunk-in-view/chunks/0.png", + "safeArea": { + "top": 0, + "height": 1024 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + } + ] + }, + { + "id": "single-chunk-slightly-out-of-view", + "fullPage": "single-chunk-slightly-out-of-view/full-page.png", + "expected": "single-chunk-slightly-out-of-view/expected.png", + "chunks": [ + { + "file": "single-chunk-slightly-out-of-view/chunks/0.png", + "safeArea": { + "top": 0, + "height": 1024 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 800, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 800, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + } + ] + }, + { + "id": "single-chunk-completely-out-of-view", + "fullPage": "single-chunk-completely-out-of-view/full-page.png", + "expected": "single-chunk-completely-out-of-view/expected.png", + "chunks": [ + { + "file": "single-chunk-completely-out-of-view/chunks/0.png", + "safeArea": { + "top": 0, + "height": 1024 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 1100, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 1100, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + } + ] + }, + { + "id": "single-chunk-safe-area-expansion-top", + "fullPage": "single-chunk-safe-area-expansion-top/full-page.png", + "expected": "single-chunk-safe-area-expansion-top/expected.png", + "chunks": [ + { + "file": "single-chunk-safe-area-expansion-top/chunks/0.png", + "safeArea": { + "top": 300, + "height": 724 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + } + ] + }, + { + "id": "single-chunk-safe-area-expansion-bottom", + "fullPage": "single-chunk-safe-area-expansion-bottom/full-page.png", + "expected": "single-chunk-safe-area-expansion-bottom/expected.png", + "chunks": [ + { + "file": "single-chunk-safe-area-expansion-bottom/chunks/0.png", + "safeArea": { + "top": 0, + "height": 424 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + } + ] + }, + { + "id": "single-chunk-safe-area-expansion-top-and-bottom", + "fullPage": "single-chunk-safe-area-expansion-top-and-bottom/full-page.png", + "expected": "single-chunk-safe-area-expansion-top-and-bottom/expected.png", + "chunks": [ + { + "file": "single-chunk-safe-area-expansion-top-and-bottom/chunks/0.png", + "safeArea": { + "top": 300, + "height": 324 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + } + ] + }, + { + "id": "two-chunks-with-gap", + "fullPage": "two-chunks-with-gap/full-page.png", + "expected": "two-chunks-with-gap/expected.png", + "chunks": [ + { + "file": "two-chunks-with-gap/chunks/0.png", + "safeArea": { + "top": 0, + "height": 400 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "two-chunks-with-gap/chunks/1.png", + "safeArea": { + "top": 0, + "height": 800 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": -300, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": -300, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + } + ] + }, + { + "id": "two-chunks-relax-upper-bottom", + "fullPage": "two-chunks-relax-upper-bottom/full-page.png", + "expected": "two-chunks-relax-upper-bottom/expected.png", + "chunks": [ + { + "file": "two-chunks-relax-upper-bottom/chunks/0.png", + "safeArea": { + "top": 0, + "height": 400 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "two-chunks-relax-upper-bottom/chunks/1.png", + "safeArea": { + "top": 0, + "height": 400 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": -300, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": -300, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + } + ] + }, + { + "id": "two-chunks-relax-lower-top", + "fullPage": "two-chunks-relax-lower-top/full-page.png", + "expected": "two-chunks-relax-lower-top/expected.png", + "chunks": [ + { + "file": "two-chunks-relax-lower-top/chunks/0.png", + "safeArea": { + "top": 100, + "height": 400 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "two-chunks-relax-lower-top/chunks/1.png", + "safeArea": { + "top": 100, + "height": 400 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": -300, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": -300, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + } + ] + }, + { + "id": "two-equal-chunks", + "fullPage": "two-equal-chunks/full-page.png", + "expected": "two-equal-chunks/expected.png", + "chunks": [ + { + "file": "two-equal-chunks/chunks/0.png", + "safeArea": { + "top": 0, + "height": 1024 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "two-equal-chunks/chunks/1.png", + "safeArea": { + "top": 0, + "height": 1024 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + } + ] + }, + { + "id": "multiple-overlapping-chunks-with-safe-areas", + "fullPage": "multiple-overlapping-chunks-with-safe-areas/full-page.png", + "expected": "multiple-overlapping-chunks-with-safe-areas/expected.png", + "chunks": [ + { + "file": "multiple-overlapping-chunks-with-safe-areas/chunks/0.png", + "safeArea": { + "top": 0, + "height": 300 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "multiple-overlapping-chunks-with-safe-areas/chunks/1.png", + "safeArea": { + "top": 0, + "height": 300 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 100, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 100, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "multiple-overlapping-chunks-with-safe-areas/chunks/2.png", + "safeArea": { + "top": 0, + "height": 300 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 0, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 0, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "multiple-overlapping-chunks-with-safe-areas/chunks/3.png", + "safeArea": { + "top": 0, + "height": 300 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": -100, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": -100, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "multiple-overlapping-chunks-with-safe-areas/chunks/4.png", + "safeArea": { + "top": 0, + "height": 300 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": -200, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": -200, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + } + ] + }, + { + "id": "multiple-chunks-with-horizontal-shifts", + "fullPage": "multiple-chunks-with-horizontal-shifts/full-page.png", + "expected": "multiple-chunks-with-horizontal-shifts/expected.png", + "chunks": [ + { + "file": "multiple-chunks-with-horizontal-shifts/chunks/0.png", + "safeArea": { + "top": 0, + "height": 300 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 200, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "multiple-chunks-with-horizontal-shifts/chunks/1.png", + "safeArea": { + "top": 0, + "height": 300 + }, + "captureSpecs": [ + { + "full": { + "left": 100, + "top": 100, + "width": 500, + "height": 500 + }, + "visible": { + "left": 100, + "top": 100, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "multiple-chunks-with-horizontal-shifts/chunks/2.png", + "safeArea": { + "top": 0, + "height": 300 + }, + "captureSpecs": [ + { + "full": { + "left": 50, + "top": 0, + "width": 500, + "height": 500 + }, + "visible": { + "left": 50, + "top": 0, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "multiple-chunks-with-horizontal-shifts/chunks/3.png", + "safeArea": { + "top": 0, + "height": 300 + }, + "captureSpecs": [ + { + "full": { + "left": 190, + "top": -100, + "width": 500, + "height": 500 + }, + "visible": { + "left": 190, + "top": -100, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "multiple-chunks-with-horizontal-shifts/chunks/4.png", + "safeArea": { + "top": 0, + "height": 300 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": -200, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": -200, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [] + } + ] + }, + { + "id": "multiple-chunks-with-safe-areas-and-ignore-areas", + "fullPage": "multiple-chunks-with-safe-areas-and-ignore-areas/full-page.png", + "expected": "multiple-chunks-with-safe-areas-and-ignore-areas/expected.png", + "chunks": [ + { + "file": "multiple-chunks-with-safe-areas-and-ignore-areas/chunks/0.png", + "safeArea": { + "top": 100, + "height": 224 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 100, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 100, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [ + { + "left": 300, + "top": 0, + "width": 100, + "height": 400 + } + ] + }, + { + "file": "multiple-chunks-with-safe-areas-and-ignore-areas/chunks/1.png", + "safeArea": { + "top": 100, + "height": 224 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 0, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": 0, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [ + { + "left": 300, + "top": -100, + "width": 100, + "height": 400 + } + ] + }, + { + "file": "multiple-chunks-with-safe-areas-and-ignore-areas/chunks/2.png", + "safeArea": { + "top": 100, + "height": 224 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": -100, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": -100, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [ + { + "left": 300, + "top": -200, + "width": 100, + "height": 400 + } + ] + }, + { + "file": "multiple-chunks-with-safe-areas-and-ignore-areas/chunks/3.png", + "safeArea": { + "top": 100, + "height": 224 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": -200, + "width": 500, + "height": 500 + }, + "visible": { + "left": 200, + "top": -200, + "width": 500, + "height": 500 + } + } + ], + "ignoreBoundingRects": [ + { + "left": 300, + "top": -300, + "width": 100, + "height": 400 + } + ] + } + ] + }, + { + "id": "duplicate-chunks-with-offscreen-zero-visible-spec", + "fullPage": "duplicate-chunks-with-offscreen-zero-visible-spec/full-page.png", + "expected": "duplicate-chunks-with-offscreen-zero-visible-spec/expected.png", + "chunks": [ + { + "file": "duplicate-chunks-with-offscreen-zero-visible-spec/chunks/0.png", + "safeArea": { + "top": 0, + "height": 1024 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": 120, + "width": 500, + "height": 420 + }, + "visible": { + "left": 200, + "top": 120, + "width": 500, + "height": 420 + } + }, + { + "full": { + "left": 200, + "top": 620, + "width": 500, + "height": 260 + }, + "visible": { + "left": 200, + "top": 620, + "width": 500, + "height": 260 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "duplicate-chunks-with-offscreen-zero-visible-spec/chunks/1.png", + "safeArea": { + "top": 0, + "height": 1024 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": -500, + "width": 500, + "height": 420 + }, + "visible": { + "left": 200, + "top": -500, + "width": 500, + "height": 0 + } + }, + { + "full": { + "left": 200, + "top": 620, + "width": 500, + "height": 260 + }, + "visible": { + "left": 200, + "top": 620, + "width": 500, + "height": 260 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "duplicate-chunks-with-offscreen-zero-visible-spec/chunks/2.png", + "safeArea": { + "top": 0, + "height": 1024 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": -800, + "width": 500, + "height": 420 + }, + "visible": { + "left": 200, + "top": -800, + "width": 500, + "height": 0 + } + }, + { + "full": { + "left": 200, + "top": 620, + "width": 500, + "height": 260 + }, + "visible": { + "left": 200, + "top": 620, + "width": 500, + "height": 260 + } + } + ], + "ignoreBoundingRects": [] + }, + { + "file": "duplicate-chunks-with-offscreen-zero-visible-spec/chunks/3.png", + "safeArea": { + "top": 0, + "height": 1024 + }, + "captureSpecs": [ + { + "full": { + "left": 200, + "top": -800, + "width": 500, + "height": 420 + }, + "visible": { + "left": 200, + "top": -800, + "width": 500, + "height": 0 + } + }, + { + "full": { + "left": 200, + "top": 620, + "width": 500, + "height": 260 + }, + "visible": { + "left": 200, + "top": 620, + "width": 500, + "height": 260 + } + } + ], + "ignoreBoundingRects": [] + } + ] + } +] diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/chunks/0.png new file mode 100644 index 000000000..d61353c6a Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/chunks/0.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/chunks/1.png b/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/chunks/1.png new file mode 100644 index 000000000..9c0ac7161 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/chunks/1.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/chunks/2.png b/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/chunks/2.png new file mode 100644 index 000000000..9c0ac7161 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/chunks/2.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/chunks/3.png b/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/chunks/3.png new file mode 100644 index 000000000..9c0ac7161 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/chunks/3.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/expected.png new file mode 100644 index 000000000..f99467f3c Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/expected.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/full-page.png new file mode 100644 index 000000000..f99467f3c Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/duplicate-chunks-with-offscreen-zero-visible-spec/full-page.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/generate.ts b/test/src/browser/screen-shooter/composite-image/fixtures/generate.ts index f4af877d0..9cd5e7cbc 100644 --- a/test/src/browser/screen-shooter/composite-image/fixtures/generate.ts +++ b/test/src/browser/screen-shooter/composite-image/fixtures/generate.ts @@ -457,472 +457,475 @@ export const createScenario = async ( return result; }; -const scenarios = [ - createScenario({ - id: "single-chunk-in-view", - pageSize: { width: 1024 as Length<"device", "x">, height: 1024 as Length<"device", "y"> }, - captureArea: { - left: 200 as Coord<"page", "device", "x">, - top: 200 as Coord<"page", "device", "y">, - width: 500 as Length<"device", "x">, - height: 500 as Length<"device", "y">, - }, - ignoreAreas: [], - unsafeAreas: [], - chunks: [ - { - height: 1024 as Length<"device", "y">, - offsetTop: 0 as Coord<"page", "device", "y">, - }, - ], - }), - createScenario({ - id: "single-chunk-slightly-out-of-view", - pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, - captureArea: { - left: 200 as Coord<"page", "device", "x">, - top: 800 as Coord<"page", "device", "y">, - width: 500 as Length<"device", "x">, - height: 500 as Length<"device", "y">, - }, - ignoreAreas: [], - unsafeAreas: [], - chunks: [ - { - height: 1024 as Length<"device", "y">, - offsetTop: 0 as Coord<"page", "device", "y">, - }, - ], - }), - createScenario({ - id: "single-chunk-completely-out-of-view", - pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, - captureArea: { - left: 200 as Coord<"page", "device", "x">, - top: 1100 as Coord<"page", "device", "y">, - width: 500 as Length<"device", "x">, - height: 500 as Length<"device", "y">, - }, - ignoreAreas: [], - unsafeAreas: [], - chunks: [ - { - height: 1024 as Length<"device", "y">, - offsetTop: 0 as Coord<"page", "device", "y">, - }, - ], - }), - createScenario({ - id: "single-chunk-safe-area-expansion-top", - pageSize: { width: 1024 as Length<"device", "x">, height: 1024 as Length<"device", "y"> }, - captureArea: { - left: 200 as Coord<"page", "device", "x">, - top: 200 as Coord<"page", "device", "y">, - width: 500 as Length<"device", "x">, - height: 500 as Length<"device", "y">, - }, - ignoreAreas: [], - unsafeAreas: [ - { - top: 0, - height: 300, - }, - ], - chunks: [ - { - height: 1024 as Length<"device", "y">, - offsetTop: 0 as Coord<"page", "device", "y">, - }, - ], - }), - createScenario({ - id: "single-chunk-safe-area-expansion-bottom", - pageSize: { width: 1024 as Length<"device", "x">, height: 1024 as Length<"device", "y"> }, - captureArea: { - left: 200 as Coord<"page", "device", "x">, - top: 200 as Coord<"page", "device", "y">, - width: 500 as Length<"device", "x">, - height: 500 as Length<"device", "y">, - }, - ignoreAreas: [], - unsafeAreas: [ - { - bottom: 0, - height: 600, - }, - ], - chunks: [ - { - height: 1024 as Length<"device", "y">, - offsetTop: 0 as Coord<"page", "device", "y">, - }, - ], - }), - createScenario({ - id: "single-chunk-safe-area-expansion-top-and-bottom", - pageSize: { width: 1024 as Length<"device", "x">, height: 1024 as Length<"device", "y"> }, - captureArea: { - left: 200 as Coord<"page", "device", "x">, - top: 200 as Coord<"page", "device", "y">, - width: 500 as Length<"device", "x">, - height: 500 as Length<"device", "y">, - }, - ignoreAreas: [], - unsafeAreas: [ - { - top: 0, - height: 300, - }, - { - bottom: 0, - height: 400, - }, - ], - chunks: [ - { - height: 1024 as Length<"device", "y">, - offsetTop: 0 as Coord<"page", "device", "y">, - }, - ], - }), - createScenario({ - id: "two-chunks-with-gap", - pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, - captureArea: { - left: 200 as Coord<"page", "device", "x">, - top: 200 as Coord<"page", "device", "y">, - width: 500 as Length<"device", "x">, - height: 500 as Length<"device", "y">, - }, - ignoreAreas: [], - unsafeAreas: [], - chunks: [ - { - height: 400 as Length<"device", "y">, - offsetTop: 0 as Coord<"page", "device", "y">, - }, - { - height: 800 as Length<"device", "y">, - offsetTop: 500 as Coord<"page", "device", "y">, - }, - ], - }), - createScenario({ - id: "two-chunks-relax-upper-bottom", - pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, - captureArea: { - left: 200 as Coord<"page", "device", "x">, - top: 200 as Coord<"page", "device", "y">, - width: 500 as Length<"device", "x">, - height: 500 as Length<"device", "y">, - }, - ignoreAreas: [], - unsafeAreas: [ - { - bottom: 0, - height: 100, - }, - ], - chunks: [ - { +// This is to prevent the script from running when it's imported by another script +if (process.argv.includes("generate")) { + const scenarios = [ + createScenario({ + id: "single-chunk-in-view", + pageSize: { width: 1024 as Length<"device", "x">, height: 1024 as Length<"device", "y"> }, + captureArea: { + left: 200 as Coord<"page", "device", "x">, + top: 200 as Coord<"page", "device", "y">, + width: 500 as Length<"device", "x">, height: 500 as Length<"device", "y">, - offsetTop: 0 as Coord<"page", "device", "y">, }, - { + ignoreAreas: [], + unsafeAreas: [], + chunks: [ + { + height: 1024 as Length<"device", "y">, + offsetTop: 0 as Coord<"page", "device", "y">, + }, + ], + }), + createScenario({ + id: "single-chunk-slightly-out-of-view", + pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, + captureArea: { + left: 200 as Coord<"page", "device", "x">, + top: 800 as Coord<"page", "device", "y">, + width: 500 as Length<"device", "x">, height: 500 as Length<"device", "y">, - offsetTop: 500 as Coord<"page", "device", "y">, - }, - ], - }), - createScenario({ - id: "two-chunks-relax-lower-top", - pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, - captureArea: { - left: 200 as Coord<"page", "device", "x">, - top: 200 as Coord<"page", "device", "y">, - width: 500 as Length<"device", "x">, - height: 500 as Length<"device", "y">, - }, - ignoreAreas: [], - unsafeAreas: [ - { - top: 0, - height: 100, }, - ], - chunks: [ - { + ignoreAreas: [], + unsafeAreas: [], + chunks: [ + { + height: 1024 as Length<"device", "y">, + offsetTop: 0 as Coord<"page", "device", "y">, + }, + ], + }), + createScenario({ + id: "single-chunk-completely-out-of-view", + pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, + captureArea: { + left: 200 as Coord<"page", "device", "x">, + top: 1100 as Coord<"page", "device", "y">, + width: 500 as Length<"device", "x">, height: 500 as Length<"device", "y">, - offsetTop: 0 as Coord<"page", "device", "y">, }, - { + ignoreAreas: [], + unsafeAreas: [], + chunks: [ + { + height: 1024 as Length<"device", "y">, + offsetTop: 0 as Coord<"page", "device", "y">, + }, + ], + }), + createScenario({ + id: "single-chunk-safe-area-expansion-top", + pageSize: { width: 1024 as Length<"device", "x">, height: 1024 as Length<"device", "y"> }, + captureArea: { + left: 200 as Coord<"page", "device", "x">, + top: 200 as Coord<"page", "device", "y">, + width: 500 as Length<"device", "x">, height: 500 as Length<"device", "y">, - offsetTop: 500 as Coord<"page", "device", "y">, - }, - ], - }), - createScenario({ - id: "two-equal-chunks", - pageSize: { width: 1024 as Length<"device", "x">, height: 1024 as Length<"device", "y"> }, - captureArea: { - left: 200 as Coord<"page", "device", "x">, - top: 200 as Coord<"page", "device", "y">, - width: 500 as Length<"device", "x">, - height: 500 as Length<"device", "y">, - }, - ignoreAreas: [], - unsafeAreas: [], - chunks: [ - { - height: 1024 as Length<"device", "y">, - offsetTop: 0 as Coord<"page", "device", "y">, - }, - { - height: 1024 as Length<"device", "y">, - offsetTop: 0 as Coord<"page", "device", "y">, - }, - ], - }), - createScenario({ - id: "multiple-overlapping-chunks-with-safe-areas", - pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, - captureArea: { - left: 200 as Coord<"page", "device", "x">, - top: 200 as Coord<"page", "device", "y">, - width: 500 as Length<"device", "x">, - height: 500 as Length<"device", "y">, - }, - ignoreAreas: [], - unsafeAreas: [ - { - bottom: 0, - height: 100, - }, - ], - chunks: [ - { - height: 400 as Length<"device", "y">, - offsetTop: 0 as Coord<"page", "device", "y">, - }, - { - height: 400 as Length<"device", "y">, - offsetTop: 100 as Coord<"page", "device", "y">, - }, - { - height: 400 as Length<"device", "y">, - offsetTop: 200 as Coord<"page", "device", "y">, - }, - { - height: 400 as Length<"device", "y">, - offsetTop: 300 as Coord<"page", "device", "y">, - }, - { - height: 400 as Length<"device", "y">, - offsetTop: 400 as Coord<"page", "device", "y">, - }, - ], - }), - createScenario({ - id: "multiple-chunks-with-horizontal-shifts", - pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, - captureArea: { - left: 200 as Coord<"page", "device", "x">, - top: 200 as Coord<"page", "device", "y">, - width: 500 as Length<"device", "x">, - height: 500 as Length<"device", "y">, - }, - ignoreAreas: [], - unsafeAreas: [ - { - bottom: 0, - height: 100, - }, - ], - chunks: [ - { - height: 400 as Length<"device", "y">, - offsetTop: 0 as Coord<"page", "device", "y">, - width: 1000 as Length<"device", "x">, - }, - { - height: 400 as Length<"device", "y">, - offsetTop: 100 as Coord<"page", "device", "y">, - width: 800 as Length<"device", "x">, - offsetLeft: 100 as Coord<"page", "device", "x">, }, - { - height: 400 as Length<"device", "y">, - offsetTop: 200 as Coord<"page", "device", "y">, - width: 600 as Length<"device", "x">, - offsetLeft: 150 as Coord<"page", "device", "x">, - }, - { - height: 400 as Length<"device", "y">, - offsetTop: 300 as Coord<"page", "device", "y">, - offsetLeft: 10 as Coord<"page", "device", "x">, + ignoreAreas: [], + unsafeAreas: [ + { + top: 0, + height: 300, + }, + ], + chunks: [ + { + height: 1024 as Length<"device", "y">, + offsetTop: 0 as Coord<"page", "device", "y">, + }, + ], + }), + createScenario({ + id: "single-chunk-safe-area-expansion-bottom", + pageSize: { width: 1024 as Length<"device", "x">, height: 1024 as Length<"device", "y"> }, + captureArea: { + left: 200 as Coord<"page", "device", "x">, + top: 200 as Coord<"page", "device", "y">, + width: 500 as Length<"device", "x">, + height: 500 as Length<"device", "y">, }, - { - height: 400 as Length<"device", "y">, - offsetTop: 400 as Coord<"page", "device", "y">, + ignoreAreas: [], + unsafeAreas: [ + { + bottom: 0, + height: 600, + }, + ], + chunks: [ + { + height: 1024 as Length<"device", "y">, + offsetTop: 0 as Coord<"page", "device", "y">, + }, + ], + }), + createScenario({ + id: "single-chunk-safe-area-expansion-top-and-bottom", + pageSize: { width: 1024 as Length<"device", "x">, height: 1024 as Length<"device", "y"> }, + captureArea: { + left: 200 as Coord<"page", "device", "x">, + top: 200 as Coord<"page", "device", "y">, + width: 500 as Length<"device", "x">, + height: 500 as Length<"device", "y">, }, - ], - }), - createScenario({ - id: "multiple-chunks-with-safe-areas-and-ignore-areas", - pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, - captureArea: { - left: 200 as Coord<"page", "device", "x">, - top: 200 as Coord<"page", "device", "y">, - width: 500 as Length<"device", "x">, - height: 500 as Length<"device", "y">, - }, - ignoreAreas: [ - { - left: 300 as Coord<"page", "device", "x">, - top: 100 as Coord<"page", "device", "y">, - width: 100 as Length<"device", "x">, - height: 400 as Length<"device", "y">, + ignoreAreas: [], + unsafeAreas: [ + { + top: 0, + height: 300, + }, + { + bottom: 0, + height: 400, + }, + ], + chunks: [ + { + height: 1024 as Length<"device", "y">, + offsetTop: 0 as Coord<"page", "device", "y">, + }, + ], + }), + createScenario({ + id: "two-chunks-with-gap", + pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, + captureArea: { + left: 200 as Coord<"page", "device", "x">, + top: 200 as Coord<"page", "device", "y">, + width: 500 as Length<"device", "x">, + height: 500 as Length<"device", "y">, }, - ], - unsafeAreas: [ - { - top: 0, - height: 100, + ignoreAreas: [], + unsafeAreas: [], + chunks: [ + { + height: 400 as Length<"device", "y">, + offsetTop: 0 as Coord<"page", "device", "y">, + }, + { + height: 800 as Length<"device", "y">, + offsetTop: 500 as Coord<"page", "device", "y">, + }, + ], + }), + createScenario({ + id: "two-chunks-relax-upper-bottom", + pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, + captureArea: { + left: 200 as Coord<"page", "device", "x">, + top: 200 as Coord<"page", "device", "y">, + width: 500 as Length<"device", "x">, + height: 500 as Length<"device", "y">, }, - { - bottom: 0, - height: 700, + ignoreAreas: [], + unsafeAreas: [ + { + bottom: 0, + height: 100, + }, + ], + chunks: [ + { + height: 500 as Length<"device", "y">, + offsetTop: 0 as Coord<"page", "device", "y">, + }, + { + height: 500 as Length<"device", "y">, + offsetTop: 500 as Coord<"page", "device", "y">, + }, + ], + }), + createScenario({ + id: "two-chunks-relax-lower-top", + pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, + captureArea: { + left: 200 as Coord<"page", "device", "x">, + top: 200 as Coord<"page", "device", "y">, + width: 500 as Length<"device", "x">, + height: 500 as Length<"device", "y">, }, - ], - chunks: [ - { - height: 1024 as Length<"device", "y">, - offsetTop: 100 as Coord<"page", "device", "y">, + ignoreAreas: [], + unsafeAreas: [ + { + top: 0, + height: 100, + }, + ], + chunks: [ + { + height: 500 as Length<"device", "y">, + offsetTop: 0 as Coord<"page", "device", "y">, + }, + { + height: 500 as Length<"device", "y">, + offsetTop: 500 as Coord<"page", "device", "y">, + }, + ], + }), + createScenario({ + id: "two-equal-chunks", + pageSize: { width: 1024 as Length<"device", "x">, height: 1024 as Length<"device", "y"> }, + captureArea: { + left: 200 as Coord<"page", "device", "x">, + top: 200 as Coord<"page", "device", "y">, + width: 500 as Length<"device", "x">, + height: 500 as Length<"device", "y">, }, - { - height: 1024 as Length<"device", "y">, - offsetTop: 200 as Coord<"page", "device", "y">, + ignoreAreas: [], + unsafeAreas: [], + chunks: [ + { + height: 1024 as Length<"device", "y">, + offsetTop: 0 as Coord<"page", "device", "y">, + }, + { + height: 1024 as Length<"device", "y">, + offsetTop: 0 as Coord<"page", "device", "y">, + }, + ], + }), + createScenario({ + id: "multiple-overlapping-chunks-with-safe-areas", + pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, + captureArea: { + left: 200 as Coord<"page", "device", "x">, + top: 200 as Coord<"page", "device", "y">, + width: 500 as Length<"device", "x">, + height: 500 as Length<"device", "y">, }, - { - height: 1024 as Length<"device", "y">, - offsetTop: 300 as Coord<"page", "device", "y">, + ignoreAreas: [], + unsafeAreas: [ + { + bottom: 0, + height: 100, + }, + ], + chunks: [ + { + height: 400 as Length<"device", "y">, + offsetTop: 0 as Coord<"page", "device", "y">, + }, + { + height: 400 as Length<"device", "y">, + offsetTop: 100 as Coord<"page", "device", "y">, + }, + { + height: 400 as Length<"device", "y">, + offsetTop: 200 as Coord<"page", "device", "y">, + }, + { + height: 400 as Length<"device", "y">, + offsetTop: 300 as Coord<"page", "device", "y">, + }, + { + height: 400 as Length<"device", "y">, + offsetTop: 400 as Coord<"page", "device", "y">, + }, + ], + }), + createScenario({ + id: "multiple-chunks-with-horizontal-shifts", + pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, + captureArea: { + left: 200 as Coord<"page", "device", "x">, + top: 200 as Coord<"page", "device", "y">, + width: 500 as Length<"device", "x">, + height: 500 as Length<"device", "y">, }, - { - height: 1024 as Length<"device", "y">, - offsetTop: 400 as Coord<"page", "device", "y">, + ignoreAreas: [], + unsafeAreas: [ + { + bottom: 0, + height: 100, + }, + ], + chunks: [ + { + height: 400 as Length<"device", "y">, + offsetTop: 0 as Coord<"page", "device", "y">, + width: 1000 as Length<"device", "x">, + }, + { + height: 400 as Length<"device", "y">, + offsetTop: 100 as Coord<"page", "device", "y">, + width: 800 as Length<"device", "x">, + offsetLeft: 100 as Coord<"page", "device", "x">, + }, + { + height: 400 as Length<"device", "y">, + offsetTop: 200 as Coord<"page", "device", "y">, + width: 600 as Length<"device", "x">, + offsetLeft: 150 as Coord<"page", "device", "x">, + }, + { + height: 400 as Length<"device", "y">, + offsetTop: 300 as Coord<"page", "device", "y">, + offsetLeft: 10 as Coord<"page", "device", "x">, + }, + { + height: 400 as Length<"device", "y">, + offsetTop: 400 as Coord<"page", "device", "y">, + }, + ], + }), + createScenario({ + id: "multiple-chunks-with-safe-areas-and-ignore-areas", + pageSize: { width: 1024 as Length<"device", "x">, height: 1800 as Length<"device", "y"> }, + captureArea: { + left: 200 as Coord<"page", "device", "x">, + top: 200 as Coord<"page", "device", "y">, + width: 500 as Length<"device", "x">, + height: 500 as Length<"device", "y">, }, - ], - }), - (async (): Promise => { - const id = "duplicate-chunks-with-offscreen-zero-visible-spec"; - const scenarioDir = path.join(__dirname, id); - const chunksDir = path.join(scenarioDir, "chunks"); - await fs.promises.mkdir(chunksDir, { recursive: true }); - - const viewportWidth = 1024 as Length<"device", "x">; - const viewportHeight = 1024 as Length<"device", "y">; - const safeArea = toViewportYBand(0, viewportHeight as number); - const fixedRect = toViewportRect({ left: 200, top: 620, width: 500, height: 260 }); - const ignoreBoundingRects: Rect<"viewport", "device">[] = []; - const weirdChunkDefs = [120, -500, -800, -800]; - const chunkResults: ScenarioGenerationResult["chunks"] = []; - let firstChunkRgba: Buffer | null = null; - - const drawVisibleSpec = (rgba: Buffer, visibleRect: Rect<"viewport", "device">): void => { - if ((visibleRect.width as number) <= 0 || (visibleRect.height as number) <= 0) { - return; - } - - drawCaptureArea( - rgba, - { width: viewportWidth, height: viewportHeight }, + ignoreAreas: [ { - left: toPageX(visibleRect.left as number), - top: toPageY(visibleRect.top as number), - width: visibleRect.width as Length<"device", "x">, - height: visibleRect.height as Length<"device", "y">, + left: 300 as Coord<"page", "device", "x">, + top: 100 as Coord<"page", "device", "y">, + width: 100 as Length<"device", "x">, + height: 400 as Length<"device", "y">, }, - ); - }; - - for (let chunkIndex = 0; chunkIndex < weirdChunkDefs.length; chunkIndex++) { - const chunkRgba = Buffer.alloc((viewportWidth as number) * (viewportHeight as number) * 4); - fillRect( - chunkRgba, - viewportWidth, - viewportHeight, + ], + unsafeAreas: [ { - left: toPageX(0), - top: toPageY(0), - width: viewportWidth, - height: viewportHeight, + top: 0, + height: 100, }, - WHITE, - ); - - const movingTop = weirdChunkDefs[chunkIndex]; - const movingVisibleHeight = movingTop >= 0 ? 420 : 0; - const chunkPath = path.posix.join(chunksDir, `${chunkIndex}.png`); - const movingSpec = { - full: toViewportRect({ left: 200, top: movingTop, width: 500, height: 420 }), - visible: toViewportRect({ - left: 200, - top: movingTop, - width: 500, - height: movingVisibleHeight, - }), + { + bottom: 0, + height: 700, + }, + ], + chunks: [ + { + height: 1024 as Length<"device", "y">, + offsetTop: 100 as Coord<"page", "device", "y">, + }, + { + height: 1024 as Length<"device", "y">, + offsetTop: 200 as Coord<"page", "device", "y">, + }, + { + height: 1024 as Length<"device", "y">, + offsetTop: 300 as Coord<"page", "device", "y">, + }, + { + height: 1024 as Length<"device", "y">, + offsetTop: 400 as Coord<"page", "device", "y">, + }, + ], + }), + (async (): Promise => { + const id = "duplicate-chunks-with-offscreen-zero-visible-spec"; + const scenarioDir = path.join(__dirname, id); + const chunksDir = path.join(scenarioDir, "chunks"); + await fs.promises.mkdir(chunksDir, { recursive: true }); + + const viewportWidth = 1024 as Length<"device", "x">; + const viewportHeight = 1024 as Length<"device", "y">; + const safeArea = toViewportYBand(0, viewportHeight as number); + const fixedRect = toViewportRect({ left: 200, top: 620, width: 500, height: 260 }); + const ignoreBoundingRects: Rect<"viewport", "device">[] = []; + const weirdChunkDefs = [120, -500, -800, -800]; + const chunkResults: ScenarioGenerationResult["chunks"] = []; + let firstChunkRgba: Buffer | null = null; + + const drawVisibleSpec = (rgba: Buffer, visibleRect: Rect<"viewport", "device">): void => { + if ((visibleRect.width as number) <= 0 || (visibleRect.height as number) <= 0) { + return; + } + + drawCaptureArea( + rgba, + { width: viewportWidth, height: viewportHeight }, + { + left: toPageX(visibleRect.left as number), + top: toPageY(visibleRect.top as number), + width: visibleRect.width as Length<"device", "x">, + height: visibleRect.height as Length<"device", "y">, + }, + ); }; - const captureSpecs = [movingSpec, { full: fixedRect, visible: fixedRect }]; - for (const spec of captureSpecs) { - drawVisibleSpec(chunkRgba, spec.visible); + for (let chunkIndex = 0; chunkIndex < weirdChunkDefs.length; chunkIndex++) { + const chunkRgba = Buffer.alloc((viewportWidth as number) * (viewportHeight as number) * 4); + fillRect( + chunkRgba, + viewportWidth, + viewportHeight, + { + left: toPageX(0), + top: toPageY(0), + width: viewportWidth, + height: viewportHeight, + }, + WHITE, + ); + + const movingTop = weirdChunkDefs[chunkIndex]; + const movingVisibleHeight = movingTop >= 0 ? 420 : 0; + const chunkPath = path.posix.join(chunksDir, `${chunkIndex}.png`); + const movingSpec = { + full: toViewportRect({ left: 200, top: movingTop, width: 500, height: 420 }), + visible: toViewportRect({ + left: 200, + top: movingTop, + width: 500, + height: movingVisibleHeight, + }), + }; + const captureSpecs = [movingSpec, { full: fixedRect, visible: fixedRect }]; + + for (const spec of captureSpecs) { + drawVisibleSpec(chunkRgba, spec.visible); + } + + await saveRgbaAsPng(chunkPath, chunkRgba, viewportWidth, viewportHeight); + + if (chunkIndex === 0) { + firstChunkRgba = chunkRgba; + } + + chunkResults.push({ + file: path.relative(__dirname, chunkPath), + safeArea, + captureSpecs, + ignoreBoundingRects, + }); } - await saveRgbaAsPng(chunkPath, chunkRgba, viewportWidth, viewportHeight); - - if (chunkIndex === 0) { - firstChunkRgba = chunkRgba; + if (!firstChunkRgba) { + throw new Error( + "Failed to build first chunk for duplicate-chunks-with-offscreen-zero-visible-spec scenario", + ); } - chunkResults.push({ - file: path.relative(__dirname, chunkPath), - safeArea, - captureSpecs, - ignoreBoundingRects, - }); - } - - if (!firstChunkRgba) { - throw new Error( - "Failed to build first chunk for duplicate-chunks-with-offscreen-zero-visible-spec scenario", + const expectedRgba = crop( + firstChunkRgba, + viewportWidth, + viewportHeight, + toPageX(200), + toPageY(120), + 500 as Length<"device", "x">, + 760 as Length<"device", "y">, ); - } + const expectedPath = path.posix.join(scenarioDir, "expected.png"); + await saveRgbaAsPng(expectedPath, expectedRgba, 500 as Length<"device", "x">, 760 as Length<"device", "y">); - const expectedRgba = crop( - firstChunkRgba, - viewportWidth, - viewportHeight, - toPageX(200), - toPageY(120), - 500 as Length<"device", "x">, - 760 as Length<"device", "y">, - ); - const expectedPath = path.posix.join(scenarioDir, "expected.png"); - await saveRgbaAsPng(expectedPath, expectedRgba, 500 as Length<"device", "x">, 760 as Length<"device", "y">); - - const fullPagePath = path.posix.join(scenarioDir, "full-page.png"); - await saveRgbaAsPng(fullPagePath, expectedRgba, 500 as Length<"device", "x">, 760 as Length<"device", "y">); - - return { - id, - fullPage: path.relative(__dirname, fullPagePath), - expected: path.relative(__dirname, expectedPath), - chunks: chunkResults, - }; - })(), -] satisfies Promise[]; - -Promise.all(scenarios).then(async results => { - await fs.promises.writeFile(path.join(__dirname, "data.json"), JSON.stringify(results, null, 4) + "\n"); - console.log("Generation completed."); -}); + const fullPagePath = path.posix.join(scenarioDir, "full-page.png"); + await saveRgbaAsPng(fullPagePath, expectedRgba, 500 as Length<"device", "x">, 760 as Length<"device", "y">); + + return { + id, + fullPage: path.relative(__dirname, fullPagePath), + expected: path.relative(__dirname, expectedPath), + chunks: chunkResults, + }; + })(), + ] satisfies Promise[]; + + Promise.all(scenarios).then(async results => { + await fs.promises.writeFile(path.join(__dirname, "data.json"), JSON.stringify(results, null, 4) + "\n"); + console.log("Generation of composite image fixtures completed."); + }); +} diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/0.png new file mode 100644 index 000000000..8ac68254e Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/0.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/1.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/1.png new file mode 100644 index 000000000..2e39a934c Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/1.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/2.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/2.png new file mode 100644 index 000000000..6a0aaab55 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/2.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/3.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/3.png new file mode 100644 index 000000000..4fd453a9b Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/3.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/4.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/4.png new file mode 100644 index 000000000..8a030c988 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/chunks/4.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/expected.png new file mode 100644 index 000000000..a66930045 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/expected.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/full-page.png new file mode 100644 index 000000000..c4346ecbc Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-horizontal-shifts/full-page.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/chunks/0.png new file mode 100644 index 000000000..e125f26a5 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/chunks/0.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/chunks/1.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/chunks/1.png new file mode 100644 index 000000000..af5aa79ea Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/chunks/1.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/chunks/2.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/chunks/2.png new file mode 100644 index 000000000..9e0de5d34 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/chunks/2.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/chunks/3.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/chunks/3.png new file mode 100644 index 000000000..4f2d273b5 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/chunks/3.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/expected.png new file mode 100644 index 000000000..ab8de02da Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/expected.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/full-page.png new file mode 100644 index 000000000..8c990fc4c Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-chunks-with-safe-areas-and-ignore-areas/full-page.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/0.png new file mode 100644 index 000000000..7012fc600 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/0.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/1.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/1.png new file mode 100644 index 000000000..28767a16a Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/1.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/2.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/2.png new file mode 100644 index 000000000..62d1c4dc0 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/2.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/3.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/3.png new file mode 100644 index 000000000..17038de7a Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/3.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/4.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/4.png new file mode 100644 index 000000000..8a030c988 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/chunks/4.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/expected.png new file mode 100644 index 000000000..a66930045 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/expected.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/full-page.png new file mode 100644 index 000000000..c4346ecbc Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/multiple-overlapping-chunks-with-safe-areas/full-page.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-completely-out-of-view/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-completely-out-of-view/chunks/0.png new file mode 100644 index 000000000..1a91781d4 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-completely-out-of-view/chunks/0.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-completely-out-of-view/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-completely-out-of-view/expected.png new file mode 100644 index 000000000..fdfa109d6 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-completely-out-of-view/expected.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-completely-out-of-view/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-completely-out-of-view/full-page.png new file mode 100644 index 000000000..d59a00747 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-completely-out-of-view/full-page.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-in-view/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-in-view/chunks/0.png new file mode 100644 index 000000000..def4242c9 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-in-view/chunks/0.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-in-view/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-in-view/expected.png new file mode 100644 index 000000000..a66930045 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-in-view/expected.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-in-view/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-in-view/full-page.png new file mode 100644 index 000000000..def4242c9 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-in-view/full-page.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-bottom/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-bottom/chunks/0.png new file mode 100644 index 000000000..0a97286e4 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-bottom/chunks/0.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-bottom/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-bottom/expected.png new file mode 100644 index 000000000..48e8ea09f Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-bottom/expected.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-bottom/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-bottom/full-page.png new file mode 100644 index 000000000..def4242c9 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-bottom/full-page.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top-and-bottom/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top-and-bottom/chunks/0.png new file mode 100644 index 000000000..aa12c4274 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top-and-bottom/chunks/0.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top-and-bottom/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top-and-bottom/expected.png new file mode 100644 index 000000000..72b4d7afb Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top-and-bottom/expected.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top-and-bottom/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top-and-bottom/full-page.png new file mode 100644 index 000000000..def4242c9 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top-and-bottom/full-page.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top/chunks/0.png new file mode 100644 index 000000000..f735df67f Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top/chunks/0.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top/expected.png new file mode 100644 index 000000000..150135cc0 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top/expected.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top/full-page.png new file mode 100644 index 000000000..def4242c9 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-safe-area-expansion-top/full-page.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-slightly-out-of-view/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-slightly-out-of-view/chunks/0.png new file mode 100644 index 000000000..47072e741 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-slightly-out-of-view/chunks/0.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-slightly-out-of-view/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-slightly-out-of-view/expected.png new file mode 100644 index 000000000..835d31b86 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-slightly-out-of-view/expected.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-slightly-out-of-view/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-slightly-out-of-view/full-page.png new file mode 100644 index 000000000..6f3be3c1a Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/single-chunk-slightly-out-of-view/full-page.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-lower-top/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-lower-top/chunks/0.png new file mode 100644 index 000000000..51ee672b1 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-lower-top/chunks/0.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-lower-top/chunks/1.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-lower-top/chunks/1.png new file mode 100644 index 000000000..afd2c2071 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-lower-top/chunks/1.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-lower-top/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-lower-top/expected.png new file mode 100644 index 000000000..ce1c9cd94 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-lower-top/expected.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-lower-top/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-lower-top/full-page.png new file mode 100644 index 000000000..c4346ecbc Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-lower-top/full-page.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-upper-bottom/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-upper-bottom/chunks/0.png new file mode 100644 index 000000000..6499a5420 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-upper-bottom/chunks/0.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-upper-bottom/chunks/1.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-upper-bottom/chunks/1.png new file mode 100644 index 000000000..49b20e6fb Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-upper-bottom/chunks/1.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-upper-bottom/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-upper-bottom/expected.png new file mode 100644 index 000000000..6175be932 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-upper-bottom/expected.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-upper-bottom/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-upper-bottom/full-page.png new file mode 100644 index 000000000..c4346ecbc Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-relax-upper-bottom/full-page.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-with-gap/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-with-gap/chunks/0.png new file mode 100644 index 000000000..de64376a8 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-with-gap/chunks/0.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-with-gap/chunks/1.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-with-gap/chunks/1.png new file mode 100644 index 000000000..7904ea06e Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-with-gap/chunks/1.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-with-gap/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-with-gap/expected.png new file mode 100644 index 000000000..da135ea0a Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-with-gap/expected.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-with-gap/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-with-gap/full-page.png new file mode 100644 index 000000000..c4346ecbc Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-chunks-with-gap/full-page.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-equal-chunks/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-equal-chunks/chunks/0.png new file mode 100644 index 000000000..def4242c9 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-equal-chunks/chunks/0.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-equal-chunks/chunks/1.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-equal-chunks/chunks/1.png new file mode 100644 index 000000000..def4242c9 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-equal-chunks/chunks/1.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-equal-chunks/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-equal-chunks/expected.png new file mode 100644 index 000000000..a66930045 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-equal-chunks/expected.png differ diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/two-equal-chunks/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/two-equal-chunks/full-page.png new file mode 100644 index 000000000..def4242c9 Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/two-equal-chunks/full-page.png differ diff --git a/test/src/browser/screen-shooter/index.js b/test/src/browser/screen-shooter/index.js index dab6d6b2a..687eb5f05 100644 --- a/test/src/browser/screen-shooter/index.js +++ b/test/src/browser/screen-shooter/index.js @@ -1,7 +1,6 @@ "use strict"; const proxyquire = require("proxyquire").noCallThru(); -const { CaptureAreaMovedError } = require("src/browser/screen-shooter/errors/capture-area-moved-error"); const validationStubs = { assertCorrectCaptureAreaBounds: sinon.stub(), @@ -81,6 +80,7 @@ describe("ElementsScreenShooter", () => { captureSpecs: [captureSpec(rect(0, 0, 100, 80))], ignoreAreas: [], safeArea: band(0, 100), + anchorShift: null, debugLog: "state debug", }, overrides, @@ -283,46 +283,44 @@ describe("ElementsScreenShooter", () => { assert.calledOnceWithExactly(operationsStubs.disableIframeAnimations, browser, browserSideScreenshooter); }); - it("should retry retriable errors and validate capture area stability on retry", async () => { - const page = createMockPage(); - const changedState = createCaptureState({ - captureSpecs: [captureSpec(rect(0, 0, 100, 120))], - safeArea: band(0, 100), - }); - const stableState = createCaptureState({ - captureSpecs: page.captureSpecs, - ignoreAreas: page.ignoreAreas, - safeArea: page.safeArea, - }); + it("should preload and do best-effort capture when capture area size changes mid-capture", async () => { + const page = createMockPage({ captureSpecs: [captureSpec(rect(0, 0, 100, 80))] }); + const changedState = createCaptureState({ captureSpecs: [captureSpec(rect(0, 0, 100, 120))] }); + const preloadState = createCaptureState({ captureSpecs: [captureSpec(rect(0, 0, 100, 120))] }); + const settledState = createCaptureState({ captureSpecs: page.captureSpecs, safeArea: page.safeArea }); browserSideScreenshooter.call .onCall(0) - .resolves(page) + .resolves(page) // prepareElementsScreenshot .onCall(1) - .resolves(changedState) + .resolves(changedState) // getCaptureState phase 1 → size change .onCall(2) - .resolves(page) + .resolves(preloadState) // getCaptureState in preload .onCall(3) - .resolves(stableState) + .resolves({}) // scrollTo restore after preload .onCall(4) - .resolves(stableState) + .resolves(undefined) // captureAnchorBaseline .onCall(5) - .resolves(stableState); + .resolves(settledState); // getCaptureState phase 2 - const result = await screenShooter.capture(".element", {}, 2); + const result = await screenShooter.capture(".element", { compositeImage: false }); assert.deepEqual( browserSideScreenshooter.call .getCalls() .map(call => call.args[0]) - .filter(method => method === "prepareElementsScreenshot"), - ["prepareElementsScreenshot", "prepareElementsScreenshot"], + .filter(m => m === "prepareElementsScreenshot"), + ["prepareElementsScreenshot"], + ); + assert.deepEqual( + browserSideScreenshooter.call + .getCalls() + .map(call => call.args[0]) + .filter(m => m === "captureAnchorBaseline"), + ["captureAnchorBaseline"], ); assert.calledOnce(camera.captureViewportImage); - assert.deepEqual(result, { - image: renderedImage, - meta: page, - }); + assert.deepEqual(result, { image: renderedImage, meta: page }); }); it("should return rendered image and page meta", async () => { @@ -372,6 +370,7 @@ describe("ElementsScreenShooter", () => { const page = createMockPage(); const state = createCaptureState({ captureSpecs: page.captureSpecs, + viewportOffset: page.viewportOffset, ignoreAreas: page.ignoreAreas, safeArea: page.safeArea, }); @@ -394,6 +393,7 @@ describe("ElementsScreenShooter", () => { state.safeArea, state.captureSpecs, state.ignoreAreas, + 0, ); assert.deepEqual(result, { image: renderedImage, @@ -467,19 +467,80 @@ describe("ElementsScreenShooter", () => { ]); }); - it("should reject with CaptureAreaMovedError when capture area size changes and retries are disabled", async () => { - const page = createMockPage({ - captureSpecs: [captureSpec(rect(0, 0, 100, 80))], + it("should pass correction delta to registerViewportImageAtOffset during best-effort pass", async () => { + const page = createMockPage({ captureSpecs: [captureSpec(rect(0, 34, 100, 80))], scrollOffset: 0 }); + const changedState = createCaptureState({ captureSpecs: [captureSpec(rect(0, 34, 100, 120))] }); + const preloadState = createCaptureState({ captureSpecs: [captureSpec(rect(0, 34, 100, 120))] }); + const settledState = createCaptureState({ + captureSpecs: [captureSpec(rect(0, -246, 100, 80))], + scrollOffset: 280, + anchorShift: -287, }); - const changedState = createCaptureState({ - captureSpecs: [captureSpec(rect(0, 0, 100, 120))], + + browserSideScreenshooter.call + .onCall(0) + .resolves(page) + .onCall(1) + .resolves(changedState) + .onCall(2) + .resolves(preloadState) + .onCall(3) + .resolves({}) + .onCall(4) + .resolves(undefined) + .onCall(5) + .resolves(settledState) + .onCall(6) + .resolves({ debugLog: "restore debug" }); + + await screenShooter.capture(".element", { compositeImage: false }); + + assert.calledOnceWithExactly( + compositeImage.registerViewportImageAtOffset, + viewportImage, + settledState.safeArea, + settledState.captureSpecs, + settledState.ignoreAreas, + -7, + ); + }); + + it("should pass zero correction when observed shift is unavailable", async () => { + const page = createMockPage({ captureSpecs: [captureSpec(rect(0, 34, 100, 80))], scrollOffset: 0 }); + const changedState = createCaptureState({ captureSpecs: [captureSpec(rect(0, 34, 100, 120))] }); + const preloadState = createCaptureState({ captureSpecs: [captureSpec(rect(0, 34, 100, 120))] }); + const settledState = createCaptureState({ + captureSpecs: [captureSpec(rect(0, -246, 100, 80))], + scrollOffset: 280, + anchorShift: null, }); - browserSideScreenshooter.call.onCall(0).resolves(page).onCall(1).resolves(changedState); + browserSideScreenshooter.call + .onCall(0) + .resolves(page) + .onCall(1) + .resolves(changedState) + .onCall(2) + .resolves(preloadState) + .onCall(3) + .resolves({}) + .onCall(4) + .resolves(undefined) + .onCall(5) + .resolves(settledState) + .onCall(6) + .resolves({ debugLog: "restore debug" }); - const error = await screenShooter.capture(".element", { compositeImage: true }, 1).catch(e => e); + await screenShooter.capture(".element", { compositeImage: false }); - assert.instanceOf(error, CaptureAreaMovedError); + assert.calledOnceWithExactly( + compositeImage.registerViewportImageAtOffset, + viewportImage, + settledState.safeArea, + settledState.captureSpecs, + settledState.ignoreAreas, + 0, + ); }); it("should warn when the captured area still overflows the viewport and allowViewportOverflow is false", async () => { diff --git a/test/src/browser/screen-shooter/validation.js b/test/src/browser/screen-shooter/validation.js index 9a5d452c8..1ea22fa82 100644 --- a/test/src/browser/screen-shooter/validation.js +++ b/test/src/browser/screen-shooter/validation.js @@ -1,12 +1,23 @@ "use strict"; const _ = require("lodash"); - -const { assertCorrectCaptureAreaBounds } = require("src/browser/screen-shooter/validation"); -const { VerticalOverflowError } = require("src/browser/screen-shooter/errors/vertical-overflow-error"); -const { HorizontalOverflowError } = require("src/browser/screen-shooter/errors/horizontal-overflow-error"); +const proxyquire = require("proxyquire"); describe("assertCorrectCaptureAreaBounds", () => { + const loggerWarnStub = sinon.stub(); + let assertCorrectCaptureAreaBounds; + + beforeEach(() => { + loggerWarnStub.resetHistory(); + loggerWarnStub.resetBehavior(); + + ({ assertCorrectCaptureAreaBounds } = proxyquire("src/browser/screen-shooter/validation", { + "../../../utils/logger": { + warn: loggerWarnStub, + }, + })); + }); + function validate_(areaModification, opts = {}) { const viewport = { left: 0, @@ -31,84 +42,97 @@ describe("assertCorrectCaptureAreaBounds", () => { const viewportOffset = { top: 0, left: 0 }; - return assertCorrectCaptureAreaBounds("test browser", viewport, viewportOffset, captureArea, opts); + return assertCorrectCaptureAreaBounds("test browser", viewport, viewportOffset, [captureArea], opts); } - describe("validation failed", () => { - it("if crop area left boundary is outside of viewport", () => { - assert.throws(() => validate_({ left: -1 }), HorizontalOverflowError); + describe("validation warnings", () => { + it("should warn if crop area left boundary is outside of viewport", () => { + validate_({ left: -1 }); + + assert.calledOnceWithMatch(loggerWarnStub, sinon.match("outside of horizontal viewport bounds")); }); - it("if crop area top boundary is outside of viewport", () => { - // Note: The current implementation checks top < 0 in the horizontal overflow check - assert.throws(() => validate_({ top: -1 }), HorizontalOverflowError); + it("should not warn if crop area top boundary is outside of viewport", () => { + validate_({ top: -1 }); + + assert.notCalled(loggerWarnStub); }); - it("if crop area right boundary is outside of viewport", () => { - assert.throws(() => validate_({ width: +1 }), HorizontalOverflowError); + it("should warn if crop area right boundary is outside of viewport", () => { + validate_({ width: +1 }); + + assert.calledOnceWithMatch(loggerWarnStub, sinon.match("outside of horizontal viewport bounds")); }); - it("if crop area height bigger than viewport height", () => { - assert.throws(() => validate_({ height: +1 }), VerticalOverflowError); + it("should warn if crop area height bigger than viewport height", () => { + validate_({ height: +1 }); + + assert.calledOnceWithMatch(loggerWarnStub, sinon.match("larger than viewport height")); }); }); it('should not throw any errors if option "allowViewportOverflow" is set and "compositeImage" is not', () => { const opts = { allowViewportOverflow: true, compositeImage: false }; - assert.doesNotThrow(() => validate_({ left: -1, height: +1 }, opts)); + validate_({ left: -1, height: +1 }, opts); + + assert.notCalled(loggerWarnStub); }); it("should not throw OffsetViewportError if option allowViewportOverflow is set", () => { const opts = { allowViewportOverflow: true }; - assert.doesNotThrow(() => validate_({ left: -1 }, opts)); + validate_({ left: -1 }, opts); + + assert.notCalled(loggerWarnStub); }); it('should not throw if crop area height bigger than viewport height and "compositeImage" is set', () => { const opts = { compositeImage: true }; - assert.doesNotThrow(() => validate_({ height: +1 }, opts)); + validate_({ height: +1 }, opts); + + assert.notCalled(loggerWarnStub); }); it("should not throw on passed validation", () => { - const fn = () => validate_({ left: 0 }); + validate_({ left: 0 }); - return assert.doesNotThrow(fn); + assert.notCalled(loggerWarnStub); }); describe("comprehensive validation tests", () => { - it("should not throw for valid bounds", () => { + it("should not warn for valid bounds", () => { const viewportSize = { width: 100, height: 100 }; const viewportOffset = { left: 0, top: 0 }; const captureArea = { left: 10, top: 10, width: 50, height: 50 }; const opts = {}; - assert.doesNotThrow(() => { - assertCorrectCaptureAreaBounds("test capture area", viewportSize, viewportOffset, captureArea, opts); - }); + assertCorrectCaptureAreaBounds("test capture area", viewportSize, viewportOffset, [captureArea], opts); + + assert.notCalled(loggerWarnStub); }); - it("should throw HorizontalOverflowError when capture area overflows horizontally", () => { + it("should warn when capture area overflows horizontally", () => { const viewportSize = { width: 100, height: 100 }; const viewportOffset = { left: 0, top: 0 }; const captureArea = { left: 90, top: 10, width: 50, height: 50 }; // overflows right const opts = {}; - assert.throws(() => { - assertCorrectCaptureAreaBounds("test capture area", viewportSize, viewportOffset, captureArea, opts); - }, HorizontalOverflowError); + assertCorrectCaptureAreaBounds("test capture area", viewportSize, viewportOffset, [captureArea], opts); + + assert.calledOnceWithMatch(loggerWarnStub, sinon.match("outside of horizontal viewport bounds")); }); - it("should throw VerticalOverflowError when capture area overflows vertically", () => { + it("should warn when capture area overflows vertically", () => { const viewportSize = { width: 100, height: 100 }; const viewportOffset = { left: 0, top: 0 }; const captureArea = { left: 10, top: 90, width: 50, height: 50 }; // overflows bottom const opts = {}; - assert.throws(() => { - assertCorrectCaptureAreaBounds("test capture area", viewportSize, viewportOffset, captureArea, opts); - }, VerticalOverflowError); + assertCorrectCaptureAreaBounds("test capture area", viewportSize, viewportOffset, [captureArea], opts); + + assert.calledOnceWithMatch(loggerWarnStub, sinon.match("larger than viewport height")); }); it("should not throw when allowViewportOverflow is set and compositeImage is false", () => { @@ -117,9 +141,9 @@ describe("assertCorrectCaptureAreaBounds", () => { const captureArea = { left: 90, top: 10, width: 50, height: 50 }; // would overflow const opts = { allowViewportOverflow: true, compositeImage: false }; - assert.doesNotThrow(() => { - assertCorrectCaptureAreaBounds("test capture area", viewportSize, viewportOffset, captureArea, opts); - }); + assertCorrectCaptureAreaBounds("test capture area", viewportSize, viewportOffset, [captureArea], opts); + + assert.notCalled(loggerWarnStub); }); it("should not throw when compositeImage is true", () => { @@ -128,9 +152,9 @@ describe("assertCorrectCaptureAreaBounds", () => { const captureArea = { left: 10, top: 90, width: 50, height: 50 }; // would overflow vertically const opts = { compositeImage: true }; - assert.doesNotThrow(() => { - assertCorrectCaptureAreaBounds("test capture area", viewportSize, viewportOffset, captureArea, opts); - }); + assertCorrectCaptureAreaBounds("test capture area", viewportSize, viewportOffset, [captureArea], opts); + + assert.notCalled(loggerWarnStub); }); }); }); diff --git a/test/src/cli/commands/config/index.ts b/test/src/cli/commands/config/index.ts index efa5cf0bd..69bec5700 100644 --- a/test/src/cli/commands/config/index.ts +++ b/test/src/cli/commands/config/index.ts @@ -1,5 +1,5 @@ import { Command } from "@gemini-testing/commander"; -import sinon, { SinonStub, SinonSpy } from "sinon"; +import sinon, { type SinonStub, type SinonSpy } from "sinon"; import { Testplane } from "../../../../../src/testplane"; import * as testplaneCli from "../../../../../src/cli"; diff --git a/test/src/cli/commands/install-deps/index.ts b/test/src/cli/commands/install-deps/index.ts index c5f1425de..62a2ab08e 100644 --- a/test/src/cli/commands/install-deps/index.ts +++ b/test/src/cli/commands/install-deps/index.ts @@ -18,7 +18,7 @@ describe("cli/commands/install-deps", () => { process.argv = ["foo/bar/node", "foo/bar/script", "install-deps", ...argv.split(" ")].filter(Boolean); cli.run(); - await (Command.prototype.action as SinonStub).lastCall.returnValue; + await new Promise(resolve => setImmediate(resolve)); }; const mkBrowser_ = (browserName: string, browserVersion: string): Config["browsers"][string] => diff --git a/test/src/cli/commands/list-browsers/index.ts b/test/src/cli/commands/list-browsers/index.ts index 833eca3ac..65848a396 100644 --- a/test/src/cli/commands/list-browsers/index.ts +++ b/test/src/cli/commands/list-browsers/index.ts @@ -1,5 +1,5 @@ import { Command } from "@gemini-testing/commander"; -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import { Testplane } from "../../../../../src/testplane"; import * as testplaneCliOriginal from "../../../../../src/cli"; diff --git a/test/src/cli/commands/list-tests/index.ts b/test/src/cli/commands/list-tests/index.ts index 80171e961..7600f7eb4 100644 --- a/test/src/cli/commands/list-tests/index.ts +++ b/test/src/cli/commands/list-tests/index.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { Command } from "@gemini-testing/commander"; import fs from "fs-extra"; -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import proxyquire from "proxyquire"; import { Formatters } from "../../../../../src/test-collection"; diff --git a/test/src/dev-server/index.ts b/test/src/dev-server/index.ts index f3c9d794f..e4cf649b7 100644 --- a/test/src/dev-server/index.ts +++ b/test/src/dev-server/index.ts @@ -197,7 +197,7 @@ describe("dev-server", () => { } catch (error) { assert.match( (error as Error).message, - /When 'reuseExisting' is set to 'true' in 'devServer' config, it is required to set 'devServer.readinessProbe.url'/, + /When 'reuseExisting' option is set in 'devServer' config, it is required to set 'devServer\.readinessProbe\.url' option/, ); } @@ -219,7 +219,7 @@ describe("dev-server", () => { } catch (error) { assert.match( (error as Error).message, - /When 'reuseExisting' is set to 'true' in 'devServer' config, it is required to set 'devServer.readinessProbe.url'/, + /When 'reuseExisting' option is set in 'devServer' config, it is required to set 'devServer\.readinessProbe\.url' option/, ); } diff --git a/test/src/runner/browser-env/vite/manual-mock.ts b/test/src/runner/browser-env/vite/manual-mock.ts index 8f1b98559..b07af1b51 100644 --- a/test/src/runner/browser-env/vite/manual-mock.ts +++ b/test/src/runner/browser-env/vite/manual-mock.ts @@ -1,6 +1,6 @@ import path from "node:path"; import fs from "node:fs/promises"; -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import type { Stats } from "node:fs"; import { ManualMock } from "../../../../../src/runner/browser-env/vite/manual-mock"; diff --git a/test/src/runner/browser-env/vite/server.ts b/test/src/runner/browser-env/vite/server.ts index cc93d929a..0962ffc04 100644 --- a/test/src/runner/browser-env/vite/server.ts +++ b/test/src/runner/browser-env/vite/server.ts @@ -1,5 +1,5 @@ import proxyquire from "proxyquire"; -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import Vite from "vite"; import chalk from "chalk"; diff --git a/test/src/test-collection/index.ts b/test/src/test-collection/index.ts index 689fcfa5e..ec1e7d4a5 100644 --- a/test/src/test-collection/index.ts +++ b/test/src/test-collection/index.ts @@ -1,7 +1,7 @@ import path from "node:path"; import _ from "lodash"; import proxyquire from "proxyquire"; -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import { TestCollection, Formatters, AVAILABLE_FORMATTERS } from "../../../src/test-collection"; import { Test } from "../../../src/test-reader/test-object"; diff --git a/test/src/test-reader/controllers/also-controller.ts b/test/src/test-reader/controllers/also-controller.ts index 3e14ec8c0..8c0e97eda 100644 --- a/test/src/test-reader/controllers/also-controller.ts +++ b/test/src/test-reader/controllers/also-controller.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "node:events"; -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import { AlsoController } from "../../../../src/test-reader/controllers/also-controller"; import { TreeBuilder } from "../../../../src/test-reader/tree-builder"; import { ConfigurableTestObject } from "../../../../src/test-reader/test-object/configurable-test-object"; diff --git a/test/src/utils/fs.ts b/test/src/utils/fs.ts index 2bab7f4a4..545c8906e 100644 --- a/test/src/utils/fs.ts +++ b/test/src/utils/fs.ts @@ -1,5 +1,5 @@ import fs from "fs"; -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import { exists } from "../../../src/utils/fs"; describe("utils/fs", () => { diff --git a/test/src/utils/logger.js b/test/src/utils/logger.js index fa338c021..e77f6b474 100644 --- a/test/src/utils/logger.js +++ b/test/src/utils/logger.js @@ -5,28 +5,22 @@ const logger = require("src/utils/logger"); describe("utils/logger", () => { ["log", "warn", "error"].forEach(logFnName => { describe(logFnName, () => { - let clock; const sandbox = sinon.createSandbox(); - const originalTZ = process.env.TZ; beforeEach(() => { sandbox.spy(console, logFnName); - clock = sinon.useFakeTimers({ - now: new Date("2023-03-02T14:21:04.000+03:00"), - }); - process.env.TZ = "Europe/Moscow"; }); afterEach(() => { - clock.restore(); sandbox.restore(); - process.env.TZ = originalTZ; }); it("should start with timestamp message", () => { logger[logFnName]("message", "another message"); - assert.calledOnceWith(console[logFnName], "[14:21:04 +0300]", "message", "another message"); + assert.calledOnce(console[logFnName]); + assert.match(console[logFnName].firstCall.args[0], /^\[\d{2}:\d{2}:\d{2} [+-]\d{4}\]$/); + assert.deepEqual(console[logFnName].firstCall.args.slice(1), ["message", "another message"]); }); }); }); diff --git a/test/src/utils/module.ts b/test/src/utils/module.ts index 5c2a001d4..59f6890ba 100644 --- a/test/src/utils/module.ts +++ b/test/src/utils/module.ts @@ -1,5 +1,5 @@ import path from "path"; -import sinon, { SinonStub } from "sinon"; +import sinon, { type SinonStub } from "sinon"; import proxyquire from "proxyquire"; import { requireModule as realRequireModule } from "../../../src/utils/module"; diff --git a/test/src/utils/page-loader.ts b/test/src/utils/page-loader.ts index 351e2b30b..00f3b06bd 100644 --- a/test/src/utils/page-loader.ts +++ b/test/src/utils/page-loader.ts @@ -1,5 +1,5 @@ import proxyquire from "proxyquire"; -import sinon, { SinonStub, SinonSpy } from "sinon"; +import sinon, { type SinonStub, type SinonSpy } from "sinon"; import FakeTimers from "@sinonjs/fake-timers"; import { mkSessionStub_, mkMockStub_ } from "../browser/utils"; import type PageLoaderType from "../../../src/utils/page-loader"; diff --git a/test/src/utils/promise.ts b/test/src/utils/promise.ts index e97a57d0b..aebb9da16 100644 --- a/test/src/utils/promise.ts +++ b/test/src/utils/promise.ts @@ -10,8 +10,8 @@ describe("utils/promise", () => { }); afterEach(() => { - clock.restore(); sandbox.restore(); + clock.restore(); }); describe("promiseMethod", () => { @@ -118,8 +118,8 @@ describe("utils/promise", () => { }); it("should work with await syntax", async () => { - const beforeTime = Date.now(); clock.tick(1000); + const beforeTime = Date.now(); const delayPromise = promiseDelay(500); clock.tick(500); diff --git a/test/src/utils/typescript.ts b/test/src/utils/typescript.ts index 1355b3002..7b02ed30b 100644 --- a/test/src/utils/typescript.ts +++ b/test/src/utils/typescript.ts @@ -11,6 +11,7 @@ describe("utils/typescript", () => { const TESTPLANE_TRANSFORM_HOOK = Symbol.for("testplane.transform.hook"); beforeEach(() => { + delete (process as any)[TESTPLANE_TRANSFORM_HOOK]; revertHookStub = sinon.stub(); addHookStub = sinon.stub().returns(revertHookStub); ts = proxyquire.noCallThru().load("src/utils/typescript", { @@ -21,7 +22,7 @@ describe("utils/typescript", () => { }); afterEach(() => { - _.set(process, TESTPLANE_TRANSFORM_HOOK, undefined); + delete (process as any)[TESTPLANE_TRANSFORM_HOOK]; _.set(process, "env", processEnvBackup); }); diff --git a/test/src/worker/browser-env/runner/test-runner/index.ts b/test/src/worker/browser-env/runner/test-runner/index.ts index 2f8d5b72d..617c8b311 100644 --- a/test/src/worker/browser-env/runner/test-runner/index.ts +++ b/test/src/worker/browser-env/runner/test-runner/index.ts @@ -1,8 +1,9 @@ import process from "node:process"; import crypto from "node:crypto"; +import clearRequire from "clear-require"; import { EventEmitter } from "node:stream"; import _ from "lodash"; -import sinon, { SinonStub, SinonFakeTimers } from "sinon"; +import sinon, { type SinonStub, type SinonFakeTimers } from "sinon"; import proxyquire from "proxyquire"; import type NodejsEnvRunnerOriginal from "../../../../../../src/worker/runner/test-runner"; @@ -22,10 +23,7 @@ import RuntimeConfig from "../../../../../../src/config/runtime-config"; import ExpectWebdriverIO from "expect-webdriverio"; import { BrowserEventNames } from "../../../../../../src/runner/browser-env/vite/types"; import { BrowserViteSocket } from "../../../../../../src/runner/browser-env/vite/browser-modules/types"; -import { - WorkerEventNames, - type WorkerViteSocket, -} from "../../../../../../src/worker/browser-env/runner/test-runner/types"; +import { WorkerEventNames } from "../../../../../../src/worker/browser-env/runner/test-runner/types"; import type { Socket } from "socket.io-client"; import type { MatcherState } from "expect"; import type { ChainablePromiseElement } from "@testplane/webdriverio"; @@ -125,22 +123,24 @@ describe("worker/browser-env/runner/test-runner", () => { return promise; }; - const mkBrowser_ = (opts: Partial = {}): ExistingBrowser => - ({ - publicAPI: { - url: sandbox.stub().resolves(), - $: sandbox.stub().resolves({ - scrollIntoView: sandbox.stub().resolves(), - elementId: "body-id", - }), - execute: sandbox.stub().resolves({ x: 0, y: 0 }), - action: sandbox.stub().returns({ - move: sandbox.stub().returnsThis(), - perform: sandbox.stub().resolves(), - }), - moveToElement: sandbox.stub().resolves(), - isW3C: true, - } as unknown as Browser["publicAPI"], + const mkBrowser_ = (opts: Partial = {}): ExistingBrowser => { + const publicAPI = Object.assign(Object.create({}), { + url: sandbox.stub().resolves(), + $: sandbox.stub().resolves({ + scrollIntoView: sandbox.stub().resolves(), + elementId: "body-id", + }), + execute: sandbox.stub().resolves({ x: 0, y: 0 }), + action: sandbox.stub().returns({ + move: sandbox.stub().returnsThis(), + perform: sandbox.stub().resolves(), + }), + moveToElement: sandbox.stub().resolves(), + isW3C: true, + }) as unknown as Browser["publicAPI"]; + + return { + publicAPI, config: makeBrowserConfigStub({ saveHistoryMode: "none" }) as BrowserConfig, state: { isBroken: false, @@ -162,7 +162,8 @@ describe("worker/browser-env/runner/test-runner", () => { release: sandbox.stub(), } as unknown as ExistingBrowser["callstackHistory"], ...opts, - } as unknown as ExistingBrowser); + } as unknown as ExistingBrowser; + }; const mkElement_ = (opts: Partial = {}): WebdriverIO.Element => { return { @@ -172,8 +173,8 @@ describe("worker/browser-env/runner/test-runner", () => { } as unknown as WebdriverIO.Element; }; - const mkSocket_ = (): WorkerViteSocket => { - const socket = new EventEmitter() as unknown as WorkerViteSocket; + const mkSocket_ = (): BrowserViteSocket => { + const socket = new EventEmitter() as unknown as BrowserViteSocket; socket.emitWithAck = sandbox.stub().onFirstCall().resolves(null).onSecondCall().resolves([null]); socket.timeout = sandbox.stub().returnsThis(); @@ -187,7 +188,9 @@ describe("worker/browser-env/runner/test-runner", () => { const initBrowserEnvRunner_ = ( opts: { expectMatchers: Record } = { expectMatchers: {} }, ): BrowserEnvRunnerClass => { - const strictProxyquire = proxyquire.noCallThru().noPreserveCache(); + const strictProxyquire = proxyquire.noCallThru(); + clearRequire(require.resolve("../../../../../../src/worker/runner/test-runner")); + clearRequire(require.resolve("../../../../../../src/worker/browser-env/runner/test-runner")); loggerWarnStub = sandbox.stub(); socketClientStub = sandbox.stub().returns(mkSocket_()); @@ -237,7 +240,10 @@ describe("worker/browser-env/runner/test-runner", () => { BrowserEnvRunnerStub = initBrowserEnvRunner_(); }); - afterEach(() => sandbox.restore()); + afterEach(() => { + proxyquire.callThru(); + sandbox.restore(); + }); describe("constructor", () => { describe("socket", () => { @@ -292,7 +298,7 @@ describe("worker/browser-env/runner/test-runner", () => { sandbox.stub(process, "pid").value(pid); (crypto.randomUUID as SinonStub).returns(runUuid); - const socket = mkSocket_() as Socket; + const socket = mkSocket_() as unknown as Socket; (socket as any).active = false; socketClientStub.returns(socket); @@ -455,7 +461,7 @@ describe("worker/browser-env/runner/test-runner", () => { let clock: SinonFakeTimers; beforeEach(() => { - clock = sinon.useFakeTimers({ toFake: ["setInterval", "setTimeout", "clearInterval"] }); + clock = sinon.useFakeTimers({ toFake: ["setInterval", "setTimeout", "clearInterval", "clearTimeout"] }); }); afterEach(() => clock.restore()); diff --git a/test/src/worker/runner/index.js b/test/src/worker/runner/index.js index e49fdf31a..696768ebe 100644 --- a/test/src/worker/runner/index.js +++ b/test/src/worker/runner/index.js @@ -1,17 +1,17 @@ "use strict"; +const clearRequire = require("clear-require"); const proxyquire = require("proxyquire"); const BrowserPool = require("src/worker/runner/browser-pool"); const { CachingTestParser } = require("src/worker/runner/caching-test-parser"); const { BrowserAgent } = require("src/worker/runner/browser-agent"); const { WorkerEvents: RunnerEvents } = require("src/events"); -const NodejsEnvTestRunner = require("src/worker/runner/test-runner"); -const { TestRunner: BrowserEnvTestRunner } = require("src/worker/browser-env/runner/test-runner"); const { NODEJS_TEST_RUN_ENV, BROWSER_TEST_RUN_ENV } = require("src/constants/config"); const { makeConfigStub, makeTest } = require("../../../utils"); describe("worker/runner", () => { const sandbox = sinon.createSandbox(); + let NodejsEnvTestRunner, BrowserEnvTestRunner; let nodejsTestRunner, browserTestRunner, Runner; let runWithTestplaneDependenciesCollecting, readTestFileWithTestplaneDependenciesCollecting; let processSendBackup; @@ -22,6 +22,13 @@ describe("worker/runner", () => { }; beforeEach(() => { + clearRequire(require.resolve("src/worker/runner")); + clearRequire(require.resolve("src/worker/runner/test-runner")); + clearRequire(require.resolve("src/worker/browser-env/runner/test-runner")); + + NodejsEnvTestRunner = require("src/worker/runner/test-runner"); + ({ TestRunner: BrowserEnvTestRunner } = require("src/worker/browser-env/runner/test-runner")); + sandbox.stub(BrowserPool, "create").returns({ browser: "pool" }); sandbox.stub(CachingTestParser, "create").returns(Object.create(CachingTestParser.prototype)); @@ -44,12 +51,13 @@ describe("worker/runner", () => { sandbox.stub(BrowserAgent, "create").returns(Object.create(BrowserAgent.prototype)); Runner = proxyquire("src/worker/runner", { - "./test-runner": { default: { create: () => nodejsTestRunner } }, - "../browser-env/runner/test-runner": { TestRunner: { create: () => browserTestRunner } }, "../../browser/cdp/selectivity/testplane-selectivity": { runWithTestplaneDependenciesCollecting, readTestFileWithTestplaneDependenciesCollecting, }, + "../../constants/process-messages": { + TEST_ASSIGNED_TO_WORKER: "worker.testAssignedToWorker", + }, }); processSendBackup = process.send; @@ -92,11 +100,13 @@ describe("worker/runner", () => { }); [ - { name: "NodejsEnvTestRunner", TestRunner: NodejsEnvTestRunner, testRunEnv: NODEJS_TEST_RUN_ENV }, - { name: "BrowserEnvTestRunner", TestRunner: BrowserEnvTestRunner, testRunEnv: BROWSER_TEST_RUN_ENV }, - ].forEach(({ name, TestRunner, testRunEnv }) => { + { name: "NodejsEnvTestRunner", testRunEnv: NODEJS_TEST_RUN_ENV }, + { name: "BrowserEnvTestRunner", testRunEnv: BROWSER_TEST_RUN_ENV }, + ].forEach(({ name, testRunEnv }) => { describe(name, () => { let runner; + const getTestRunner = () => + testRunEnv === NODEJS_TEST_RUN_ENV ? NodejsEnvTestRunner : BrowserEnvTestRunner; beforeEach(() => { runner = mkRunner_({ @@ -124,6 +134,7 @@ describe("worker/runner", () => { CachingTestParser.prototype.parse.resolves([test]); await runner.runTest("some test", {}); + const TestRunner = getTestRunner(); // Note: BrowserEnvTestRunner assertions are skipped due to dynamic import mocking limitations // The core functionality is still tested via NodejsEnvTestRunner which uses the same API @@ -143,6 +154,7 @@ describe("worker/runner", () => { CachingTestParser.prototype.parse.resolves([test]); await runner.runTest("some test", { browserId: "bro" }); + const TestRunner = getTestRunner(); // Note: BrowserEnvTestRunner assertions are skipped due to dynamic import mocking limitations if (TestRunner === NodejsEnvTestRunner) { @@ -157,6 +169,7 @@ describe("worker/runner", () => { CachingTestParser.prototype.parse.resolves([test]); await runner.runTest("some test", { file: "/path/to/file" }); + const TestRunner = getTestRunner(); assert.calledOnceWith(TestRunner.create, sinon.match({ file: "/path/to/file" })); }); @@ -173,6 +186,7 @@ describe("worker/runner", () => { BrowserAgent.create.withArgs({ id: "bro", version: "1.0", pool }).returns(browserAgent); await runner.runTest("some test", { browserId: "bro", browserVersion: "1.0" }); + const TestRunner = getTestRunner(); assert.calledOnceWith(TestRunner.create, sinon.match({ browserAgent })); }); @@ -191,6 +205,7 @@ describe("worker/runner", () => { CachingTestParser.prototype.parse.resolves([test1, test2]); await runner.runTest("other test", {}); + const TestRunner = getTestRunner(); const testRunner = TestRunner === NodejsEnvTestRunner ? nodejsTestRunner : browserTestRunner; @@ -215,6 +230,7 @@ describe("worker/runner", () => { sessionOpts: "some-opts", state: {}, }); + const TestRunner = getTestRunner(); const testRunner = TestRunner === NodejsEnvTestRunner ? nodejsTestRunner : browserTestRunner; diff --git a/test/src/worker/testplane.js b/test/src/worker/testplane.js index 12f417ade..0ba5ff673 100644 --- a/test/src/worker/testplane.js +++ b/test/src/worker/testplane.js @@ -24,6 +24,7 @@ describe("worker/testplane", () => { Testplane = proxyquire("src/worker/testplane", { "expect-webdriverio": ExpectWebdriverio, + "./runner": Runner, }).Testplane; sandbox.stub(Config, "create").returns(makeConfigStub()); diff --git a/test/tsconfig.json b/test/tsconfig.json index 3ccc84cf3..40493864d 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../tsconfig.common.json", - "include": ["../typings", "../src/index.ts", "."], + "include": ["../typings", "../src/index.ts", "./types.ts", "src", "integration"], "exclude": ["../src/runner/browser-env/vite/browser-modules"], "compilerOptions": { "baseUrl": "..", @@ -9,5 +9,19 @@ "src/*": ["src/*"], "@isomorphic": ["src/browser/isomorphic/index.ts"] } - } + }, + "references": [ + { + "path": "../src/runner/browser-env/vite/browser-modules/tsconfig.json" + }, + { + "path": "../src/browser/client-scripts/screen-shooter/tsconfig.json" + }, + { + "path": "../src/browser/client-scripts/browser-utils/tsconfig.json" + }, + { + "path": "../src/browser/isomorphic/tsconfig.json" + } + ] } diff --git a/test/utils.js b/test/utils.js index cf3be2951..81d0ee04d 100644 --- a/test/utils.js +++ b/test/utils.js @@ -35,6 +35,10 @@ function makeConfigStub(opts = {}) { input: "some-path", output: "some-other-path", }, + takeScreenshotOnFails: { + testFail: true, + assertViewFail: true, + }, timeTravel: { mode: "off" }, selectivity: { enabled: false }, }); @@ -49,6 +53,7 @@ function makeConfigStub(opts = {}) { lastFailed: opts.lastFailed, timeTravel: opts.timeTravel, selectivity: opts.selectivity, + takeScreenshotOnFails: opts.takeScreenshotOnFails, }; opts.browsers.forEach(browserId => { @@ -79,6 +84,7 @@ function makeBrowserConfigStub(opts = {}, browserId) { system: opts.system, urlHttpTimeout: opts.urlHttpTimeout, httpTimeout: opts.httpTimeout, + takeScreenshotOnFails: opts.takeScreenshotOnFails || { testFail: true, assertViewFail: true }, timeTravel: { mode: "off" }, selectivity: opts.selectivity, }; diff --git a/tsconfig.json b/tsconfig.json index 0fdad425e..48a6dcbb5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,13 +12,18 @@ "compilerOptions": { "outDir": "build" }, - "references": [{ - "path": "./src/runner/browser-env/vite/browser-modules/tsconfig.json" - }, { - "path": "./src/browser/client-scripts/screen-shooter/tsconfig.json" - }, { - "path": "./src/browser/client-scripts/browser-utils/tsconfig.json" - }, { - "path": "./src/browser/isomorphic/tsconfig.json" - }] + "references": [ + { + "path": "./src/runner/browser-env/vite/browser-modules/tsconfig.json" + }, + { + "path": "./src/browser/client-scripts/screen-shooter/tsconfig.json" + }, + { + "path": "./src/browser/client-scripts/browser-utils/tsconfig.json" + }, + { + "path": "./src/browser/isomorphic/tsconfig.json" + } + ] }