Repository: electron/electron
Pull request: ci: run BrowserWindow test spec on Wayland

#### Description of Change

Brings `spec/api-browser-window-spec.ts` to the Wayland test job. Many tests are skipped (or already were skipped on Linux) but there are still hundreds of quality results from this suite, especially related to window bounds. Even the skipped tests should  help contributors be more aware of Wayland's limitations in the future.

The big changes:

- Split up the "visibility and focus" group into separate "visibility" and "focus" groups, since Wayland can manipulate visibility but not focus. I also moved one `webContents.focus()` test into the focus group.
- Added a helper to show windows on Wayland in some tests , since windows don't fully paint/layout when hidden.
   - Most of these tests are still valid and should not be skipped on Wayland unless they are specifically interested in testing painting while hidden. We should provide guidance to developers in the docs about not trying to get too fancy with hidden windows if they intend to run their apps on Linux, since compositors won't guarantee things like geometry changes while invisible.
- Sectioned off a group of tests about disabled parent windows since they do not work on either macOS or Wayland.

Run with:

```
CI=1 WAYLAND_DISPLAY=wayland-99 ./script/actions/run-tests-wayland.sh script/yarn.js test --runners=main --files spec/api-browser-window-spec.ts
```

Last bit of the results:

```
ok 467 BrowserWindow module offscreen rendering creates offscreen window with correct size
ok 468 BrowserWindow module offscreen rendering does not crash after navigation
ok 469 BrowserWindow module offscreen rendering window.webContents.isOffscreen() is true for offscreen type
ok 470 BrowserWindow module offscreen rendering window.webContents.isOffscreen() is false for regular window
ok 471 BrowserWindow module offscreen rendering window.webContents.isPainting() returns whether is currently painting
ok 472 BrowserWindow module offscreen rendering window.webContents.stopPainting() stops painting
ok 473 BrowserWindow module offscreen rendering window.webContents.startPainting() starts painting
ok 474 BrowserWindow module offscreen rendering frameRate APIs has default frame rate (function)
ok 475 BrowserWindow module offscreen rendering frameRate APIs has default frame rate (property)
ok 476 BrowserWindow module offscreen rendering frameRate APIs sets custom frame rate (function)
ok 477 BrowserWindow module offscreen rendering frameRate APIs sets custom frame rate (property)
ok 478 BrowserWindow module offscreen rendering shared texture does not crash when release() is called after the texture is garbage collected
ok 479 BrowserWindow module offscreen rendering with device scale factor creates offscreen window with correct size considering device scale factor
ok 480 BrowserWindow module offscreen rendering with device scale factor has correct screen and window sizes
ok 481 BrowserWindow module offscreen rendering with device scale factor has correct device screen size media query result
ok 482 BrowserWindow module "transparent" option correctly returns isMaximized() when the window is maximized then minimized # SKIP -
ok 483 BrowserWindow module "transparent" option can show maximized frameless window # SKIP -
ok 484 BrowserWindow module "transparent" option should not display a visible background # SKIP -
ok 485 BrowserWindow module "transparent" option Allows setting a transparent window via CSS # SKIP -
ok 486 BrowserWindow module "transparent" option should not make background transparent if falsy # SKIP -
ok 487 BrowserWindow module "backgroundColor" option should display the set color # SKIP -
# tests 281
# pass 281
# fail 0
1..487
```


<!--
Thank you for your Pull Request. Please provide a description above and review
the requirements below.

Contributors guide: https://github.com/electron/electron/blob/main/CONTRIBUTING.md

NOTE: PRS submitted without this template will be automatically closed.
-->

#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] PR description included
- [x] I have built and tested this PR
- [x] `npm test` passes
- [x] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [ ] relevant API documentation, tutorials, and examples are updated and follow the [documentation style guide](https://github.com/electron/electron/blob/main/docs/development/style-guide.md)
- [ ] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: <!-- Please add a one-line description for app developers to read in the release notes, or 'none' if no notes relevant to app developers. Examples and help on special cases: https://github.com/electron/clerk/blob/main/README.md#examples --> none


Changed files:
filename: script/actions/run-tests-wayland.sh
patch: @@ -2,6 +2,8 @@
 set -euo pipefail
 
 export XDG_SESSION_TYPE=wayland
+# On a Wayland desktop, the tests will use your active display and compositor.
+# To run headlessly in weston like in CI, set WAYLAND_DISPLAY=wayland-99.
 export WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-99}"
 
 if [[ -z "${XDG_RUNTIME_DIR:-}" ]]; then

filename: script/wayland-test-allowlist.txt
patch: @@ -1,4 +1,5 @@
 spec/parse-features-string-spec.ts
 spec/types-spec.ts
 spec/version-bump-spec.ts
