123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214 |
- import { screen, desktopCapturer, NativeImage } from 'electron';
- import { AssertionError } from 'chai';
- import { createArtifactWithRandomId } from './artifacts';
- 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 euclidean 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<void> {
- 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);
- }
- public async takeScreenshot (filePrefix: string) {
- const frame = await this.captureFrame();
- return await createArtifactWithRandomId(
- (id) => `${filePrefix}-${id}.png`,
- frame.toPNG()
- );
- }
- private async captureFrame (): Promise<NativeImage> {
- 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;
- };
|