testplane
Version:
Tests framework based on mocha and wdio
522 lines • 26.6 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CompositeImage = void 0;
const node_os_1 = __importDefault(require("node:os"));
const node_path_1 = __importDefault(require("node:path"));
const image_1 = require("../../../image");
const geometry_1 = require("../../isomorphic/geometry");
const debug_utils_1 = require("./debug-utils");
const debug_1 = require("../debug");
const debug = (0, debug_1.makeVerboseScreenshotsDebug)("testplane:screenshots:composite-image");
class CompositeImage {
/** Creates a composite renderer instance while preserving subclass construction. */
static create(...args) {
return new this(...args);
}
/** Initializes chunk storage and an optional debug-output directory. */
constructor() {
this._debugTmpDir = null;
this._captureAreaSize = null;
this._compositeChunks = [];
if (process.env.TESTPLANE_DEBUG_SCREENSHOTS) {
this._debugTmpDir = node_path_1.default.join(node_os_1.default.tmpdir(), `testplane-composite-image-${Math.random().toString(36).slice(2)}`);
}
}
/**
* Registers a viewport image with corresponding safe area, capture bounding rects and ignore bounding rects, all relative to the viewport.
* The order of registration can be arbitrary, viewport height can change between chunks, gaps will be handled gracefully.
* Expects finite integer coords and sizes, otherwise behavior is undefined.
* @throws {Error} if capture area size is zero or negative
*/
async registerViewportImageAtOffset(viewportImage, safeArea, captureSpecs, ignoreBoundingRects, anchorShift = null) {
const visibleCoveringRect = this._getVisibleCoveringRect({ captureSpecs }) ?? (0, geometry_1.getCoveringRect)(captureSpecs.map(s => s.visible));
if (!this._captureAreaSize) {
this._captureAreaSize = (0, geometry_1.getSize)(visibleCoveringRect);
}
if (this._captureAreaSize.width <= 0 || this._captureAreaSize.height <= 0) {
throw new Error("Capture area size cannot be zero or negative. Got: " +
(0, geometry_1.prettySize)(this._captureAreaSize) +
"\nMost likely this means that you are trying to capture an area that is completely clipped by parent block (e.g. with overflow: hidden), or the element is zero-sized on its own.");
}
const imageSize = viewportImage.getSize();
debug("Captured the next chunk.\n captureSpecs: %O\n visibleCoveringRect: %O\n ignoreBoundingRects: %O\n viewportImageSize: %O", captureSpecs, visibleCoveringRect, ignoreBoundingRects, imageSize);
await (0, debug_utils_1.saveViewportImageForDebugIfNeeded)(this._compositeChunks.length, viewportImage, imageSize, safeArea, captureSpecs, visibleCoveringRect, this._debugTmpDir);
this._compositeChunks.push({
image: viewportImage,
imageSize,
safeArea,
captureSpecs,
boundingRectsToIgnore: ignoreBoundingRects,
anchorShift,
});
}
/**
* Renders a composite image from the registered chunks.
* @throws {Error} if trying to render with zero chunks registered
* @throws {Error} if any of the chunks contain malformed PNG data
*/
async render() {
if (!this._compositeChunks.length) {
throw new Error("Cannot render composite image: no chunks were registered.\n" +
"This means that screenshot was not captured even once and we have no image to render.");
}
if (!this._captureAreaSize) {
throw new Error("Cannot render composite image: capture area size is not set.\n" +
"This means registerViewportImageAtOffset was never called with a valid capture rect.");
}
const anchoredChunks = this._computeAnchoredChunks();
const sortedChunks = anchoredChunks.slice().sort((a, b) => (0, geometry_1.subtractCoords)(b.anchorTop, a.anchorTop));
const candidates = sortedChunks.map(chunk => this._buildCandidate(chunk));
debug("Candidates: %O", candidates);
this._expandCandidatesToFullArea(candidates);
this._chooseBestCandidates(candidates);
const commonHorizontalArea = this._computeCommonHorizontalAreaIfNeeded(candidates, this._captureAreaSize.width);
const captureWidth = commonHorizontalArea?.width ?? this._captureAreaSize.width;
debug("Chosen best candidates: %O", candidates);
const pieces = this._buildRenderPieces(candidates);
debug("Rendering composite image. chunks: %d, pieces: %O", this._compositeChunks.length, pieces);
const renderedPieces = [];
for (const piece of pieces) {
if (piece.type === "black") {
renderedPieces.push(await this._createBlackPiece(piece.height, captureWidth));
continue;
}
if (piece.verticalArea.height <= 0) {
continue;
}
renderedPieces.push(await this._createChunkPiece(piece.chunk, piece.verticalArea, captureWidth, commonHorizontalArea));
}
await (0, debug_utils_1.saveRenderedPiecesForDebugIfNeeded)(renderedPieces, this._debugTmpDir);
if (!renderedPieces.length) {
return this._createBlackPiece(this._captureAreaSize.height, captureWidth);
}
const result = renderedPieces[0];
if (renderedPieces.length > 1) {
result.addJoin(renderedPieces.slice(1));
}
await result.applyJoin();
return result;
}
/**
* Computes anchor tops for all chunks.
*
* The reference chunk is the one with the highest captureSpec covering-rect top (= the first
* scroll position, which has the most positive viewport-space top).
*
* For each non-reference chunk the base anchorTop is computed from captureSpec deltas (same as
* before). When per-chunk correction data is available, the anchor is additionally corrected.
*
* anchorTop_corrected = anchorTop_from_specs + (chunkAnchorShift - referenceAnchorShift)
*
* In the stable case correction values are 0 for all chunks.
*/
_computeAnchoredChunks() {
let referenceIndex = 0;
let referenceCoveringRectTop = (0, geometry_1.getCoveringRect)(this._compositeChunks[0].captureSpecs.map(s => s.full)).top;
for (let i = 1; i < this._compositeChunks.length; i++) {
const coveringRectTop = (0, geometry_1.getCoveringRect)(this._compositeChunks[i].captureSpecs.map(s => s.full)).top;
if (coveringRectTop > referenceCoveringRectTop) {
referenceIndex = i;
referenceCoveringRectTop = coveringRectTop;
}
}
const referenceChunk = this._compositeChunks[referenceIndex];
const referenceCaptureSpecs = referenceChunk.captureSpecs;
const referenceAnchorShift = referenceChunk.anchorShift;
const anchoredChunks = this._compositeChunks.map((chunk, index) => {
if (index === referenceIndex) {
return { ...chunk, anchorTop: referenceCoveringRectTop };
}
let maxDelta = 0;
let hasRenderableDelta = false;
const minLength = Math.min(chunk.captureSpecs.length, referenceCaptureSpecs.length);
for (let i = 0; i < minLength; i++) {
const referenceSpec = referenceCaptureSpecs[i];
const chunkSpec = chunk.captureSpecs[i];
if (!this._isRenderableCaptureSpec(referenceSpec) || !this._isRenderableCaptureSpec(chunkSpec)) {
continue;
}
const delta = (0, geometry_1.subtractCoords)(referenceSpec.full.top, chunkSpec.full.top);
if (delta > maxDelta) {
maxDelta = delta;
}
hasRenderableDelta = true;
}
if (!hasRenderableDelta) {
for (let i = 0; i < minLength; i++) {
const referenceSpec = referenceCaptureSpecs[i];
const chunkSpec = chunk.captureSpecs[i];
const delta = (0, geometry_1.subtractCoords)(referenceSpec.full.top, chunkSpec.full.top);
if (delta > maxDelta) {
maxDelta = delta;
}
}
}
else if (maxDelta === 0) {
for (let i = 0; i < minLength; i++) {
const referenceSpec = referenceCaptureSpecs[i];
const chunkSpec = chunk.captureSpecs[i];
if (!this._isRenderableCaptureSpec(chunkSpec)) {
continue;
}
const delta = (0, geometry_1.subtractCoords)(referenceSpec.full.top, chunkSpec.full.top);
if (delta > maxDelta) {
maxDelta = delta;
}
}
}
const anchorTopFromSpecs = referenceCoveringRectTop - maxDelta;
// Apply content-shift correction when anchor tracking data is available (best-effort pass).
const shiftCorrection = chunk.anchorShift !== null && referenceAnchorShift !== null
? chunk.anchorShift - referenceAnchorShift
: 0;
return {
...chunk,
anchorTop: (anchorTopFromSpecs + shiftCorrection),
};
});
debug("Anchored chunks: %O", anchoredChunks);
return anchoredChunks;
}
/** Checks whether a capture spec contributes visible pixels in the current chunk. */
_isRenderableCaptureSpec(spec) {
return spec.visible.width > 0 && spec.visible.height > 0;
}
/** Returns the bounding rect that covers all visible capture-spec parts for a chunk. */
_getVisibleCoveringRect(chunk) {
const visibleRects = chunk.captureSpecs
.filter(spec => this._isRenderableCaptureSpec(spec))
.map(spec => spec.visible);
if (!visibleRects.length) {
return null;
}
return (0, geometry_1.getCoveringRect)(visibleRects);
}
/** Builds a segment candidate, listing all possible options, e.g. strictly follow safe area, relax top/bottom edges, ignore safe area at all. */
_buildCandidate(chunk) {
const strict = this._getYBandForMode(chunk, "strict");
const relaxTop = this._getYBandForMode(chunk, "relaxTop");
const relaxBottom = this._getYBandForMode(chunk, "relaxBottom");
const full = this._getYBandForMode(chunk, "full");
return {
chunk,
strict,
relaxTop,
relaxBottom,
full,
chosen: strict,
};
}
/** Computes a usable vertical band for a specific mode: e.g. what if we expand the top edge of the safe area? */
_getYBandForMode(chunk, mode) {
const viewportTop = 0;
const viewportBottom = chunk.imageSize.height;
const safeTop = chunk.safeArea.top;
const safeBottom = (0, geometry_1.getBottom)(chunk.safeArea);
let resultingBand = {
top: safeTop,
height: (0, geometry_1.getHeight)(safeTop, safeBottom),
};
if (mode === "relaxTop") {
resultingBand.top = viewportTop;
resultingBand.height = (0, geometry_1.getHeight)(resultingBand.top, safeBottom);
}
else if (mode === "relaxBottom") {
resultingBand.height = (0, geometry_1.getHeight)(resultingBand.top, viewportBottom);
}
else if (mode === "full") {
resultingBand.top = viewportTop;
resultingBand.height = (0, geometry_1.getHeight)(resultingBand.top, viewportBottom);
}
const visibleCoveringRect = this._getVisibleCoveringRect(chunk);
if (!visibleCoveringRect) {
return null;
}
resultingBand = (0, geometry_1.intersectYBands)(resultingBand, { top: viewportTop, height: chunk.imageSize.height });
resultingBand = (0, geometry_1.intersectYBands)(resultingBand, visibleCoveringRect);
if (!resultingBand || resultingBand.height <= 0) {
return null;
}
return resultingBand;
}
/** Chooses the best vertical band per chunk, relaxing edges only where needed to avoid gaps. */
_chooseBestCandidates(candidates) {
if (!candidates.length) {
return;
}
// Always choose relaxed values for the first and last candidates
const first = candidates[0];
first.chosen = first.chosen ?? first.relaxTop ?? first.full;
if (first.chosen) {
const originalBottom = (0, geometry_1.getBottom)(first.chosen);
const relaxedTop = first.relaxTop?.top ?? first.full?.top ?? first.chosen.top;
first.chosen.top = (0, geometry_1.getMinCoord)(first.chosen.top, relaxedTop);
first.chosen.height = (0, geometry_1.getHeight)(first.chosen.top, originalBottom);
}
const last = candidates[candidates.length - 1];
last.chosen = last.chosen ?? last.relaxBottom ?? last.full;
if (last.chosen) {
const relaxedBottom = (0, geometry_1.getBottom)(last.relaxBottom ?? last.full ?? last.chosen);
const currentBottom = (0, geometry_1.getBottom)(last.chosen);
const maxBottom = (0, geometry_1.getMaxCoord)(currentBottom, relaxedBottom);
const maxHeight = (0, geometry_1.getHeight)(last.chosen.top, maxBottom);
last.chosen.height = (0, geometry_1.getMaxLength)(last.chosen.height, maxHeight);
}
for (let i = 0; i < candidates.length - 1; i++) {
const upper = candidates[i];
const lower = candidates[i + 1];
upper.chosen = upper.chosen ?? upper.relaxBottom ?? upper.full;
lower.chosen = lower.chosen ?? lower.relaxTop ?? lower.full;
if (!upper.chosen || !lower.chosen) {
continue;
}
let upperRelativeToCaptureArea = {
top: (0, geometry_1.fromViewportToCaptureArea)(upper.chosen.top, upper.chunk.anchorTop),
height: upper.chosen.height,
};
let upperBottomRelativeToCaptureArea = (0, geometry_1.getBottom)(upperRelativeToCaptureArea);
let lowerTopRelativeToCaptureArea = (0, geometry_1.fromViewportToCaptureArea)(lower.chosen.top, lower.chunk.anchorTop);
if (upperBottomRelativeToCaptureArea >= lowerTopRelativeToCaptureArea) {
continue;
}
const relaxedUpperBottom = (0, geometry_1.getBottom)(upper.relaxBottom ?? upper.full ?? upper.chosen);
if (relaxedUpperBottom > (0, geometry_1.getBottom)(upper.chosen)) {
upper.chosen.height = (0, geometry_1.getHeight)(upper.chosen.top, relaxedUpperBottom);
}
upperRelativeToCaptureArea = {
top: (0, geometry_1.fromViewportToCaptureArea)(upper.chosen.top, upper.chunk.anchorTop),
height: upper.chosen.height,
};
upperBottomRelativeToCaptureArea = (0, geometry_1.getBottom)(upperRelativeToCaptureArea);
lowerTopRelativeToCaptureArea = (0, geometry_1.fromViewportToCaptureArea)(lower.chosen.top, lower.chunk.anchorTop);
if (upperBottomRelativeToCaptureArea >= lowerTopRelativeToCaptureArea) {
continue;
}
const relaxedLowerStart = lower.relaxTop?.top ?? lower.full?.top ?? lower.chosen.top;
if (relaxedLowerStart < lower.chosen.top) {
const originalBottom = (0, geometry_1.getBottom)(lower.chosen);
lower.chosen.top = relaxedLowerStart;
lower.chosen.height = (0, geometry_1.getHeight)(lower.chosen.top, originalBottom);
}
}
}
/** Expansion for cases when capture elements are far apart and not fit one viewport. */
_expandCandidatesToFullArea(candidates) {
if (candidates.some(candidate => this._doesVisibleAreaCoverFullArea(candidate.chunk))) {
return;
}
for (const candidate of candidates) {
if (!candidate.chosen) {
continue;
}
const safeAreaBand = candidate.chunk.safeArea;
const fullCoveringRect = (0, geometry_1.getCoveringRect)(candidate.chunk.captureSpecs.map(spec => spec.full));
const safeAreaBottom = (0, geometry_1.getBottom)(safeAreaBand);
const fullBottom = (0, geometry_1.getBottom)(fullCoveringRect);
const chosenBottom = (0, geometry_1.getBottom)(candidate.chosen);
let top = candidate.chosen.top;
let bottom = chosenBottom;
if (fullCoveringRect.top < safeAreaBand.top && top > safeAreaBand.top) {
top = safeAreaBand.top;
}
if (fullBottom > safeAreaBottom && bottom < safeAreaBottom) {
bottom = safeAreaBottom;
}
if (top !== candidate.chosen.top || bottom !== chosenBottom) {
candidate.chosen = {
top,
height: (0, geometry_1.getHeight)(top, bottom),
};
candidate.expanded = true;
}
}
}
/** Checks whether visible capture-spec pixels cover the complete requested capture area. */
_doesVisibleAreaCoverFullArea(chunk) {
const visibleCoveringRect = this._getVisibleCoveringRect(chunk);
if (!visibleCoveringRect) {
return false;
}
const fullCoveringRect = (0, geometry_1.getCoveringRect)(chunk.captureSpecs.map(spec => spec.full));
return (visibleCoveringRect.top <= fullCoveringRect.top &&
(0, geometry_1.getBottom)(visibleCoveringRect) >= (0, geometry_1.getBottom)(fullCoveringRect));
}
/** Given a list of best possible segments, builds a list of image pieces, inserting gaps when needed,
* ensuring resulting array is a vertically continuous sequence of pieces. */
_buildRenderPieces(candidates) {
const pieces = [];
const sortedCandidates = candidates
.filter(candidate => Boolean(candidate.chosen))
.sort((a, b) => (0, geometry_1.subtractCoords)((0, geometry_1.fromViewportToCaptureArea)(a.chosen.top, a.chunk.anchorTop), (0, geometry_1.fromViewportToCaptureArea)(b.chosen.top, b.chunk.anchorTop)));
let cursor = 0;
let hasStartedRendering = false;
for (const candidate of sortedCandidates) {
const chosen = candidate.chosen;
const chosenRelativeToCaptureArea = {
top: (0, geometry_1.fromViewportToCaptureArea)(chosen.top, candidate.chunk.anchorTop),
height: chosen.height,
};
const bottomRelativeToCaptureArea = (0, geometry_1.getBottom)(chosenRelativeToCaptureArea);
if (bottomRelativeToCaptureArea <= cursor) {
continue;
}
if (!hasStartedRendering) {
cursor = chosenRelativeToCaptureArea.top;
hasStartedRendering = true;
}
const topRelativeToCaptureArea = (0, geometry_1.getMaxCoord)(chosenRelativeToCaptureArea.top, cursor);
if (topRelativeToCaptureArea > cursor) {
pieces.push(...this._buildGapPieces(candidates, cursor, topRelativeToCaptureArea));
}
const cursorRelativeToViewport = (0, geometry_1.fromCaptureAreaToViewport)(cursor, candidate.chunk.anchorTop);
const topRelativeToViewport = (0, geometry_1.getMaxCoord)(chosen.top, cursorRelativeToViewport);
const bottomRelativeToViewport = (0, geometry_1.getBottom)(chosen);
pieces.push({
type: "chunk",
chunk: candidate.chunk,
verticalArea: {
top: topRelativeToViewport,
height: (0, geometry_1.getHeight)(topRelativeToViewport, bottomRelativeToViewport),
},
});
cursor = bottomRelativeToCaptureArea;
}
return pieces;
}
/** Fills an uncovered capture-area gap with usable chunk areas or black fallback slices. */
_buildGapPieces(candidates, gapTop, gapBottom) {
const pieces = [];
const usableAreas = candidates
.map(candidate => {
const safeArea = candidate.chunk.safeArea;
return {
chunk: candidate.chunk,
area: {
top: (0, geometry_1.fromViewportToCaptureArea)(safeArea.top, candidate.chunk.anchorTop),
height: safeArea.height,
},
};
})
.sort((a, b) => (0, geometry_1.subtractCoords)(a.area.top, b.area.top));
let cursor = gapTop;
for (const { chunk, area } of usableAreas) {
const areaBottom = (0, geometry_1.getBottom)(area);
if (areaBottom <= cursor || area.top >= gapBottom) {
continue;
}
const top = (0, geometry_1.getMaxCoord)(area.top, cursor);
const bottom = (0, geometry_1.getMinCoord)(areaBottom, gapBottom);
if (top > cursor) {
pieces.push({ type: "black", height: (0, geometry_1.getHeight)(cursor, top) });
}
if (bottom > top) {
pieces.push({
type: "chunk",
chunk,
verticalArea: {
top: (0, geometry_1.fromCaptureAreaToViewport)(top, chunk.anchorTop),
height: (0, geometry_1.getHeight)(top, bottom),
},
});
cursor = bottom;
}
if (cursor >= gapBottom) {
break;
}
}
if (cursor < gapBottom) {
pieces.push({ type: "black", height: (0, geometry_1.getHeight)(cursor, gapBottom) });
}
return pieces;
}
/** Returns the horizontal viewport band occupied by visible capture-spec pixels. */
_getChunkHorizontalArea(chunk) {
const viewportHorizontalArea = {
left: 0,
width: chunk.imageSize.width,
};
const visibleCoveringRect = this._getVisibleCoveringRect(chunk);
if (!visibleCoveringRect) {
return null;
}
return (0, geometry_1.intersectXBands)(viewportHorizontalArea, visibleCoveringRect);
}
/** Computes a shared horizontal crop band when chunks cannot safely use the original width. */
_computeCommonHorizontalAreaIfNeeded(candidates, captureWidth) {
const chunkHorizontalAreas = candidates
.map(candidate => this._getCandidateHorizontalArea(candidate))
.filter((area) => Boolean(area));
if (chunkHorizontalAreas.length === 0) {
return null;
}
const hasMultipleCaptureSpecs = candidates.some(candidate => candidate.chunk.captureSpecs.length > 1);
const hasExpandedCandidate = candidates.some(candidate => candidate.expanded);
const hasWidthMismatch = chunkHorizontalAreas.some(area => area.width !== captureWidth);
if (!hasMultipleCaptureSpecs && !hasExpandedCandidate && !hasWidthMismatch) {
return null;
}
const left = Math.min(...chunkHorizontalAreas.map(area => area.left));
const right = Math.max(...chunkHorizontalAreas.map(area => area.left + area.width));
return {
left: left,
width: (right - left),
};
}
/** Selects the horizontal band that should be used for a candidate chunk. */
_getCandidateHorizontalArea(candidate) {
if (!candidate.expanded) {
return this._getChunkHorizontalArea(candidate.chunk);
}
return this._getExpandedChunkHorizontalArea(candidate.chunk) ?? this._getChunkHorizontalArea(candidate.chunk);
}
/** Computes a horizontal band for expanded chunks using requested full rects and clip bounds. */
_getExpandedChunkHorizontalArea(chunk) {
const viewportHorizontalArea = {
left: 0,
width: chunk.imageSize.width,
};
const fullCoveringRect = (0, geometry_1.getCoveringRect)(chunk.captureSpecs.map(spec => spec.full));
const clipCoveringRect = (0, geometry_1.getCoveringRect)(chunk.captureSpecs.map(spec => spec.clip));
const fullHorizontalArea = (0, geometry_1.intersectXBands)(viewportHorizontalArea, fullCoveringRect);
return (0, geometry_1.intersectXBands)(fullHorizontalArea, clipCoveringRect);
}
/** Crops one viewport chunk into a render piece after clearing ignored regions. */
async _createChunkPiece(chunk, verticalArea, captureWidth, commonHorizontalArea) {
const viewportHorizontalArea = {
left: 0,
width: chunk.imageSize.width,
};
const horizonalArea = commonHorizontalArea
? (0, geometry_1.intersectXBands)(viewportHorizontalArea, commonHorizontalArea)
: this._getChunkHorizontalArea(chunk);
if (!horizonalArea ||
horizonalArea.width <= 0 ||
verticalArea.height <= 0 ||
horizonalArea.width !== captureWidth) {
debug("Chunk crop area is invalid or doesn't match capture width, using black fallback.\n verticalArea: %O\n horizonalArea: %O \n captureWidth: %d", verticalArea, horizonalArea, captureWidth);
return this._createBlackPiece(verticalArea.height, captureWidth);
}
const cropArea = {
top: verticalArea.top,
height: verticalArea.height,
left: horizonalArea.left,
width: horizonalArea.width,
};
const image = await chunk.image.clone();
for (const ignoreRect of chunk.boundingRectsToIgnore) {
await image.addClear(ignoreRect);
}
await image.applyJoin();
await image.crop(cropArea);
return image;
}
/** Creates a black fallback image piece for areas that no chunk can provide. */
async _createBlackPiece(height, width) {
return new image_1.Image({ width, height });
}
}
exports.CompositeImage = CompositeImage;
//# sourceMappingURL=index.js.map