-spec/api-app-spec.ts
\ No newline at end of file
+spec/api-app-spec.ts
+spec/api-browser-window-spec.ts
\ No newline at end of file

filename: spec/api-browser-window-spec.ts
patch: @@ -17,7 +17,7 @@ import * as nodeUrl from 'node:url';
 import { emittedUntil, emittedNTimes } from './lib/events-helpers';
 import { randomString } from './lib/net-helpers';
 import { HexColors, hasCapturableScreen, ScreenCapture } from './lib/screen-helpers';
-import { ifit, ifdescribe, defer, listen, waitUntil } from './lib/spec-helpers';
+import { ifit, ifdescribe, defer, listen, waitUntil, isWayland } from './lib/spec-helpers';
 import { closeWindow, closeAllWindows } from './lib/window-helpers';
 
 const fixtures = path.resolve(__dirname, 'fixtures');
@@ -1204,7 +1204,7 @@ describe('BrowserWindow module', () => {
     });
   }
 
-  describe('focus and visibility', () => {
+  describe('visibility', () => {
     let w: BrowserWindow;
     beforeEach(() => {
       w = new BrowserWindow({ show: false });
@@ -1215,19 +1215,7 @@ describe('BrowserWindow module', () => {
     });
 
     describe('BrowserWindow.show()', () => {
-      it('should focus on window', async () => {
-        const p = once(w, 'focus');
-        w.show();
-        await p;
-        expect(w.isFocused()).to.equal(true);
-      });
       it('should make the window visible', async () => {
-        const p = once(w, 'focus');
-        w.show();
-        await p;
-        expect(w.isVisible()).to.equal(true);
-      });
-      it('emits when window is shown', async () => {
         const show = once(w, 'show');
         w.show();
         await show;
@@ -1236,10 +1224,6 @@ describe('BrowserWindow module', () => {
     });
 
     describe('BrowserWindow.hide()', () => {
-      it('should defocus on window', () => {
-        w.hide();
-        expect(w.isFocused()).to.equal(false);
-      });
       it('should make the window not visible', () => {
         w.show();
         w.hide();
@@ -1255,6 +1239,40 @@ describe('BrowserWindow module', () => {
         expect(w.isVisible()).to.equal(false);
       });
     });
+  });
+
+  // Wayland does not allow focus and z-order to be controlled without user input
+  ifdescribe(!isWayland)('focus, blur, and z-order', () => {
+    let w: BrowserWindow;
+    beforeEach(() => {
+      w = new BrowserWindow({ show: false });
+    });
+    afterEach(async () => {
+      await closeWindow(w);
+      w = null as unknown as BrowserWindow;
+    });
+
+    describe('BrowserWindow.show()', () => {
+      it('should focus on window', async () => {
+        const p = once(w, 'focus');
+        w.show();
+        await p;
+        expect(w.isFocused()).to.equal(true);
+      });
+      it('emits focus event and makes the window visible', async () => {
+        const p = once(w, 'focus');
+        w.show();
+        await p;
+        expect(w.isVisible()).to.equal(true);
+      });
+    });
+
+    describe('BrowserWindow.hide()', () => {
+      it('should defocus on window', () => {
+        w.hide();
+        expect(w.isFocused()).to.equal(false);
+      });
+    });
 
     describe('BrowserWindow.minimize()', () => {
       // TODO(codebytere): Enable for Linux once maximize/minimize events work in CI.
@@ -1626,6 +1644,20 @@ describe('BrowserWindow module', () => {
         await closeWindow(w2, { assertNotWindows: false });
       });
     });
+
+    describe('window.webContents.focus()', () => {
+      afterEach(closeAllWindows);
+      it('focuses window', async () => {
+        const w1 = new BrowserWindow({ x: 100, y: 300, width: 300, height: 200 });
+        w1.loadURL('about:blank');
+        const w2 = new BrowserWindow({ x: 300, y: 300, width: 300, height: 200 });
+        w2.loadURL('about:blank');
+        const w1Focused = once(w1, 'focus');
+        w1.webContents.focus();
+        await w1Focused;
+        expect(w1.webContents.isFocused()).to.be.true('focuses window');
+      });
+    });
   });
 
   describe('sizing', () => {
@@ -1814,7 +1846,8 @@ describe('BrowserWindow module', () => {
       });
     });
 
-    describe('BrowserWindow.setContentBounds(bounds)', () => {
+    // Windows cannot be programmatically moved on Wayland
+    ifdescribe(!isWayland)('BrowserWindow.setContentBounds(bounds)', () => {
       it('sets the content size and position', async () => {
         const bounds = { x: 10, y: 10, width: 250, height: 250 };
         const resize = once(w, 'resize');
@@ -3285,6 +3318,19 @@ describe('BrowserWindow module', () => {
     });
   });
 
+  // On Wayland, hidden windows may not have mapped surfaces or finalized geometry
+  // until shown. Tests that depend on real geometry or frame events may need
+  // to show the window first.
+  const showWindowForWayland = async (w: BrowserWindow) => {
+    if (!isWayland || w.isVisible()) {
+      return;
+    }
+
+    const shown = once(w, 'show');
+    w.show();
+    await shown;
+  };
+
   describe('"titleBarStyle" option', () => {
     const testWindowsOverlay = async (style: any) => {
       const w = new BrowserWindow({
@@ -3304,8 +3350,10 @@ describe('BrowserWindow module', () => {
       } else {
         const overlayReady = once(ipcMain, 'geometrychange');
         await w.loadFile(overlayHTML);
+        await showWindowForWayland(w);
         await overlayReady;
       }
+
       const overlayEnabled = await w.webContents.executeJavaScript('navigator.windowControlsOverlay.visible');
       expect(overlayEnabled).to.be.true('overlayEnabled');
       const overlayRect = await w.webContents.executeJavaScript('getJSOverlayProperties()');
@@ -3418,6 +3466,7 @@ describe('BrowserWindow module', () => {
       } else {
         const overlayReady = once(ipcMain, 'geometrychange');
         await w.loadFile(overlayHTML);
+        await showWindowForWayland(w);
         await overlayReady;
       }
 
@@ -3491,6 +3540,7 @@ describe('BrowserWindow module', () => {
         const overlayHTML = path.join(__dirname, 'fixtures', 'pages', 'overlay.html');
         const overlayReady = once(ipcMain, 'geometrychange');
         await w.loadFile(overlayHTML);
+        await showWindowForWayland(w);
         if (firstRun) {
           await overlayReady;
         }
@@ -4771,7 +4821,9 @@ describe('BrowserWindow module', () => {
       const w = new BrowserWindow({ show: false });
       let called = false;
       w.loadFile(path.join(fixtures, 'api', 'frame-subscriber.html'));
-      w.webContents.on('dom-ready', () => {
+      w.webContents.on('dom-ready', async () => {
+        await showWindowForWayland(w);
+
         w.webContents.beginFrameSubscription(function () {
           // This callback might be called twice.
           if (called) return;
@@ -4791,7 +4843,9 @@ describe('BrowserWindow module', () => {
       const w = new BrowserWindow({ show: false });
       let called = false;
       w.loadFile(path.join(fixtures, 'api', 'frame-subscriber.html'));
-      w.webContents.on('dom-ready', () => {
+      w.webContents.on('dom-ready', async () => {
+        await showWindowForWayland(w);
+
         w.webContents.beginFrameSubscription(function (data) {
           // This callback might be called twice.
           if (called) return;
@@ -4815,7 +4869,9 @@ describe('BrowserWindow module', () => {
       let called = false;
       let gotInitialFullSizeFrame = false;
       const [contentWidth, contentHeight] = w.getContentSize();
-      w.webContents.on('did-finish-load', () => {
+      w.webContents.on('did-finish-load', async () => {
+        await showWindowForWayland(w);
+
         w.webContents.beginFrameSubscription(true, (image, rect) => {
           if (image.isEmpty()) {
             // Chromium sometimes sends a 0x0 frame at the beginning of the
@@ -5396,55 +5452,57 @@ describe('BrowserWindow module', () => {
         await createTwo();
       });
 
-      ifit(process.platform !== 'darwin')('can disable and enable a window', () => {
-        const w = new BrowserWindow({ show: false });
-        w.setEnabled(false);
-        expect(w.isEnabled()).to.be.false('w.isEnabled()');
-        w.setEnabled(true);
-        expect(w.isEnabled()).to.be.true('!w.isEnabled()');
-      });
+      ifdescribe(process.platform !== 'darwin' && !isWayland)('disabling parent windows', () => {
+        it('can disable and enable a window', () => {
+          const w = new BrowserWindow({ show: false });
+          w.setEnabled(false);
+          expect(w.isEnabled()).to.be.false('w.isEnabled()');
+          w.setEnabled(true);
+          expect(w.isEnabled()).to.be.true('!w.isEnabled()');
+        });
 
-      ifit(process.platform !== 'darwin')('disables parent window', () => {
-        const w = new BrowserWindow({ show: false });
-        const c = new BrowserWindow({ show: false, parent: w, modal: true });
-        expect(w.isEnabled()).to.be.true('w.isEnabled');
-        c.show();
-        expect(w.isEnabled()).to.be.false('w.isEnabled');
-      });
+        it('disables parent window', () => {
+          const w = new BrowserWindow({ show: false });
+          const c = new BrowserWindow({ show: false, parent: w, modal: true });
+          expect(w.isEnabled()).to.be.true('w.isEnabled');
+          c.show();
+          expect(w.isEnabled()).to.be.false('w.isEnabled');
+        });
 
-      ifit(process.platform !== 'darwin')('re-enables an enabled parent window when closed', async () => {
-        const w = new BrowserWindow({ show: false });
-        const c = new BrowserWindow({ show: false, parent: w, modal: true });
-        const closed = once(c, 'closed');
-        c.show();
-        c.close();
-        await closed;
-        expect(w.isEnabled()).to.be.true('w.isEnabled');
-      });
+        it('re-enables an enabled parent window when closed', async () => {
+          const w = new BrowserWindow({ show: false });
+          const c = new BrowserWindow({ show: false, parent: w, modal: true });
+          const closed = once(c, 'closed');
+          c.show();
+          c.close();
+          await closed;
+          expect(w.isEnabled()).to.be.true('w.isEnabled');
+        });
 
-      ifit(process.platform !== 'darwin')('does not re-enable a disabled parent window when closed', async () => {
-        const w = new BrowserWindow({ show: false });
-        const c = new BrowserWindow({ show: false, parent: w, modal: true });
-        const closed = once(c, 'closed');
-        w.setEnabled(false);
-        c.show();
-        c.close();
-        await closed;
-        expect(w.isEnabled()).to.be.false('w.isEnabled');
-      });
+        it('does not re-enable a disabled parent window when closed', async () => {
+          const w = new BrowserWindow({ show: false });
+          const c = new BrowserWindow({ show: false, parent: w, modal: true });
+          const closed = once(c, 'closed');
+          w.setEnabled(false);
+          c.show();
+          c.close();
+          await closed;
+          expect(w.isEnabled()).to.be.false('w.isEnabled');
+        });
 
-      ifit(process.platform !== 'darwin')('disables parent window recursively', () => {
-        const w = new BrowserWindow({ show: false });
-        const c = new BrowserWindow({ show: false, parent: w, modal: true });
-        const c2 = new BrowserWindow({ show: false, parent: w, modal: true });
-        c.show();
-        expect(w.isEnabled()).to.be.false('w.isEnabled');
-        c2.show();
-        expect(w.isEnabled()).to.be.false('w.isEnabled');
-        c.destroy();
-        expect(w.isEnabled()).to.be.false('w.isEnabled');
-        c2.destroy();
-        expect(w.isEnabled()).to.be.true('w.isEnabled');
+        it('disables parent window recursively', () => {
+          const w = new BrowserWindow({ show: false });
+          const c = new BrowserWindow({ show: false, parent: w, modal: true });
+          const c2 = new BrowserWindow({ show: false, parent: w, modal: true });
+          c.show();
+          expect(w.isEnabled()).to.be.false('w.isEnabled');
+          c2.show();
+          expect(w.isEnabled()).to.be.false('w.isEnabled');
+          c.destroy();
+          expect(w.isEnabled()).to.be.false('w.isEnabled');
+          c2.destroy();
+          expect(w.isEnabled()).to.be.true('w.isEnabled');
+        });
       });
     });
   });
@@ -5684,7 +5742,7 @@ describe('BrowserWindow module', () => {
       });
     });
 
-    ifdescribe(process.platform !== 'win32')('visibleOnAllWorkspaces state', () => {
+    ifdescribe(process.platform !== 'win32' && !isWayland)('visibleOnAllWorkspaces state', () => {
       describe('with properties', () => {
         it('can be changed', () => {
           const w = new BrowserWindow({ show: false });
@@ -6835,20 +6893,6 @@ describe('BrowserWindow module', () => {
     });
   });
 
-  describe('window.webContents.focus()', () => {
-    afterEach(closeAllWindows);
-    it('focuses window', async () => {
-      const w1 = new BrowserWindow({ x: 100, y: 300, width: 300, height: 200 });
-      w1.loadURL('about:blank');
-      const w2 = new BrowserWindow({ x: 300, y: 300, width: 300, height: 200 });
-      w2.loadURL('about:blank');
-      const w1Focused = once(w1, 'focus');
-      w1.webContents.focus();
-      await w1Focused;
-      expect(w1.webContents.isFocused()).to.be.true('focuses window');
-    });
-  });
-
   describe('offscreen rendering', () => {
     let w: BrowserWindow;
     beforeEach(function () {