Repository: electron/electron (language: cpp)

Issue:
[Feature Request]: Set `border-radius` for `BrowserView`.
### Preflight Checklist

- [X] I have read the [Contributing Guidelines](https://github.com/electron/electron/blob/main/CONTRIBUTING.md) for this project.
- [X] I agree to follow the [Code of Conduct](https://github.com/electron/electron/blob/main/CODE_OF_CONDUCT.md) that this project adheres to.
- [X] I have searched the [issue tracker](https://www.github.com/electron/electron/issues) for a feature request that matches the one I want to file, without success.

### Problem Description

I am aware that currently the only setting items for BrowserView style are `setBounds` and `setBackgroundColor`.
https://www.electronjs.org/docs/latest/api/browser-view

Is there any way to set `border-radius` (rounded corner) for BrowserView?

If not, I would be very happy to see it implemented.

### Proposed Solution

I think it should be implemented in the constructor or instance method of BrowserView.

```
// in constructor
const view = new BrowserView({
    borderRadius: 12,
});

// instance method
view.setBorderRadius(12);
```

### Alternatives Considered

I have tried editing the HTML within webContents, but adding `border-radius` to `<html />` does not work (children do not fit).

### Additional Information

_No response_


Discussion / hints:
Please can we have this 🙏 
I really want this feature implemented!
(Does commenting on an issue make it so that the devs notice it again? because I'm pretty sure it does, and that's why I'm writing this comment now)
Hi team, can we have an update on this?
Any update on this?
We should get this reconsidered by the devs
Would love to have this feature + drop shadow. It's hard to see how this could be implemented in a more general styling framework, but these are the two power tools of modern design so adding support for them would be high impact.
this would be an amazing feature. pls
+1, would like this as well
+1 from me. My current solution is to overwrite page css styles and apply border radius by css
This feature is so much needed! 😀

Fix patch:
diff --git a/chromium_src/BUILD.gn b/chromium_src/BUILD.gn
index 026260adbbfdb..ca7edf23c08eb 100644
--- a/chromium_src/BUILD.gn
+++ b/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",
diff --git a/docs/api/view.md b/docs/api/view.md
index 2f2ec00b2e233..62c710626f011 100644
--- a/docs/api/view.md
+++ b/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.
diff --git a/shell/browser/api/electron_api_view.cc b/shell/browser/api/electron_api_view.cc
index 4650dc0e2bb8d..35cc47b703750 100644
--- a/shell/browser/api/electron_api_view.cc
+++ b/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);
 }
diff --git a/shell/browser/api/electron_api_view.h b/shell/browser/api/electron_api_view.h
index 0475d5e6d12ee..b3fd2e9cd86a7 100644
--- a/shell/browser/api/electron_api_view.h
+++ b/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;
diff --git a/shell/browser/api/electron_api_web_contents_view.cc b/shell/browser/api/electron_api_web_contents_view.cc
index 0d1bdd5cb3f7c..092b60a7e9143 100644
--- a/shell/browser/api/electron_api_web_contents_view.cc
+++ b/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);
 }
 
diff --git a/shell/browser/api/electron_api_web_contents_view.h b/shell/browser/api/electron_api_web_contents_view.h
index b02c3a460d58b..f4248193e5a35 100644
--- a/shell/browser/api/electron_api_web_contents_view.h
+++ b/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_;
diff --git a/shell/browser/ui/inspectable_web_contents_view.cc b/shell/browser/ui/inspectable_web_contents_view.cc
index 8f2844850afb1..e3e03bc97b21e 100644
--- a/shell/browser/ui/inspectable_web_contents_view.cc
+++ b/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);
diff --git a/shell/browser/ui/inspectable_web_contents_view.h b/shell/browser/ui/inspectable_web_contents_view.h
index 49eafbbd0ffd6..bd279b4ae02a0 100644
--- a/shell/browser/ui/inspectable_web_contents_view.h
+++ b/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;
diff --git a/spec/api-browser-view-spec.ts b/spec/api-browser-view-spec.ts
index 62927d1c8804f..dc193b083e7aa 100644
--- a/spec/api-browser-view-spec.ts
+++ b/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);
     });
   });
diff --git a/spec/api-browser-window-spec.ts b/spec/api-browser-window-spec.ts
index db2412341f41d..95d18815a5bee 100644
--- a/spec/api-browser-window-spec.ts
+++ b/spec/api-browser-window-spec.ts
@@ -6490,8 +6490,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({
@@ -6514,9 +6514,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) => ({
@@ -6533,8 +6531,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({
@@ -6560,14 +6558,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]) {
@@ -6579,8 +6574,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);
 
@@ -6592,8 +6586,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({
@@ -6605,9 +6598,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);
     });
   });
diff --git a/spec/api-view-spec.ts b/spec/api-view-spec.ts
index 5af674785c82d..de2aac8b91012 100644
--- a/spec/api-view-spec.ts
+++ b/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);
+  });
 });
diff --git a/spec/api-web-contents-view-spec.ts b/spec/api-web-contents-view-spec.ts
index dbfa1d17dbe98..220100434d1ff 100644
--- a/spec/api-web-contents-view-spec.ts
+++ b/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);
+    });
+  });
 });
diff --git a/spec/guest-window-manager-spec.ts b/spec/guest-window-manager-spec.ts
index fbe3377897ca6..a625f2a7b8392 100644
--- a/spec/guest-window-manager-spec.ts
+++ b/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);
     });
diff --git a/spec/lib/screen-helpers.ts b/spec/lib/screen-helpers.ts
index 3dc84bbd80db5..98f7ed2cb1914 100644
--- a/spec/lib/screen-helpers.ts
+++ b/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;
 };
diff --git a/spec/webview-spec.ts b/spec/webview-spec.ts
index 3c80e41ce9c6d..6c85a5bcf2039 100644
--- a/spec/webview-spec.ts
+++ b/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);
     });
   });


Test patch: