diff --git a/chromium_src/BUILD.gn b/chromium_src/BUILD.gn index 026260adbbfd..ca7edf23c08e 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 2f2ec00b2e23..62c710626f01 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 4650dc0e2bb8..35cc47b70375 100644 --- a/shell/browser/api/electron_api_view.cc +++ b/shell/browser/api/electron_api_view.cc @@ -10,6 +10,7 @@ #include #include +#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 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 0475d5e6d12e..b3fd2e9cd86a 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, void SetLayout(v8::Isolate* isolate, v8::Local value); std::vector> GetChildren(); void SetBackgroundColor(std::optional color); + void SetBorderRadius(int radius); void SetVisible(bool visible); // views::ViewObserver @@ -44,6 +45,7 @@ class View : public gin_helper::EventEmitter, void OnViewIsDeleting(views::View* observed_view) override; views::View* view() const { return view_; } + std::optional border_radius() const { return border_radius_; } // disable copy View(const View&) = delete; @@ -58,9 +60,11 @@ class View : public gin_helper::EventEmitter, void set_delete_view(bool should) { delete_view_ = should; } private: + void ApplyBorderRadius(); void ReorderChildView(gin::Handle child, size_t index); std::vector> child_views_; + std::optional border_radius_; bool delete_view_ = true; raw_ptr 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 0d1bdd5cb3f7..092b60a7e914 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 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 b02c3a460d58..f4248193e5a3 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 GetWebContents(v8::Isolate* isolate); void SetBackgroundColor(std::optional 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 web_contents_; raw_ptr api_web_contents_; diff --git a/shell/browser/ui/inspectable_web_contents_view.cc b/shell/browser/ui/inspectable_web_contents_view.cc index 8f2844850afb..e3e03bc97b21 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(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(this); diff --git a/shell/browser/ui/inspectable_web_contents_view.h b/shell/browser/ui/inspectable_web_contents_view.h index 49eafbbd0ffd..bd279b4ae02a 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 devtools_window_; raw_ptr devtools_window_web_view_ = nullptr; - raw_ptr contents_web_view_ = nullptr; raw_ptr devtools_web_view_ = nullptr; + raw_ptr contents_web_view_ = nullptr; + raw_ptr 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 62927d1c8804..dc193b083e7a 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 d5d21b962975..f0b6fb360012 100644 --- a/spec/api-browser-window-spec.ts +++ b/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,'); - 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); }); }); diff --git a/spec/api-view-spec.ts b/spec/api-view-spec.ts index 5af674785c82..de2aac8b9101 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 dbfa1d17dbe9..220100434d1f 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,`; + + 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 fbe3377897ca..a625f2a7b839 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 3dc84bbd80db..98f7ed2cb191 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 { + 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; - } + /** Timeout to wait for expected color to match. */ + static TIMEOUT = 3000; - public static async create (): Promise { - const display = screen.getPrimaryDisplay(); - return ScreenCapture._createImpl(display); - } - - public static async createForDisplay ( - display: Electron.Display - ): Promise { - 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 { 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 3c80e41ce9c6..6c85a5bcf203 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(' 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); }); });