import { screen, desktopCapturer, NativeImage } from 'electron'; import { createArtifactWithRandomId } from './artifacts'; import { AssertionError } from 'chai'; export enum HexColors { GREEN = '#00b140', PURPLE = '#6a0dad', RED = '#ff0000', BLUE = '#0000ff', WHITE = '#ffffff', } function hexToRgba ( hexColor: string ): [number, number, number, number] | undefined { const match = hexColor.match(/^#([0-9a-fA-F]{6,8})$/); if (!match) return; const colorStr = match[1]; return [ parseInt(colorStr.substring(0, 2), 16), parseInt(colorStr.substring(2, 4), 16), parseInt(colorStr.substring(4, 6), 16), parseInt(colorStr.substring(6, 8), 16) || 0xff ]; } function formatHexByte (val: number): string { const str = val.toString(16); return str.length === 2 ? str : `0${str}`; } /** * Get the hex color at the given pixel coordinate in an image. */ function getPixelColor ( image: Electron.NativeImage, point: Electron.Point ): string { // image.crop crashes if point is fractional, so round to prevent that crash const pixel = image.crop({ x: Math.round(point.x), y: Math.round(point.y), width: 1, height: 1 }); // TODO(samuelmaddock): NativeImage.toBitmap() should return the raw pixel // color, but it sometimes differs. Why is that? const [b, g, r] = pixel.toBitmap(); return `#${formatHexByte(r)}${formatHexByte(g)}${formatHexByte(b)}`; } /** Calculate euclidian distance between colors. */ function colorDistance (hexColorA: string, hexColorB: string): number { const colorA = hexToRgba(hexColorA); const colorB = hexToRgba(hexColorB); if (!colorA || !colorB) return -1; return Math.sqrt( Math.pow(colorB[0] - colorA[0], 2) + Math.pow(colorB[1] - colorA[1], 2) + Math.pow(colorB[2] - colorA[2], 2) ); } /** * Determine if colors are similar based on distance. This can be useful when * comparing colors which may differ based on lossy compression. */ function areColorsSimilar ( hexColorA: string, hexColorB: string, distanceThreshold = 90 ): boolean { const distance = colorDistance(hexColorA, hexColorB); return distance <= distanceThreshold; } function displayCenter (display: Electron.Display): Electron.Point { return { x: display.size.width / 2, y: display.size.height / 2 }; } /** Resolve when approx. one frame has passed (30FPS) */ export async function nextFrameTime (): Promise { return await new Promise((resolve) => { setTimeout(resolve, 1000 / 30); }); } /** * Utilities for creating and inspecting a screen capture. * * Set `PAUSE_CAPTURE_TESTS` env var to briefly pause during screen * capture for easier inspection. * * NOTE: Not yet supported on Linux in CI due to empty sources list. */ export class ScreenCapture { /** Timeout to wait for expected color to match. */ static TIMEOUT = 3000; constructor (display?: Electron.Display) { this.display = display || screen.getPrimaryDisplay(); } public async expectColorAtCenterMatches (hexColor: string) { return this._expectImpl(displayCenter(this.display), hexColor, true); } public async expectColorAtCenterDoesNotMatch (hexColor: string) { return this._expectImpl(displayCenter(this.display), hexColor, false); } public async expectColorAtPointOnDisplayMatches ( hexColor: string, findPoint: (displaySize: Electron.Size) => Electron.Point ) { return this._expectImpl(findPoint(this.display.size), hexColor, true); } private async captureFrame (): Promise { const sources = await desktopCapturer.getSources({ types: ['screen'], thumbnailSize: this.display.size }); const captureSource = sources.find( (source) => source.display_id === this.display.id.toString() ); if (captureSource === undefined) { const displayIds = sources.map((source) => source.display_id).join(', '); throw new Error( `Unable to find screen capture for display '${this.display.id}'\n\tAvailable displays: ${displayIds}` ); } if (process.env.PAUSE_CAPTURE_TESTS) { await new Promise((resolve) => setTimeout(resolve, 1e3)); } return captureSource.thumbnail; } private async _expectImpl ( point: Electron.Point, expectedColor: string, matchIsExpected: boolean ) { let frame: Electron.NativeImage; let actualColor: string; let gotExpectedResult: boolean = false; const expiration = Date.now() + ScreenCapture.TIMEOUT; // Continuously capture frames until we either see the expected result or // reach a timeout. This helps avoid flaky tests in which a short waiting // period is required for the expected result. do { frame = await this.captureFrame(); actualColor = getPixelColor(frame, point); const colorsMatch = areColorsSimilar(expectedColor, actualColor); gotExpectedResult = matchIsExpected ? colorsMatch : !colorsMatch; if (gotExpectedResult) break; await nextFrameTime(); // limit framerate } while (Date.now() < expiration); if (!gotExpectedResult) { // Limit image to 720p to save on storage space if (process.env.CI) { const width = Math.floor(Math.min(frame.getSize().width, 720)); frame = frame.resize({ width }); } // Save the image as an artifact for better debugging const artifactName = await createArtifactWithRandomId( (id) => `color-mismatch-${id}.png`, frame.toPNG() ); throw new AssertionError( `Expected color at (${point.x}, ${point.y}) to ${ matchIsExpected ? 'match' : '*not* match' } '${expectedColor}', but got '${actualColor}'. See the artifact '${artifactName}' for more information.` ); } } private display: Electron.Display; } /** * Whether the current VM has a valid screen which can be used to capture. * * This is specific to Electron's CI test runners. * - Linux: virtual screen display is 0x0 * - Win32 arm64 (WOA): virtual screen display is 0x0 * - Win32 ia32: skipped * - Win32 x64: virtual screen display is 0x0 */ export const hasCapturableScreen = () => { return process.env.CI ? process.platform === 'darwin' : true; };