2022-03-01 22:14:11 +00:00
|
|
|
import { screen, desktopCapturer, NativeImage } from 'electron';
|
2024-10-03 03:52:01 +00:00
|
|
|
|
2024-02-28 03:54:20 +00:00
|
|
|
import { AssertionError } from 'chai';
|
2022-03-01 22:14:11 +00:00
|
|
|
|
2024-10-03 03:52:01 +00:00
|
|
|
import { createArtifactWithRandomId } from './artifacts';
|
|
|
|
|
2022-10-11 17:11:58 +00:00
|
|
|
export enum HexColors {
|
|
|
|
GREEN = '#00b140',
|
|
|
|
PURPLE = '#6a0dad',
|
|
|
|
RED = '#ff0000',
|
2023-05-02 21:44:34 +00:00
|
|
|
BLUE = '#0000ff',
|
2024-02-28 03:54:20 +00:00
|
|
|
WHITE = '#ffffff',
|
|
|
|
}
|
2022-03-01 22:14:11 +00:00
|
|
|
|
2024-02-28 03:54:20 +00:00
|
|
|
function hexToRgba (
|
|
|
|
hexColor: string
|
|
|
|
): [number, number, number, number] | undefined {
|
|
|
|
const match = hexColor.match(/^#([0-9a-fA-F]{6,8})$/);
|
|
|
|
if (!match) return;
|
2022-03-01 22:14:11 +00:00
|
|
|
|
2024-02-28 03:54:20 +00:00
|
|
|
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 {
|
2022-03-01 22:14:11 +00:00
|
|
|
const str = val.toString(16);
|
|
|
|
return str.length === 2 ? str : `0${str}`;
|
2024-02-28 03:54:20 +00:00
|
|
|
}
|
2022-03-01 22:14:11 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the hex color at the given pixel coordinate in an image.
|
|
|
|
*/
|
2024-02-28 03:54:20 +00:00
|
|
|
function getPixelColor (
|
|
|
|
image: Electron.NativeImage,
|
|
|
|
point: Electron.Point
|
|
|
|
): string {
|
2022-03-23 00:14:49 +00:00
|
|
|
// image.crop crashes if point is fractional, so round to prevent that crash
|
2024-02-28 03:54:20 +00:00
|
|
|
const pixel = image.crop({
|
|
|
|
x: Math.round(point.x),
|
|
|
|
y: Math.round(point.y),
|
|
|
|
width: 1,
|
|
|
|
height: 1
|
|
|
|
});
|
2022-03-01 22:14:11 +00:00
|
|
|
// 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)}`;
|
2024-02-28 03:54:20 +00:00
|
|
|
}
|
2022-03-01 22:14:11 +00:00
|
|
|
|
2024-08-22 17:30:24 +00:00
|
|
|
/** Calculate euclidean distance between colors. */
|
2024-02-28 03:54:20 +00:00
|
|
|
function colorDistance (hexColorA: string, hexColorB: string): number {
|
2022-03-01 22:14:11 +00:00
|
|
|
const colorA = hexToRgba(hexColorA);
|
|
|
|
const colorB = hexToRgba(hexColorB);
|
|
|
|
if (!colorA || !colorB) return -1;
|
|
|
|
return Math.sqrt(
|
|
|
|
Math.pow(colorB[0] - colorA[0], 2) +
|
2024-02-28 03:54:20 +00:00
|
|
|
Math.pow(colorB[1] - colorA[1], 2) +
|
|
|
|
Math.pow(colorB[2] - colorA[2], 2)
|
2022-03-01 22:14:11 +00:00
|
|
|
);
|
2024-02-28 03:54:20 +00:00
|
|
|
}
|
2022-03-01 22:14:11 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Determine if colors are similar based on distance. This can be useful when
|
|
|
|
* comparing colors which may differ based on lossy compression.
|
|
|
|
*/
|
2024-02-28 03:54:20 +00:00
|
|
|
function areColorsSimilar (
|
2022-03-01 22:14:11 +00:00
|
|
|
hexColorA: string,
|
|
|
|
hexColorB: string,
|
|
|
|
distanceThreshold = 90
|
2024-02-28 03:54:20 +00:00
|
|
|
): boolean {
|
2022-03-01 22:14:11 +00:00
|
|
|
const distance = colorDistance(hexColorA, hexColorB);
|
|
|
|
return distance <= distanceThreshold;
|
2024-02-28 03:54:20 +00:00
|
|
|
}
|
|
|
|
|
2024-07-17 00:16:25 +00:00
|
|
|
function displayCenter (display: Electron.Display): Electron.Point {
|
2024-02-28 03:54:20 +00:00
|
|
|
return {
|
2024-07-17 00:16:25 +00:00
|
|
|
x: display.size.width / 2,
|
|
|
|
y: display.size.height / 2
|
2024-02-28 03:54:20 +00:00
|
|
|
};
|
|
|
|
}
|
2024-07-17 00:16:25 +00:00
|
|
|
|
|
|
|
/** Resolve when approx. one frame has passed (30FPS) */
|
|
|
|
export async function nextFrameTime (): Promise<void> {
|
|
|
|
return await new Promise((resolve) => {
|
|
|
|
setTimeout(resolve, 1000 / 30);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2024-02-28 03:54:20 +00:00
|
|
|
/**
|
|
|
|
* Utilities for creating and inspecting a screen capture.
|
|
|
|
*
|
2024-07-17 00:16:25 +00:00
|
|
|
* Set `PAUSE_CAPTURE_TESTS` env var to briefly pause during screen
|
|
|
|
* capture for easier inspection.
|
|
|
|
*
|
2024-02-28 03:54:20 +00:00
|
|
|
* NOTE: Not yet supported on Linux in CI due to empty sources list.
|
|
|
|
*/
|
|
|
|
export class ScreenCapture {
|
2024-07-17 00:16:25 +00:00
|
|
|
/** Timeout to wait for expected color to match. */
|
|
|
|
static TIMEOUT = 3000;
|
2024-02-28 03:54:20 +00:00
|
|
|
|
2024-07-17 00:16:25 +00:00
|
|
|
constructor (display?: Electron.Display) {
|
|
|
|
this.display = display || screen.getPrimaryDisplay();
|
2024-02-28 03:54:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public async expectColorAtCenterMatches (hexColor: string) {
|
2024-07-17 00:16:25 +00:00
|
|
|
return this._expectImpl(displayCenter(this.display), hexColor, true);
|
2024-02-28 03:54:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public async expectColorAtCenterDoesNotMatch (hexColor: string) {
|
2024-07-17 00:16:25 +00:00
|
|
|
return this._expectImpl(displayCenter(this.display), hexColor, false);
|
2024-02-28 03:54:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public async expectColorAtPointOnDisplayMatches (
|
|
|
|
hexColor: string,
|
|
|
|
findPoint: (displaySize: Electron.Size) => Electron.Point
|
|
|
|
) {
|
2024-07-17 00:16:25 +00:00
|
|
|
return this._expectImpl(findPoint(this.display.size), hexColor, true);
|
2024-02-28 03:54:20 +00:00
|
|
|
}
|
|
|
|
|
2024-07-17 00:16:25 +00:00
|
|
|
private async captureFrame (): Promise<NativeImage> {
|
2024-02-28 03:54:20 +00:00
|
|
|
const sources = await desktopCapturer.getSources({
|
|
|
|
types: ['screen'],
|
2024-07-17 00:16:25 +00:00
|
|
|
thumbnailSize: this.display.size
|
2024-02-28 03:54:20 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
const captureSource = sources.find(
|
2024-07-17 00:16:25 +00:00
|
|
|
(source) => source.display_id === this.display.id.toString()
|
2024-02-28 03:54:20 +00:00
|
|
|
);
|
|
|
|
if (captureSource === undefined) {
|
|
|
|
const displayIds = sources.map((source) => source.display_id).join(', ');
|
|
|
|
throw new Error(
|
2024-07-17 00:16:25 +00:00
|
|
|
`Unable to find screen capture for display '${this.display.id}'\n\tAvailable displays: ${displayIds}`
|
2024-02-28 03:54:20 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2024-07-17 00:16:25 +00:00
|
|
|
if (process.env.PAUSE_CAPTURE_TESTS) {
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
|
|
}
|
|
|
|
|
|
|
|
return captureSource.thumbnail;
|
2024-02-28 03:54:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private async _expectImpl (
|
|
|
|
point: Electron.Point,
|
|
|
|
expectedColor: string,
|
|
|
|
matchIsExpected: boolean
|
|
|
|
) {
|
2024-07-17 00:16:25 +00:00
|
|
|
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);
|
2024-02-28 03:54:20 +00:00
|
|
|
|
|
|
|
if (!gotExpectedResult) {
|
2024-07-17 05:56:56 +00:00
|
|
|
// 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 });
|
|
|
|
}
|
|
|
|
|
2024-02-28 03:54:20 +00:00
|
|
|
// Save the image as an artifact for better debugging
|
|
|
|
const artifactName = await createArtifactWithRandomId(
|
|
|
|
(id) => `color-mismatch-${id}.png`,
|
2024-07-17 00:16:25 +00:00
|
|
|
frame.toPNG()
|
2024-02-28 03:54:20 +00:00
|
|
|
);
|
2024-07-17 05:56:56 +00:00
|
|
|
|
2024-02-28 03:54:20 +00:00
|
|
|
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.`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-07-17 00:16:25 +00:00
|
|
|
private display: Electron.Display;
|
2024-02-28 03:54:20 +00:00
|
|
|
}
|
2024-01-31 09:29:17 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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
|
2024-03-08 00:17:39 +00:00
|
|
|
* - Win32 x64: virtual screen display is 0x0
|
2024-01-31 09:29:17 +00:00
|
|
|
*/
|
|
|
|
export const hasCapturableScreen = () => {
|
2024-07-17 00:16:25 +00:00
|
|
|
return process.env.CI ? process.platform === 'darwin' : true;
|
2024-01-31 09:29:17 +00:00
|
|
|
};
|