UNPKG

testplane

Version:

Tests framework based on mocha and wdio

522 lines 26.6 kB
"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