screen-helpers.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. import { screen, desktopCapturer, NativeImage } from 'electron';
  2. import { AssertionError } from 'chai';
  3. import { createArtifactWithRandomId } from './artifacts';
  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 euclidean 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 displayCenter (display: Electron.Display): Electron.Point {
  71. return {
  72. x: display.size.width / 2,
  73. y: display.size.height / 2
  74. };
  75. }
  76. /** Resolve when approx. one frame has passed (30FPS) */
  77. export async function nextFrameTime (): Promise<void> {
  78. return await new Promise((resolve) => {
  79. setTimeout(resolve, 1000 / 30);
  80. });
  81. }
  82. /**
  83. * Utilities for creating and inspecting a screen capture.
  84. *
  85. * Set `PAUSE_CAPTURE_TESTS` env var to briefly pause during screen
  86. * capture for easier inspection.
  87. *
  88. * NOTE: Not yet supported on Linux in CI due to empty sources list.
  89. */
  90. export class ScreenCapture {
  91. /** Timeout to wait for expected color to match. */
  92. static TIMEOUT = 3000;
  93. constructor (display?: Electron.Display) {
  94. this.display = display || screen.getPrimaryDisplay();
  95. }
  96. public async expectColorAtCenterMatches (hexColor: string) {
  97. return this._expectImpl(displayCenter(this.display), hexColor, true);
  98. }
  99. public async expectColorAtCenterDoesNotMatch (hexColor: string) {
  100. return this._expectImpl(displayCenter(this.display), hexColor, false);
  101. }
  102. public async expectColorAtPointOnDisplayMatches (
  103. hexColor: string,
  104. findPoint: (displaySize: Electron.Size) => Electron.Point
  105. ) {
  106. return this._expectImpl(findPoint(this.display.size), hexColor, true);
  107. }
  108. public async takeScreenshot (filePrefix: string) {
  109. const frame = await this.captureFrame();
  110. return await createArtifactWithRandomId(
  111. (id) => `${filePrefix}-${id}.png`,
  112. frame.toPNG()
  113. );
  114. }
  115. private async captureFrame (): Promise<NativeImage> {
  116. const sources = await desktopCapturer.getSources({
  117. types: ['screen'],
  118. thumbnailSize: this.display.size
  119. });
  120. const captureSource = sources.find(
  121. (source) => source.display_id === this.display.id.toString()
  122. );
  123. if (captureSource === undefined) {
  124. const displayIds = sources.map((source) => source.display_id).join(', ');
  125. throw new Error(
  126. `Unable to find screen capture for display '${this.display.id}'\n\tAvailable displays: ${displayIds}`
  127. );
  128. }
  129. if (process.env.PAUSE_CAPTURE_TESTS) {
  130. await new Promise((resolve) => setTimeout(resolve, 1e3));
  131. }
  132. return captureSource.thumbnail;
  133. }
  134. private async _expectImpl (
  135. point: Electron.Point,
  136. expectedColor: string,
  137. matchIsExpected: boolean
  138. ) {
  139. let frame: Electron.NativeImage;
  140. let actualColor: string;
  141. let gotExpectedResult: boolean = false;
  142. const expiration = Date.now() + ScreenCapture.TIMEOUT;
  143. // Continuously capture frames until we either see the expected result or
  144. // reach a timeout. This helps avoid flaky tests in which a short waiting
  145. // period is required for the expected result.
  146. do {
  147. frame = await this.captureFrame();
  148. actualColor = getPixelColor(frame, point);
  149. const colorsMatch = areColorsSimilar(expectedColor, actualColor);
  150. gotExpectedResult = matchIsExpected ? colorsMatch : !colorsMatch;
  151. if (gotExpectedResult) break;
  152. await nextFrameTime(); // limit framerate
  153. } while (Date.now() < expiration);
  154. if (!gotExpectedResult) {
  155. // Limit image to 720p to save on storage space
  156. if (process.env.CI) {
  157. const width = Math.floor(Math.min(frame.getSize().width, 720));
  158. frame = frame.resize({ width });
  159. }
  160. // Save the image as an artifact for better debugging
  161. const artifactName = await createArtifactWithRandomId(
  162. (id) => `color-mismatch-${id}.png`,
  163. frame.toPNG()
  164. );
  165. throw new AssertionError(
  166. `Expected color at (${point.x}, ${point.y}) to ${
  167. matchIsExpected ? 'match' : '*not* match'
  168. } '${expectedColor}', but got '${actualColor}'. See the artifact '${artifactName}' for more information.`
  169. );
  170. }
  171. }
  172. private display: Electron.Display;
  173. }
  174. /**
  175. * Whether the current VM has a valid screen which can be used to capture.
  176. *
  177. * This is specific to Electron's CI test runners.
  178. * - Linux: virtual screen display is 0x0
  179. * - Win32 arm64 (WOA): virtual screen display is 0x0
  180. * - Win32 ia32: skipped
  181. * - Win32 x64: virtual screen display is 0x0
  182. */
  183. export const hasCapturableScreen = () => {
  184. return process.env.CI ? process.platform === 'darwin' : true;
  185. };