test: disable flaky macOS panel test & refactor screen capture testing (#41441)

* Disable flaky test

* Add helper for storing test artifacts

* Refactor screen capture tests

We have a pattern for inspecting a screen capture, so this refactor codifies that pattern into a helper. This gives us shorter test code, consistency (previously, the display in test code and the display captured could theoretically be different), and better debugging/observability on failure.
This commit is contained in:
Calvin 2024-02-27 20:54:20 -07:00 committed by GitHub
parent 267c0796dd
commit a6133e85d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 215 additions and 141 deletions

View file

@ -1,60 +1,18 @@
import * as path from 'node:path';
import * as fs from 'node:fs';
import { screen, desktopCapturer, NativeImage } from 'electron';
const fixtures = path.resolve(__dirname, '..', 'fixtures');
import { createArtifactWithRandomId } from './artifacts';
import { AssertionError } from 'chai';
export enum HexColors {
GREEN = '#00b140',
PURPLE = '#6a0dad',
RED = '#ff0000',
BLUE = '#0000ff',
WHITE = '#ffffff'
};
WHITE = '#ffffff',
}
/**
* Capture the screen at the given point.
*
* NOTE: Not yet supported on Linux in CI due to empty sources list.
*/
export const captureScreen = async (point: Electron.Point = { x: 0, y: 0 }): Promise<NativeImage> => {
const display = screen.getDisplayNearestPoint(point);
const sources = await desktopCapturer.getSources({ types: ['screen'], thumbnailSize: display.size });
// Toggle to save screen captures for debugging.
const DEBUG_CAPTURE = process.env.DEBUG_CAPTURE || false;
if (DEBUG_CAPTURE) {
for (const source of sources) {
await fs.promises.writeFile(path.join(fixtures, `screenshot_${source.display_id}_${Date.now()}.png`), source.thumbnail.toPNG());
}
}
const screenCapture = sources.find(source => source.display_id === `${display.id}`);
// Fails when HDR is enabled on Windows.
// https://bugs.chromium.org/p/chromium/issues/detail?id=1247730
if (!screenCapture) {
const displayIds = sources.map(source => source.display_id);
throw new Error(`Unable to find screen capture for display '${display.id}'\n\tAvailable displays: ${displayIds.join(', ')}`);
}
return screenCapture.thumbnail;
};
const 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.
*/
export const 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)}`;
};
const hexToRgba = (hexColor: string) => {
function hexToRgba (
hexColor: string
): [number, number, number, number] | undefined {
const match = hexColor.match(/^#([0-9a-fA-F]{6,8})$/);
if (!match) return;
@ -63,34 +21,148 @@ const hexToRgba = (hexColor: string) => {
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
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. */
const colorDistance = (hexColorA: string, hexColorB: string) => {
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)
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.
*/
export const areColorsSimilar = (
function areColorsSimilar (
hexColorA: string,
hexColorB: string,
distanceThreshold = 90
): boolean => {
): boolean {
const distance = colorDistance(hexColorA, hexColorB);
return distance <= distanceThreshold;
};
}
function imageCenter (image: NativeImage): Electron.Point {
const size = image.getSize();
return {
x: size.width / 2,
y: size.height / 2
};
}
/**
* Utilities for creating and inspecting a screen capture.
*
* NOTE: Not yet supported on Linux in CI due to empty sources list.
*/
export class ScreenCapture {
/** Use the async constructor `ScreenCapture.create()` instead. */
private constructor (image: NativeImage) {
this.image = image;
}
public static async create (): Promise<ScreenCapture> {
const display = screen.getPrimaryDisplay();
return ScreenCapture._createImpl(display);
}
public static async createForDisplay (
display: Electron.Display
): Promise<ScreenCapture> {
return ScreenCapture._createImpl(display);
}
public async expectColorAtCenterMatches (hexColor: string) {
return this._expectImpl(imageCenter(this.image), hexColor, true);
}
public async expectColorAtCenterDoesNotMatch (hexColor: string) {
return this._expectImpl(imageCenter(this.image), hexColor, false);
}
public async expectColorAtPointOnDisplayMatches (
hexColor: string,
findPoint: (displaySize: Electron.Size) => Electron.Point
) {
return this._expectImpl(findPoint(this.image.getSize()), hexColor, true);
}
private static async _createImpl (display: Electron.Display) {
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: display.size
});
const captureSource = sources.find(
(source) => source.display_id === 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 '${display.id}'\n\tAvailable displays: ${displayIds}`
);
}
return new ScreenCapture(captureSource.thumbnail);
}
private async _expectImpl (
point: Electron.Point,
expectedColor: string,
matchIsExpected: boolean
) {
const actualColor = getPixelColor(this.image, point);
const colorsMatch = areColorsSimilar(expectedColor, actualColor);
const gotExpectedResult = matchIsExpected ? colorsMatch : !colorsMatch;
if (!gotExpectedResult) {
// Save the image as an artifact for better debugging
const artifactName = await createArtifactWithRandomId(
(id) => `color-mismatch-${id}.png`,
this.image.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 image: NativeImage;
}
/**
* Whether the current VM has a valid screen which can be used to capture.
@ -101,6 +173,8 @@ export const areColorsSimilar = (
* - Win32 ia32: skipped
*/
export const hasCapturableScreen = () => {
return process.platform === 'darwin' ||
(process.platform === 'win32' && process.arch === 'x64');
return (
process.platform === 'darwin' ||
(process.platform === 'win32' && process.arch === 'x64')
);
};