screen-helpers.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. import { screen, desktopCapturer, NativeImage } from 'electron';
  2. import { createArtifactWithRandomId } from './artifacts';
  3. import { AssertionError } from 'chai';
  4. export enum HexColors {
  5. GREEN = '#00b140',
  6. PURPLE = '#6a0dad',
  7. RED = '#ff0000',
  8. BLUE = '#0000ff',
  9. WHITE = '#ffffff',
  10. }
  11. function hexToRgba (
  12. hexColor: string
  13. ): [number, number, number, number] | undefined {
  14. const match = hexColor.match(/^#([0-9a-fA-F]{6,8})$/);
  15. if (!match) return;
  16. const colorStr = match[1];
  17. return [
  18. parseInt(colorStr.substring(0, 2), 16),
  19. parseInt(colorStr.substring(2, 4), 16),
  20. parseInt(colorStr.substring(4, 6), 16),
  21. parseInt(colorStr.substring(6, 8), 16) || 0xff
  22. ];
  23. }
  24. function formatHexByte (val: number): string {
  25. const str = val.toString(16);
  26. return str.length === 2 ? str : `0${str}`;
  27. }
  28. /**
  29. * Get the hex color at the given pixel coordinate in an image.
  30. */
  31. function getPixelColor (
  32. image: Electron.NativeImage,
  33. point: Electron.Point
  34. ): string {
  35. // image.crop crashes if point is fractional, so round to prevent that crash
  36. const pixel = image.crop({
  37. x: Math.round(point.x),
  38. y: Math.round(point.y),
  39. width: 1,
  40. height: 1
  41. });
  42. // TODO(samuelmaddock): NativeImage.toBitmap() should return the raw pixel
  43. // color, but it sometimes differs. Why is that?
  44. const [b, g, r] = pixel.toBitmap();
  45. return `#${formatHexByte(r)}${formatHexByte(g)}${formatHexByte(b)}`;
  46. }
  47. /** Calculate euclidian distance between colors. */
  48. function colorDistance (hexColorA: string, hexColorB: string): number {
  49. const colorA = hexToRgba(hexColorA);
  50. const colorB = hexToRgba(hexColorB);
  51. if (!colorA || !colorB) return -1;
  52. return Math.sqrt(
  53. Math.pow(colorB[0] - colorA[0], 2) +
  54. Math.pow(colorB[1] - colorA[1], 2) +
  55. Math.pow(colorB[2] - colorA[2], 2)
  56. );
  57. }
  58. /**
  59. * Determine if colors are similar based on distance. This can be useful when
  60. * comparing colors which may differ based on lossy compression.
  61. */
  62. function areColorsSimilar (
  63. hexColorA: string,
  64. hexColorB: string,
  65. distanceThreshold = 90
  66. ): boolean {
  67. const distance = colorDistance(hexColorA, hexColorB);
  68. return distance <= distanceThreshold;
  69. }
  70. function imageCenter (image: NativeImage): Electron.Point {
  71. const size = image.getSize();
  72. return {
  73. x: size.width / 2,
  74. y: size.height / 2
  75. };
  76. }
  77. /**
  78. * Utilities for creating and inspecting a screen capture.
  79. *
  80. * NOTE: Not yet supported on Linux in CI due to empty sources list.
  81. */
  82. export class ScreenCapture {
  83. /** Use the async constructor `ScreenCapture.create()` instead. */
  84. private constructor (image: NativeImage) {
  85. this.image = image;
  86. }
  87. public static async create (): Promise<ScreenCapture> {
  88. const display = screen.getPrimaryDisplay();
  89. return ScreenCapture._createImpl(display);
  90. }
  91. public static async createForDisplay (
  92. display: Electron.Display
  93. ): Promise<ScreenCapture> {
  94. return ScreenCapture._createImpl(display);
  95. }
  96. public async expectColorAtCenterMatches (hexColor: string) {
  97. return this._expectImpl(imageCenter(this.image), hexColor, true);
  98. }
  99. public async expectColorAtCenterDoesNotMatch (hexColor: string) {
  100. return this._expectImpl(imageCenter(this.image), hexColor, false);
  101. }
  102. public async expectColorAtPointOnDisplayMatches (
  103. hexColor: string,
  104. findPoint: (displaySize: Electron.Size) => Electron.Point
  105. ) {
  106. return this._expectImpl(findPoint(this.image.getSize()), hexColor, true);
  107. }
  108. private static async _createImpl (display: Electron.Display) {
  109. const sources = await desktopCapturer.getSources({
  110. types: ['screen'],
  111. thumbnailSize: display.size
  112. });
  113. const captureSource = sources.find(
  114. (source) => source.display_id === display.id.toString()
  115. );
  116. if (captureSource === undefined) {
  117. const displayIds = sources.map((source) => source.display_id).join(', ');
  118. throw new Error(
  119. `Unable to find screen capture for display '${display.id}'\n\tAvailable displays: ${displayIds}`
  120. );
  121. }
  122. return new ScreenCapture(captureSource.thumbnail);
  123. }
  124. private async _expectImpl (
  125. point: Electron.Point,
  126. expectedColor: string,
  127. matchIsExpected: boolean
  128. ) {
  129. const actualColor = getPixelColor(this.image, point);
  130. const colorsMatch = areColorsSimilar(expectedColor, actualColor);
  131. const gotExpectedResult = matchIsExpected ? colorsMatch : !colorsMatch;
  132. if (!gotExpectedResult) {
  133. // Save the image as an artifact for better debugging
  134. const artifactName = await createArtifactWithRandomId(
  135. (id) => `color-mismatch-${id}.png`,
  136. this.image.toPNG()
  137. );
  138. throw new AssertionError(
  139. `Expected color at (${point.x}, ${point.y}) to ${
  140. matchIsExpected ? 'match' : '*not* match'
  141. } '${expectedColor}', but got '${actualColor}'. See the artifact '${artifactName}' for more information.`
  142. );
  143. }
  144. }
  145. private image: NativeImage;
  146. }
  147. /**
  148. * Whether the current VM has a valid screen which can be used to capture.
  149. *
  150. * This is specific to Electron's CI test runners.
  151. * - Linux: virtual screen display is 0x0
  152. * - Win32 arm64 (WOA): virtual screen display is 0x0
  153. * - Win32 ia32: skipped
  154. * - Win32 x64: virtual screen display is 0x0
  155. */
  156. export const hasCapturableScreen = () => {
  157. return process.platform === 'darwin';
  158. };