Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions src/__tests__/app-menu.test.e2e.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* @jest-environment node
*/
/*
Copyright 2026 Marc Nuri San Felix

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const {spawnElectron, createTestServer, expect} = require('./');

// What this suite covers, and just as importantly what it does not.
//
// Covers the open -> scrim click -> close -> reopen chain end to end. Every link in it is already
// tested in isolation, but only with its neighbours mocked: app-menu.browser.test.mjs fires the
// wrapper click in JSDOM, preload.test.js maps close to the appMenuClose channel, and
// global-listeners.test.js drives the main process side. Nothing proved that a real scrim click in
// a real renderer reaches the real main process and leaves the menu reopenable, which is the gap
// this closes. It catches a broken scrim handler, a bad guard in appMenuOpen, or a menu that fails
// to be rebuilt for the next open.
//
// It asserts the menu is ATTACHED to the window, not merely that a window with the app-menu URL
// exists - the menu is built ahead of the click, so that window is present even while the menu is
// closed, and waiting only on it would pass against a menu that was never opened.
//
// It does NOT prove the reopened menu is visible to the user, and must never be read as if it did.
// A WebContentsView can be attached, visible, correctly bounded and backed by a perfectly healthy
// renderer while compositing nothing - which is exactly what happens on Linux when a view is
// attached a second time (see the comment above destroyAppMenu in src/main/index.js). In that
// state isDestroyed() is false, isLoading() is false, the DOM is laid out, and every assertion
// below still passes while the window is in fact blank and dead to both mouse and keyboard.
// Playwright cannot see it: DOM visibility is a renderer-side property and the renderer is fine;
// what fails is that the view's composited output never reaches the window. Catching that requires
// scoring the composited window from outside the process (real input via XTEST, real screen
// capture), which is what the experiment/x11-repaint-harness branch exists for.
//
// So: do not rename this suite into something that claims to guard the repaint bug, and do not
// treat a green run here as evidence that reopening the menu works on Linux.
describe('E2E :: App menu reopen test suite', () => {
let electron;
let chromeTabsView;
let testServer;

// Waits for ATTACHMENT, not just for a window with the app-menu URL to exist. The menu is built
// ahead of the click, so that window is present even while the menu is closed - waiting only on
// it would make these tests pass against a menu that was never opened.
const openAppMenu = async () => {
await chromeTabsView.locator('.menu__button').click();
const appMenuWindow = await electron.waitForWindow(({url}) => url.includes('app-menu/index.html'));
await expect.poll(() => electron.isAppMenuOpen()).toBe(true);
return appMenuWindow;
};

beforeAll(async () => {
testServer = await createTestServer({manualCleanup: true});
electron = await spawnElectron({
settings: {
tabs: [
{
id: 'test-tab-1',
url: testServer.url,
name: 'Test Tab'
}
]
}
});
chromeTabsView = await electron.waitForWindow(
({url, title}) => url.includes('chrome-tabs') || title === 'ElectronIM tabs');
});

afterAll(async () => {
await Promise.all([electron.kill(), testServer.close()]);
});

describe('closing the app menu by clicking the scrim', () => {
let firstAppMenu;

beforeAll(async () => {
firstAppMenu = await openAppMenu();
await expect(firstAppMenu.locator('[data-testid="about-menu-entry"]')).toBeVisible();
// The scrim fills the window while the menu itself is right aligned, so its top left corner
// is scrim and nothing else. The click bubbles up to .wrapper, which is what calls close().
await firstAppMenu.locator('.scrim').click({position: {x: 10, y: 10}});
});

test('releases the app menu renderer', async () => {
await expect.poll(() => firstAppMenu.isClosed()).toBe(true);
// Re-asserted synchronously on the settled state: expect.poll alone reads as "no assertion".
expect(firstAppMenu.isClosed()).toBe(true);
});

test('detaches the app menu from the window', async () => {
const attached = await electron.isAppMenuOpen();
expect(attached).toBe(false);
});
});

describe('reopening the app menu after a scrim close', () => {
let reopenedAppMenu;

beforeAll(async () => {
reopenedAppMenu = await openAppMenu();
});

test('opens an app menu again', () => {
expect(reopenedAppMenu).toBeDefined();
});

test('attaches it to the window', async () => {
const attached = await electron.isAppMenuOpen();
expect(attached).toBe(true);
});

test('serves it from a live renderer, not the released one', () => {
expect(reopenedAppMenu.isClosed()).toBe(false);
});

test('shows the menu entries', async () => {
await expect(reopenedAppMenu.locator('[data-testid="about-menu-entry"]')).toBeVisible();
});

test('still routes clicks on those entries', async () => {
await reopenedAppMenu.locator('[data-testid="about-menu-entry"]').click();
const aboutWindow = await electron.waitForWindow(({url}) => url.includes('about/index.html'));
expect(aboutWindow).toBeDefined();
});
});
});
23 changes: 23 additions & 0 deletions src/__tests__/playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,29 @@ const spawnElectron = async ({extraArgs = [], settings} = {}) => {
return contentView.children.some(child => child.isFindInPage === true);
});
},
/**
* Check if the app menu is currently ATTACHED to the main window.
*
* Distinct from "an app-menu window exists": the menu is built ahead of the click and lives as
* an unattached view until it is opened, so a renderer with the app-menu URL can be present
* while the menu is closed. Only attachment says it was actually opened.
*
* Note this still cannot say the menu is VISIBLE - an attached view can composite nothing while
* its renderer stays healthy. See the comment in app-menu.test.e2e.js.
*/
isAppMenuOpen: async () => {
return await electronApp.evaluate(async ({BaseWindow}) => {
const window = BaseWindow.getAllWindows()[0];
if (!window) {
return false;
}
const contentView = window.contentView;
if (!contentView?.children) {
return false;
}
return contentView.children.some(child => child.isAppMenu === true);
});
},
/**
* Wait for a condition to be true with polling
* @param {Function} conditionFn - Async function that returns a truthy value when condition is met
Expand Down
Loading