|
@@ -1,96 +1,168 @@
|
|
|
-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;
|
|
|
-};
|
|
|
+function hexToRgba (
|
|
|
+ hexColor: string
|
|
|
+): [number, number, number, number] | undefined {
|
|
|
+ const match = hexColor.match(/^#([0-9a-fA-F]{6,8})$/);
|
|
|
+ if (!match) return;
|
|
|
|
|
|
-const formatHexByte = (val: number): string => {
|
|
|
+ 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.
|
|
|
*/
|
|
|
-export const getPixelColor = (image: Electron.NativeImage, point: Electron.Point): string => {
|
|
|
+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 });
|
|
|
+ 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) => {
|
|
|
- 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
|
|
|
- ];
|
|
|
-};
|
|
|
+}
|
|
|
|
|
|
/** 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')
|
|
|
+ );
|
|
|
};
|