Browse Source

feat: customize border radius of Views (#42320)

* feat: add View#setBorderRadius

test: initial setBorderRadius tests

fix: robustly set border radius

chore: add PAUSE_CAPTURE_TESTS for easier screencap dev

feat: add view border radius support

test: view border radius

refactor: cleanup view code

* maybe delay capture to fix tests?

* refactor: retry screen captures in an attempt to fix flakiness

* refactor: ScreenCapture constructor no longer async

* increase screen capture timeout, feels a little short

* refactor: move rounded rect util into chromium_src

* skip some capture tests on mas
Sam Maddock 9 months ago
parent
commit
778d3098a0

+ 2 - 0
chromium_src/BUILD.gn

@@ -14,6 +14,8 @@ import("//third_party/widevine/cdm/widevine.gni")
 static_library("chrome") {
   visibility = [ "//electron:electron_lib" ]
   sources = [
+    "//ash/style/rounded_rect_cutout_path_builder.cc",
+    "//ash/style/rounded_rect_cutout_path_builder.h",
     "//chrome/browser/app_mode/app_mode_utils.cc",
     "//chrome/browser/app_mode/app_mode_utils.h",
     "//chrome/browser/browser_features.cc",

+ 6 - 0
docs/api/view.md

@@ -94,6 +94,12 @@ Examples of valid `color` values:
 
 **Note:** Hex format with alpha takes `AARRGGBB` or `ARGB`, _not_ `RRGGBBAA` or `RGB`.
 
+#### `view.setBorderRadius(radius)`
+
+* `radius` Integer - Border radius size in pixels.
+
+**Note:** The area cutout of the view's border still captures clicks.
+
 #### `view.setVisible(visible)`
 
 * `visible` boolean - If false, the view will be hidden from display.

+ 35 - 0
shell/browser/api/electron_api_view.cc

@@ -10,6 +10,7 @@
 #include <string>
 #include <utility>
 
+#include "ash/style/rounded_rect_cutout_path_builder.h"
 #include "gin/data_object_builder.h"
 #include "gin/wrappable.h"
 #include "shell/browser/javascript_environment.h"
@@ -338,6 +339,38 @@ void View::SetBackgroundColor(std::optional<WrappedSkColor> color) {
   view_->SetBackground(color ? views::CreateSolidBackground(*color) : nullptr);
 }
 
+void View::SetBorderRadius(int radius) {
+  border_radius_ = radius;
+  ApplyBorderRadius();
+}
+
+void View::ApplyBorderRadius() {
+  if (!border_radius_.has_value() || !view_)
+    return;
+
+  auto size = view_->bounds().size();
+
+  // Restrict border radius to the constraints set in the path builder class.
+  // If the constraints are exceeded, the builder will crash.
+  int radius;
+  {
+    float r = border_radius_.value() * 1.f;
+    r = std::min(r, size.width() / 2.f);
+    r = std::min(r, size.height() / 2.f);
+    r = std::max(r, 0.f);
+    radius = std::floor(r);
+  }
+
+  // RoundedRectCutoutPathBuilder has a minimum size of 32 x 32.
+  if (radius > 0 && size.width() >= 32 && size.height() >= 32) {
+    auto builder = ash::RoundedRectCutoutPathBuilder(gfx::SizeF(size));
+    builder.CornerRadius(radius);
+    view_->SetClipPath(builder.Build());
+  } else {
+    view_->SetClipPath(SkPath());
+  }
+}
+
 void View::SetVisible(bool visible) {
   if (!view_)
     return;
@@ -345,6 +378,7 @@ void View::SetVisible(bool visible) {
 }
 
 void View::OnViewBoundsChanged(views::View* observed_view) {
+  ApplyBorderRadius();
   Emit("bounds-changed");
 }
 
@@ -393,6 +427,7 @@ void View::BuildPrototype(v8::Isolate* isolate,
       .SetMethod("setBounds", &View::SetBounds)
       .SetMethod("getBounds", &View::GetBounds)
       .SetMethod("setBackgroundColor", &View::SetBackgroundColor)
+      .SetMethod("setBorderRadius", &View::SetBorderRadius)
       .SetMethod("setLayout", &View::SetLayout)
       .SetMethod("setVisible", &View::SetVisible);
 }

+ 4 - 0
shell/browser/api/electron_api_view.h

@@ -37,6 +37,7 @@ class View : public gin_helper::EventEmitter<View>,
   void SetLayout(v8::Isolate* isolate, v8::Local<v8::Object> value);
   std::vector<v8::Local<v8::Value>> GetChildren();
   void SetBackgroundColor(std::optional<WrappedSkColor> color);
+  void SetBorderRadius(int radius);
   void SetVisible(bool visible);
 
   // views::ViewObserver
@@ -44,6 +45,7 @@ class View : public gin_helper::EventEmitter<View>,
   void OnViewIsDeleting(views::View* observed_view) override;
 
   views::View* view() const { return view_; }
+  std::optional<int> border_radius() const { return border_radius_; }
 
   // disable copy
   View(const View&) = delete;
@@ -58,9 +60,11 @@ class View : public gin_helper::EventEmitter<View>,
   void set_delete_view(bool should) { delete_view_ = should; }
 
  private:
+  void ApplyBorderRadius();
   void ReorderChildView(gin::Handle<View> child, size_t index);
 
   std::vector<v8::Global<v8::Object>> child_views_;
+  std::optional<int> border_radius_;
 
   bool delete_view_ = true;
   raw_ptr<views::View> view_ = nullptr;

+ 23 - 0
shell/browser/api/electron_api_web_contents_view.cc

@@ -20,6 +20,8 @@
 #include "shell/common/options_switches.h"
 #include "third_party/skia/include/core/SkRegion.h"
 #include "ui/base/hit_test.h"
+#include "ui/gfx/geometry/rounded_corners_f.h"
+#include "ui/views/controls/webview/webview.h"
 #include "ui/views/layout/flex_layout_types.h"
 #include "ui/views/view_class_properties.h"
 #include "ui/views/widget/widget.h"
@@ -65,6 +67,25 @@ void WebContentsView::SetBackgroundColor(std::optional<WrappedSkColor> color) {
   }
 }
 
+void WebContentsView::SetBorderRadius(int radius) {
+  View::SetBorderRadius(radius);
+  ApplyBorderRadius();
+}
+
+void WebContentsView::ApplyBorderRadius() {
+  if (border_radius().has_value() && api_web_contents_ && view()->GetWidget()) {
+    auto* web_view = api_web_contents_->inspectable_web_contents()
+                         ->GetView()
+                         ->contents_web_view();
+
+    // WebView won't exist for offscreen rendering.
+    if (web_view) {
+      web_view->holder()->SetCornerRadii(
+          gfx::RoundedCornersF(border_radius().value()));
+    }
+  }
+}
+
 int WebContentsView::NonClientHitTest(const gfx::Point& point) {
   if (api_web_contents_) {
     gfx::Point local_point(point);
@@ -93,6 +114,7 @@ void WebContentsView::OnViewAddedToWidget(views::View* observed_view) {
   // because that's handled in the WebContents dtor called prior.
   api_web_contents_->SetOwnerWindow(native_window);
   native_window->AddDraggableRegionProvider(this);
+  ApplyBorderRadius();
 }
 
 void WebContentsView::OnViewRemovedFromWidget(views::View* observed_view) {
@@ -198,6 +220,7 @@ void WebContentsView::BuildPrototype(
   prototype->SetClassName(gin::StringToV8(isolate, "WebContentsView"));
   gin_helper::ObjectTemplateBuilder(isolate, prototype->PrototypeTemplate())
       .SetMethod("setBackgroundColor", &WebContentsView::SetBackgroundColor)
+      .SetMethod("setBorderRadius", &WebContentsView::SetBorderRadius)
       .SetProperty("webContents", &WebContentsView::GetWebContents);
 }
 

+ 3 - 0
shell/browser/api/electron_api_web_contents_view.h

@@ -39,6 +39,7 @@ class WebContentsView : public View,
   // Public APIs.
   gin::Handle<WebContents> GetWebContents(v8::Isolate* isolate);
   void SetBackgroundColor(std::optional<WrappedSkColor> color);
+  void SetBorderRadius(int radius);
 
   int NonClientHitTest(const gfx::Point& point) override;
 
@@ -57,6 +58,8 @@ class WebContentsView : public View,
  private:
   static gin_helper::WrappableBase* New(gin_helper::Arguments* args);
 
+  void ApplyBorderRadius();
+
   // Keep a reference to v8 wrapper.
   v8::Global<v8::Value> web_contents_;
   raw_ptr<api::WebContents> api_web_contents_;

+ 5 - 5
shell/browser/ui/inspectable_web_contents_view.cc

@@ -84,14 +84,14 @@ InspectableWebContentsView::InspectableWebContentsView(
     auto* contents_web_view = new views::WebView(nullptr);
     contents_web_view->SetWebContents(
         inspectable_web_contents_->GetWebContents());
-    contents_web_view_ = contents_web_view;
+    contents_view_ = contents_web_view_ = contents_web_view;
   } else {
-    contents_web_view_ = new views::Label(u"No content under offscreen mode");
+    contents_view_ = new views::Label(u"No content under offscreen mode");
   }
 
   devtools_web_view_->SetVisible(false);
   AddChildView(devtools_web_view_.get());
-  AddChildView(contents_web_view_.get());
+  AddChildView(contents_view_.get());
 }
 
 InspectableWebContentsView::~InspectableWebContentsView() {
@@ -209,7 +209,7 @@ const std::u16string InspectableWebContentsView::GetTitle() {
 
 void InspectableWebContentsView::Layout(PassKey) {
   if (!devtools_web_view_->GetVisible()) {
-    contents_web_view_->SetBoundsRect(GetContentsBounds());
+    contents_view_->SetBoundsRect(GetContentsBounds());
     // Propagate layout call to all children, for example browser views.
     LayoutSuperclass<View>(this);
     return;
@@ -227,7 +227,7 @@ void InspectableWebContentsView::Layout(PassKey) {
   new_contents_bounds.set_x(GetMirroredXForRect(new_contents_bounds));
 
   devtools_web_view_->SetBoundsRect(new_devtools_bounds);
-  contents_web_view_->SetBoundsRect(new_contents_bounds);
+  contents_view_->SetBoundsRect(new_contents_bounds);
 
   // Propagate layout call to all children, for example browser views.
   LayoutSuperclass<View>(this);

+ 4 - 1
shell/browser/ui/inspectable_web_contents_view.h

@@ -36,6 +36,8 @@ class InspectableWebContentsView : public views::View {
     return inspectable_web_contents_;
   }
 
+  views::WebView* contents_web_view() const { return contents_web_view_; }
+
   // The delegate manages its own life.
   void SetDelegate(InspectableWebContentsViewDelegate* delegate) {
     delegate_ = delegate;
@@ -67,8 +69,9 @@ class InspectableWebContentsView : public views::View {
 
   std::unique_ptr<views::Widget> devtools_window_;
   raw_ptr<views::WebView> devtools_window_web_view_ = nullptr;
-  raw_ptr<views::View> contents_web_view_ = nullptr;
   raw_ptr<views::WebView> devtools_web_view_ = nullptr;
+  raw_ptr<views::WebView> contents_web_view_ = nullptr;
+  raw_ptr<views::View> contents_view_ = nullptr;
 
   DevToolsContentsResizingStrategy strategy_;
   bool devtools_visible_ = false;

+ 5 - 7
spec/api-browser-view-spec.ts

@@ -3,7 +3,7 @@ import * as path from 'node:path';
 import { BrowserView, BrowserWindow, screen, webContents } from 'electron/main';
 import { closeWindow } from './lib/window-helpers';
 import { defer, ifit, startRemoteControlApp } from './lib/spec-helpers';
-import { ScreenCapture } from './lib/screen-helpers';
+import { ScreenCapture, hasCapturableScreen } from './lib/screen-helpers';
 import { once } from 'node:events';
 
 describe('BrowserView module', () => {
@@ -75,8 +75,7 @@ describe('BrowserView module', () => {
       }).not.to.throw();
     });
 
-    // Linux and arm64 platforms (WOA and macOS) do not return any capture sources
-    ifit(process.platform === 'darwin' && process.arch === 'x64')('sets the background color to transparent if none is set', async () => {
+    ifit(hasCapturableScreen())('sets the background color to transparent if none is set', async () => {
       const display = screen.getPrimaryDisplay();
       const WINDOW_BACKGROUND_COLOR = '#55ccbb';
 
@@ -90,12 +89,11 @@ describe('BrowserView module', () => {
       w.setBrowserView(view);
       await view.webContents.loadURL('data:text/html,hello there');
 
-      const screenCapture = await ScreenCapture.createForDisplay(display);
+      const screenCapture = new ScreenCapture(display);
       await screenCapture.expectColorAtCenterMatches(WINDOW_BACKGROUND_COLOR);
     });
 
-    // Linux and arm64 platforms (WOA and macOS) do not return any capture sources
-    ifit(process.platform === 'darwin' && process.arch === 'x64')('successfully applies the background color', async () => {
+    ifit(hasCapturableScreen())('successfully applies the background color', async () => {
       const WINDOW_BACKGROUND_COLOR = '#55ccbb';
       const VIEW_BACKGROUND_COLOR = '#ff00ff';
       const display = screen.getPrimaryDisplay();
@@ -111,7 +109,7 @@ describe('BrowserView module', () => {
       w.setBackgroundColor(VIEW_BACKGROUND_COLOR);
       await view.webContents.loadURL('data:text/html,hello there');
 
-      const screenCapture = await ScreenCapture.createForDisplay(display);
+      const screenCapture = new ScreenCapture(display);
       await screenCapture.expectColorAtCenterMatches(VIEW_BACKGROUND_COLOR);
     });
   });

+ 10 - 19
spec/api-browser-window-spec.ts

@@ -6510,8 +6510,8 @@ describe('BrowserWindow module', () => {
       expect(w.getBounds()).to.deep.equal(newBounds);
     });
 
-    // FIXME(codebytere): figure out why these are failing on macOS arm64.
-    ifit(process.platform === 'darwin' && process.arch !== 'arm64')('should not display a visible background', async () => {
+    // FIXME(codebytere): figure out why these are failing on MAS arm64.
+    ifit(hasCapturableScreen() && !(process.mas && process.arch === 'arm64'))('should not display a visible background', async () => {
       const display = screen.getPrimaryDisplay();
 
       const backgroundWindow = new BrowserWindow({
@@ -6534,9 +6534,7 @@ describe('BrowserWindow module', () => {
       const colorFile = path.join(__dirname, 'fixtures', 'pages', 'half-background-color.html');
       await foregroundWindow.loadFile(colorFile);
 
-      await setTimeout(1000);
-
-      const screenCapture = await ScreenCapture.createForDisplay(display);
+      const screenCapture = new ScreenCapture(display);
       await screenCapture.expectColorAtPointOnDisplayMatches(
         HexColors.GREEN,
         (size) => ({
@@ -6553,8 +6551,8 @@ describe('BrowserWindow module', () => {
       );
     });
 
-    // FIXME(codebytere): figure out why these are failing on macOS arm64.
-    ifit(process.platform === 'darwin' && process.arch !== 'arm64')('Allows setting a transparent window via CSS', async () => {
+    // FIXME(codebytere): figure out why these are failing on MAS arm64.
+    ifit(hasCapturableScreen() && !(process.mas && process.arch === 'arm64'))('Allows setting a transparent window via CSS', async () => {
       const display = screen.getPrimaryDisplay();
 
       const backgroundWindow = new BrowserWindow({
@@ -6580,14 +6578,11 @@ describe('BrowserWindow module', () => {
       foregroundWindow.loadFile(path.join(__dirname, 'fixtures', 'pages', 'css-transparent.html'));
       await once(ipcMain, 'set-transparent');
 
-      await setTimeout(1000);
-
-      const screenCapture = await ScreenCapture.createForDisplay(display);
+      const screenCapture = new ScreenCapture(display);
       await screenCapture.expectColorAtCenterMatches(HexColors.PURPLE);
     });
 
-    // Linux and arm64 platforms (WOA and macOS) do not return any capture sources
-    ifit(process.platform === 'darwin' && process.arch === 'x64')('should not make background transparent if falsy', async () => {
+    ifit(hasCapturableScreen())('should not make background transparent if falsy', async () => {
       const display = screen.getPrimaryDisplay();
 
       for (const transparent of [false, undefined]) {
@@ -6599,8 +6594,7 @@ describe('BrowserWindow module', () => {
         await once(window, 'show');
         await window.webContents.loadURL('data:text/html,<head><meta name="color-scheme" content="dark"></head>');
 
-        await setTimeout(1000);
-        const screenCapture = await ScreenCapture.createForDisplay(display);
+        const screenCapture = new ScreenCapture(display);
         // color-scheme is set to dark so background should not be white
         await screenCapture.expectColorAtCenterDoesNotMatch(HexColors.WHITE);
 
@@ -6612,8 +6606,7 @@ describe('BrowserWindow module', () => {
   describe('"backgroundColor" option', () => {
     afterEach(closeAllWindows);
 
-    // Linux/WOA doesn't return any capture sources.
-    ifit(process.platform === 'darwin')('should display the set color', async () => {
+    ifit(hasCapturableScreen())('should display the set color', async () => {
       const display = screen.getPrimaryDisplay();
 
       const w = new BrowserWindow({
@@ -6625,9 +6618,7 @@ describe('BrowserWindow module', () => {
       w.loadURL('about:blank');
       await once(w, 'ready-to-show');
 
-      await setTimeout(1000);
-
-      const screenCapture = await ScreenCapture.createForDisplay(display);
+      const screenCapture = new ScreenCapture(display);
       await screenCapture.expectColorAtCenterMatches(HexColors.BLUE);
     });
   });

+ 11 - 0
spec/api-view-spec.ts

@@ -54,4 +54,15 @@ describe('View', () => {
     w.contentView.addChildView(v2);
     expect(w.contentView.children).to.deep.equal([v3, v1, v2]);
   });
+
+  it('allows setting various border radius values', () => {
+    w = new BaseWindow({ show: false });
+    const v = new View();
+    w.setContentView(v);
+    v.setBorderRadius(10);
+    v.setBorderRadius(0);
+    v.setBorderRadius(-10);
+    v.setBorderRadius(9999999);
+    v.setBorderRadius(-9999999);
+  });
 });

+ 93 - 5
spec/api-web-contents-view-spec.ts

@@ -1,10 +1,10 @@
-import { closeAllWindows } from './lib/window-helpers';
 import { expect } from 'chai';
-
-import { BaseWindow, View, WebContentsView, webContents } from 'electron/main';
+import { BaseWindow, BrowserWindow, View, WebContentsView, webContents, screen } from 'electron/main';
 import { once } from 'node:events';
-import { defer } from './lib/spec-helpers';
-import { BrowserWindow } from 'electron';
+
+import { closeAllWindows } from './lib/window-helpers';
+import { defer, ifdescribe } from './lib/spec-helpers';
+import { HexColors, ScreenCapture, hasCapturableScreen, nextFrameTime } from './lib/screen-helpers';
 
 describe('WebContentsView', () => {
   afterEach(closeAllWindows);
@@ -224,4 +224,92 @@ describe('WebContentsView', () => {
       expect(visibilityState).to.equal('visible');
     });
   });
+
+  describe('setBorderRadius', () => {
+    ifdescribe(hasCapturableScreen())('capture', () => {
+      let w: Electron.BaseWindow;
+      let v: Electron.WebContentsView;
+      let display: Electron.Display;
+      let corners: Electron.Point[];
+
+      const backgroundUrl = `data:text/html,<style>html{background:${encodeURIComponent(HexColors.GREEN)}}</style>`;
+
+      beforeEach(async () => {
+        display = screen.getPrimaryDisplay();
+
+        w = new BaseWindow({
+          ...display.workArea,
+          show: true,
+          frame: false,
+          hasShadow: false,
+          backgroundColor: HexColors.BLUE,
+          roundedCorners: false
+        });
+
+        v = new WebContentsView();
+        w.setContentView(v);
+        v.setBorderRadius(100);
+
+        const readyForCapture = once(v.webContents, 'ready-to-show');
+        v.webContents.loadURL(backgroundUrl);
+
+        const inset = 10;
+        corners = [
+          { x: display.workArea.x + inset, y: display.workArea.y + inset }, // top-left
+          { x: display.workArea.x + display.workArea.width - inset, y: display.workArea.y + inset }, // top-right
+          { x: display.workArea.x + display.workArea.width - inset, y: display.workArea.y + display.workArea.height - inset }, // bottom-right
+          { x: display.workArea.x + inset, y: display.workArea.y + display.workArea.height - inset } // bottom-left
+        ];
+
+        await readyForCapture;
+      });
+
+      afterEach(() => {
+        w.destroy();
+        w = v = null!;
+      });
+
+      it('should render with cutout corners', async () => {
+        const screenCapture = new ScreenCapture(display);
+
+        for (const corner of corners) {
+          await screenCapture.expectColorAtPointOnDisplayMatches(HexColors.BLUE, () => corner);
+        }
+
+        // Center should be WebContents page background color
+        await screenCapture.expectColorAtCenterMatches(HexColors.GREEN);
+      });
+
+      it('should allow resetting corners', async () => {
+        const corner = corners[0];
+        v.setBorderRadius(0);
+
+        await nextFrameTime();
+        const screenCapture = new ScreenCapture(display);
+        await screenCapture.expectColorAtPointOnDisplayMatches(HexColors.GREEN, () => corner);
+        await screenCapture.expectColorAtCenterMatches(HexColors.GREEN);
+      });
+
+      it('should render when set before attached', async () => {
+        v = new WebContentsView();
+        v.setBorderRadius(100); // must set before
+
+        w.setContentView(v);
+
+        const readyForCapture = once(v.webContents, 'ready-to-show');
+        v.webContents.loadURL(backgroundUrl);
+        await readyForCapture;
+
+        const corner = corners[0];
+        const screenCapture = new ScreenCapture(display);
+        await screenCapture.expectColorAtPointOnDisplayMatches(HexColors.BLUE, () => corner);
+        await screenCapture.expectColorAtCenterMatches(HexColors.GREEN);
+      });
+    });
+
+    it('should allow setting when not attached', async () => {
+      const v = new WebContentsView();
+      v.setBorderRadius(100);
+    });
+  });
 });

+ 3 - 6
spec/guest-window-manager-spec.ts

@@ -1,10 +1,9 @@
 import { BrowserWindow, screen } from 'electron';
 import { expect, assert } from 'chai';
-import { HexColors, ScreenCapture } from './lib/screen-helpers';
+import { HexColors, ScreenCapture, hasCapturableScreen } from './lib/screen-helpers';
 import { ifit, listen } from './lib/spec-helpers';
 import { closeAllWindows } from './lib/window-helpers';
 import { once } from 'node:events';
-import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
 import * as http from 'node:http';
 
 describe('webContents.setWindowOpenHandler', () => {
@@ -201,8 +200,7 @@ describe('webContents.setWindowOpenHandler', () => {
       expect(await browserWindow.webContents.executeJavaScript('42')).to.equal(42);
     });
 
-    // Linux and arm64 platforms (WOA and macOS) do not return any capture sources
-    ifit(process.platform === 'darwin' && process.arch === 'x64')('should not make child window background transparent', async () => {
+    ifit(hasCapturableScreen())('should not make child window background transparent', async () => {
       browserWindow.webContents.setWindowOpenHandler(() => ({ action: 'allow' }));
       const didCreateWindow = once(browserWindow.webContents, 'did-create-window');
       browserWindow.webContents.executeJavaScript("window.open('about:blank') && true");
@@ -210,8 +208,7 @@ describe('webContents.setWindowOpenHandler', () => {
       const display = screen.getPrimaryDisplay();
       childWindow.setBounds(display.bounds);
       await childWindow.webContents.executeJavaScript("const meta = document.createElement('meta'); meta.name = 'color-scheme'; meta.content = 'dark'; document.head.appendChild(meta); true;");
-      await setTimeoutAsync(1000);
-      const screenCapture = await ScreenCapture.createForDisplay(display);
+      const screenCapture = new ScreenCapture(display);
       // color-scheme is set to dark so background should not be white
       await screenCapture.expectColorAtCenterDoesNotMatch(HexColors.WHITE);
     });

+ 50 - 31
spec/lib/screen-helpers.ts

@@ -75,67 +75,72 @@ function areColorsSimilar (
   return distance <= distanceThreshold;
 }
 
-function imageCenter (image: NativeImage): Electron.Point {
-  const size = image.getSize();
+function displayCenter (display: Electron.Display): Electron.Point {
   return {
-    x: size.width / 2,
-    y: size.height / 2
+    x: display.size.width / 2,
+    y: display.size.height / 2
   };
 }
+
+/** Resolve when approx. one frame has passed (30FPS) */
+export async function nextFrameTime (): Promise<void> {
+  return await new Promise((resolve) => {
+    setTimeout(resolve, 1000 / 30);
+  });
+}
+
 /**
  * Utilities for creating and inspecting a screen capture.
  *
+ * Set `PAUSE_CAPTURE_TESTS` env var to briefly pause during screen
+ * capture for easier inspection.
+ *
  * 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);
-  }
+  /** Timeout to wait for expected color to match. */
+  static TIMEOUT = 3000;
 
-  public static async createForDisplay (
-    display: Electron.Display
-  ): Promise<ScreenCapture> {
-    return ScreenCapture._createImpl(display);
+  constructor (display?: Electron.Display) {
+    this.display = display || screen.getPrimaryDisplay();
   }
 
   public async expectColorAtCenterMatches (hexColor: string) {
-    return this._expectImpl(imageCenter(this.image), hexColor, true);
+    return this._expectImpl(displayCenter(this.display), hexColor, true);
   }
 
   public async expectColorAtCenterDoesNotMatch (hexColor: string) {
-    return this._expectImpl(imageCenter(this.image), hexColor, false);
+    return this._expectImpl(displayCenter(this.display), hexColor, false);
   }
 
   public async expectColorAtPointOnDisplayMatches (
     hexColor: string,
     findPoint: (displaySize: Electron.Size) => Electron.Point
   ) {
-    return this._expectImpl(findPoint(this.image.getSize()), hexColor, true);
+    return this._expectImpl(findPoint(this.display.size), hexColor, true);
   }
 
-  private static async _createImpl (display: Electron.Display) {
+  private async captureFrame (): Promise<NativeImage> {
     const sources = await desktopCapturer.getSources({
       types: ['screen'],
-      thumbnailSize: display.size
+      thumbnailSize: this.display.size
     });
 
     const captureSource = sources.find(
-      (source) => source.display_id === display.id.toString()
+      (source) => source.display_id === this.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}`
+        `Unable to find screen capture for display '${this.display.id}'\n\tAvailable displays: ${displayIds}`
       );
     }
 
-    return new ScreenCapture(captureSource.thumbnail);
+    if (process.env.PAUSE_CAPTURE_TESTS) {
+      await new Promise((resolve) => setTimeout(resolve, 1e3));
+    }
+
+    return captureSource.thumbnail;
   }
 
   private async _expectImpl (
@@ -143,15 +148,29 @@ export class ScreenCapture {
     expectedColor: string,
     matchIsExpected: boolean
   ) {
-    const actualColor = getPixelColor(this.image, point);
-    const colorsMatch = areColorsSimilar(expectedColor, actualColor);
-    const gotExpectedResult = matchIsExpected ? colorsMatch : !colorsMatch;
+    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);
 
     if (!gotExpectedResult) {
       // Save the image as an artifact for better debugging
       const artifactName = await createArtifactWithRandomId(
         (id) => `color-mismatch-${id}.png`,
-        this.image.toPNG()
+        frame.toPNG()
       );
       throw new AssertionError(
         `Expected color at (${point.x}, ${point.y}) to ${
@@ -161,7 +180,7 @@ export class ScreenCapture {
     }
   }
 
-  private image: NativeImage;
+  private display: Electron.Display;
 }
 
 /**
@@ -174,5 +193,5 @@ export class ScreenCapture {
  * - Win32 x64: virtual screen display is 0x0
  */
 export const hasCapturableScreen = () => {
-  return process.platform === 'darwin';
+  return process.env.CI ? process.platform === 'darwin' : true;
 };

+ 7 - 16
spec/webview-spec.ts

@@ -9,7 +9,7 @@ import * as http from 'node:http';
 import * as auth from 'basic-auth';
 import { once } from 'node:events';
 import { setTimeout } from 'node:timers/promises';
-import { HexColors, ScreenCapture } from './lib/screen-helpers';
+import { HexColors, ScreenCapture, hasCapturableScreen } from './lib/screen-helpers';
 
 declare let WebView: any;
 const features = process._linkedBinding('electron_common_features');
@@ -796,41 +796,32 @@ describe('<webview> tag', function () {
     });
     after(() => w.close());
 
-    // Linux and arm64 platforms (WOA and macOS) do not return any capture sources
-    ifit(process.platform === 'darwin' && process.arch === 'x64')('is transparent by default', async () => {
+    ifit(hasCapturableScreen())('is transparent by default', async () => {
       await loadWebView(w.webContents, {
         src: 'data:text/html,foo'
       });
 
-      await setTimeout(1000);
-
-      const screenCapture = await ScreenCapture.create();
+      const screenCapture = new ScreenCapture();
       await screenCapture.expectColorAtCenterMatches(WINDOW_BACKGROUND_COLOR);
     });
 
-    // Linux and arm64 platforms (WOA and macOS) do not return any capture sources
-    ifit(process.platform === 'darwin' && process.arch === 'x64')('remains transparent when set', async () => {
+    ifit(hasCapturableScreen())('remains transparent when set', async () => {
       await loadWebView(w.webContents, {
         src: 'data:text/html,foo',
         webpreferences: 'transparent=yes'
       });
 
-      await setTimeout(1000);
-
-      const screenCapture = await ScreenCapture.create();
+      const screenCapture = new ScreenCapture();
       await screenCapture.expectColorAtCenterMatches(WINDOW_BACKGROUND_COLOR);
     });
 
-    // Linux and arm64 platforms (WOA and macOS) do not return any capture sources
-    ifit(process.platform === 'darwin' && process.arch === 'x64')('can disable transparency', async () => {
+    ifit(hasCapturableScreen())('can disable transparency', async () => {
       await loadWebView(w.webContents, {
         src: 'data:text/html,foo',
         webpreferences: 'transparent=no'
       });
 
-      await setTimeout(1000);
-
-      const screenCapture = await ScreenCapture.create();
+      const screenCapture = new ScreenCapture();
       await screenCapture.expectColorAtCenterMatches(HexColors.WHITE);
     });
   });