test: drag region tests

This commit is contained in:
clavin
2025-12-25 16:16:55 -07:00
parent 3df3a6a736
commit bfe4a515b0
7 changed files with 386 additions and 134 deletions

View File

@@ -12,6 +12,7 @@
"@electron/github-app-auth": "^3.2.0",
"@electron/lint-roller": "^3.1.2",
"@electron/typescript-definitions": "^9.1.5",
"@hurdlegroup/robotjs": "^0.12.3",
"@octokit/rest": "^20.1.2",
"@primer/octicons": "^10.0.0",
"@types/minimist": "^1.2.5",

View File

@@ -6979,119 +6979,4 @@ describe('BrowserWindow module', () => {
await screenCapture.expectColorAtCenterMatches(HexColors.BLUE);
});
});
describe('draggable regions', () => {
afterEach(closeAllWindows);
ifit(hasCapturableScreen())('should allow the window to be dragged when enabled', async () => {
// FIXME: nut-js has been removed from npm; we need to find a replacement
// WOA fails to load libnut so we're using require to defer loading only
// on supported platforms.
// "@nut-tree\libnut-win32\build\Release\libnut.node is not a valid Win32 application."
// @ts-ignore: nut-js is an optional dependency so it may not be installed
const { mouse, straightTo, centerOf, Region, Button } = require('@nut-tree/nut-js') as typeof import('@nut-tree/nut-js');
const display = screen.getPrimaryDisplay();
const w = new BrowserWindow({
x: 0,
y: 0,
width: display.bounds.width / 2,
height: display.bounds.height / 2,
frame: false,
titleBarStyle: 'hidden'
});
const overlayHTML = path.join(__dirname, 'fixtures', 'pages', 'overlay.html');
w.loadFile(overlayHTML);
await once(w, 'ready-to-show');
const winBounds = w.getBounds();
const titleBarHeight = 30;
const titleBarRegion = new Region(winBounds.x, winBounds.y, winBounds.width, titleBarHeight);
const screenRegion = new Region(display.bounds.x, display.bounds.y, display.bounds.width, display.bounds.height);
const startPos = w.getPosition();
await mouse.setPosition(await centerOf(titleBarRegion));
await mouse.pressButton(Button.LEFT);
await mouse.drag(straightTo(centerOf(screenRegion)));
// Wait for move to complete
await Promise.race([
once(w, 'move'),
setTimeout(100) // fallback for possible race condition
]);
const endPos = w.getPosition();
expect(startPos).to.not.deep.equal(endPos);
});
ifit(hasCapturableScreen())('should allow the window to be dragged when no WCO and --webkit-app-region: drag enabled', async () => {
// FIXME: nut-js has been removed from npm; we need to find a replacement
// @ts-ignore: nut-js is an optional dependency so it may not be installed
const { mouse, straightTo, centerOf, Region, Button } = require('@nut-tree/nut-js') as typeof import('@nut-tree/nut-js');
const display = screen.getPrimaryDisplay();
const w = new BrowserWindow({
x: 0,
y: 0,
width: display.bounds.width / 2,
height: display.bounds.height / 2,
frame: false
});
const basePageHTML = path.join(__dirname, 'fixtures', 'pages', 'base-page.html');
w.loadFile(basePageHTML);
await once(w, 'ready-to-show');
await w.webContents.executeJavaScript(`
const style = document.createElement('style');
style.innerHTML = \`
#titlebar {
background-color: red;
height: 30px;
width: 100%;
-webkit-user-select: none;
-webkit-app-region: drag;
position: fixed;
top: 0;
left: 0;
z-index: 1000000000000;
}
\`;
const titleBar = document.createElement('title-bar');
titleBar.id = 'titlebar';
titleBar.textContent = 'test-titlebar';
document.body.append(style);
document.body.append(titleBar);
`);
// allow time for titlebar to finish loading
await setTimeout(2000);
const winBounds = w.getBounds();
const titleBarHeight = 30;
const titleBarRegion = new Region(winBounds.x, winBounds.y, winBounds.width, titleBarHeight);
const screenRegion = new Region(display.bounds.x, display.bounds.y, display.bounds.width, display.bounds.height);
const startPos = w.getPosition();
await mouse.setPosition(await centerOf(titleBarRegion));
await mouse.pressButton(Button.LEFT);
await mouse.drag(straightTo(centerOf(screenRegion)));
// Wait for move to complete
await Promise.race([
once(w, 'move'),
setTimeout(1000) // fallback for possible race condition
]);
const endPos = w.getPosition();
expect(startPos).to.not.deep.equal(endPos);
});
});
});

267
spec/drag-region-spec.ts Normal file
View File

@@ -0,0 +1,267 @@
import { BrowserWindow, screen } from 'electron/main';
import { expect } from 'chai';
import { once } from 'node:events';
import * as path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { pathToFileURL } from 'node:url';
import { hasCapturableScreen } from './lib/screen-helpers';
import { closeAllWindows } from './lib/window-helpers';
const display = screen.getPrimaryDisplay();
const fixtures = path.resolve(__dirname, 'fixtures');
// Try to load robotjs
let robot: typeof import('@hurdlegroup/robotjs');
try {
robot = require('@hurdlegroup/robotjs');
} catch {
// ignore. tests are skipped below if this is undefined.
}
const draggablePageURL = pathToFileURL(
path.join(fixtures, 'pages', 'draggable-page.html')
);
const iframePageURL = pathToFileURL(
path.join(fixtures, 'pages', 'iframe.html')
);
const webviewPageURL = pathToFileURL(
path.join(fixtures, 'pages', 'webview.html')
);
const testWindowOpts: Electron.BrowserWindowConstructorOptions = {
x: 0,
y: 0,
width: Math.round(display.bounds.width / 2),
height: Math.round(display.bounds.height / 2),
frame: false
};
const center = (rect: Electron.Rectangle): Electron.Point => ({
x: Math.round(rect.x + rect.width / 2),
y: Math.round(rect.y + rect.height / 2)
});
const performDrag = async (
w: BrowserWindow
): Promise<{
start: [number, number];
end: [number, number];
}> => {
const winBounds = w.getBounds();
const winCenter = center(winBounds);
const screenCenter = center(display.bounds);
const start = w.getPosition() as [number, number];
const moved = once(w, 'move');
// Extra events based on research from https://github.com/octalmage/robotjs/issues/389
robot.moveMouse(winCenter.x, winCenter.y);
robot.mouseToggle('down', 'left');
robot.moveMouse(winCenter.x + 2, winCenter.y + 2); // extra
await setTimeout(200); // extra
robot.dragMouse(screenCenter.x, screenCenter.y);
robot.mouseToggle('up', 'left');
await Promise.race([moved, setTimeout(1000)]);
const end = w.getPosition() as [number, number];
return { start, end };
};
const loadDraggableSubframe = async (w: BrowserWindow): Promise<void> => {
let selector: string;
let eventName: string;
if (w.getURL() === iframePageURL.href) {
selector = 'iframe';
eventName = 'load';
} else if (w.getURL() === webviewPageURL.href) {
selector = 'webview';
eventName = 'did-finish-load';
} else {
throw new Error('Unexpected page loaded');
}
await w.webContents.executeJavaScript(`
new Promise((resolve) => {
const frame = document.querySelector('${selector}');
frame.addEventListener(
'${eventName}',
() => resolve(),
{ once: true }
);
frame.src = '${draggablePageURL.href}';
})
`);
};
describe('draggable regions', function () {
before(async function () {
if (!robot || !robot.moveMouse || !hasCapturableScreen()) {
this.skip();
}
// The first window may not properly receive events due to UI transitions or
// focus management. To mitigate this, warm up with a test run.
const w = new BrowserWindow(testWindowOpts);
await w.loadURL(draggablePageURL.href);
await performDrag(w);
w.destroy();
});
afterEach(closeAllWindows);
describe('main window', () => {
let w: BrowserWindow;
beforeEach(() => {
w = new BrowserWindow(testWindowOpts);
});
it('drags with app-region: drag', async () => {
await w.loadURL(draggablePageURL.href);
const { start, end } = await performDrag(w);
expect(start).to.not.deep.equal(end);
});
it('does not drag when app-region: no-drag overlaps drag region', async () => {
const noDragURL = new URL(draggablePageURL.href);
noDragURL.searchParams.set('no-drag', '1');
await w.loadURL(noDragURL.href);
const { start, end } = await performDrag(w);
expect(start).to.deep.equal(end);
});
it('drags after navigation', async () => {
await w.loadFile(path.join(fixtures, 'pages', 'base-page.html'));
await w.loadURL(draggablePageURL.href);
const { start, end } = await performDrag(w);
expect(start).to.not.deep.equal(end);
});
it('drags after in-page navigation', async () => {
await w.loadURL(draggablePageURL.href);
const didNavigate = once(w.webContents, 'did-navigate-in-page');
await w.webContents.executeJavaScript(`
window.history.pushState({}, '', '/new-path');
`);
await didNavigate;
const { start, end } = await performDrag(w);
expect(start).to.not.deep.equal(end);
});
});
describe('child windows (window.open)', () => {
let childWindow: BrowserWindow;
beforeEach(async () => {
const parentWindow = new BrowserWindow({ show: false });
await parentWindow.loadFile(
path.join(fixtures, 'pages', 'base-page.html')
);
parentWindow.webContents.setWindowOpenHandler(() => ({
action: 'allow',
overrideBrowserWindowOptions: testWindowOpts
}));
const newBrowserWindow = once(parentWindow.webContents, 'did-create-window');
await parentWindow.webContents.executeJavaScript(
`void window.open('${draggablePageURL.href}', '_blank');`
);
[childWindow] = await newBrowserWindow;
await once(childWindow, 'ready-to-show');
});
it('drags with app-region: drag', async () => {
const { start, end } = await performDrag(childWindow);
expect(start).to.not.deep.equal(end);
});
it('drags after navigation', async () => {
await childWindow.loadURL(draggablePageURL.href);
const { start, end } = await performDrag(childWindow);
expect(start).to.not.deep.equal(end);
});
});
for (const frameType of ['webview', 'iframe'] as const) {
// FIXME: this behavior is broken before the tests were added
// See: https://github.com/electron/electron/issues/49256
describe.skip(`child frames (${frameType})`, () => {
const subframePageURL = frameType === 'webview' ? webviewPageURL : iframePageURL;
let w: BrowserWindow;
beforeEach(() => {
w = new BrowserWindow({
...testWindowOpts,
webPreferences: frameType === 'webview'
? {
webviewTag: true
}
: {}
});
});
it('drags in subframe with app-region: drag', async () => {
await w.loadURL(subframePageURL.href);
await loadDraggableSubframe(w);
const { start, end } = await performDrag(w);
expect(start).to.not.deep.equal(end);
});
it('drags after subframe navigation', async () => {
await w.loadURL(subframePageURL.href);
await loadDraggableSubframe(w);
await loadDraggableSubframe(w);
const { start, end } = await performDrag(w);
expect(start).to.not.deep.equal(end);
});
it('does not drag after host page navigation without draggable region', async () => {
await w.loadURL(subframePageURL.href);
await loadDraggableSubframe(w);
await w.loadFile(
path.join(fixtures, 'pages', 'base-page.html')
);
const { start, end } = await performDrag(w);
expect(start).to.deep.equal(end);
});
it('drags after host page navigation', async () => {
await w.loadURL(subframePageURL.href);
await loadDraggableSubframe(w);
await w.loadURL(subframePageURL.href);
await loadDraggableSubframe(w);
const { start, end } = await performDrag(w);
expect(start).to.not.deep.equal(end);
});
});
}
});

View File

@@ -1,22 +1,36 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Draggable Page</title>
<style>
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
app-region: drag;
<head>
<meta charset="UTF-8">
<title>Draggable Page</title>
<style>
.draggable {
-webkit-app-region: drag;
height: 100vh;
width: 100vw;
margin: 0;
background-color: #f0f0f0;
}
</style>
</head>
<body>
<div class="draggable"></div>
</body>
display: flex;
justify-content: center;
align-items: center;
}
#no-drag {
width: 100px;
height: 100px;
background-color: orangered;
app-region: no-drag;
}
</style>
</head>
<body>
<script>
if (window.location.search.includes('no-drag')) {
const noDragDiv = document.createElement('div');
noDragDiv.id = 'no-drag';
document.body.appendChild(noDragDiv);
}
</script>
</body>
</html>

27
spec/fixtures/pages/iframe.html vendored Normal file
View File

@@ -0,0 +1,27 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>iframe Host</title>
<style>
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
iframe {
width: 100%;
height: 100%;
border: none;
position: absolute;
top: 0;
left: 0;
}
</style>
</head>
<body>
<iframe></iframe>
</body>
</html>

26
spec/fixtures/pages/webview.html vendored Normal file
View File

@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>webview Host</title>
<style>
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
webview {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
</style>
</head>
<body>
<webview></webview>
</body>
</html>

View File

@@ -594,6 +594,7 @@ __metadata:
"@electron/github-app-auth": "npm:^3.2.0"
"@electron/lint-roller": "npm:^3.1.2"
"@electron/typescript-definitions": "npm:^9.1.5"
"@hurdlegroup/robotjs": "npm:^0.12.3"
"@octokit/rest": "npm:^20.1.2"
"@primer/octicons": "npm:^10.0.0"
"@types/minimist": "npm:^1.2.5"
@@ -1090,6 +1091,17 @@ __metadata:
languageName: node
linkType: hard
"@hurdlegroup/robotjs@npm:^0.12.3":
version: 0.12.3
resolution: "@hurdlegroup/robotjs@npm:0.12.3"
dependencies:
node-addon-api: "npm:*"
node-gyp: "npm:latest"
node-gyp-build: "npm:^4.8.1"
checksum: 10c0/6d5310320187bacfccaa7f636315b8d5d5a4ecdc586b7d3467b1def22ee31428d6b39f06dadb240e72d0ac4548068be1f896f2501dd7115d0100820a0640c562
languageName: node
linkType: hard
"@inquirer/external-editor@npm:^1.0.0":
version: 1.0.2
resolution: "@inquirer/external-editor@npm:1.0.2"
@@ -10019,6 +10031,15 @@ __metadata:
languageName: node
linkType: hard
"node-addon-api@npm:*":
version: 8.5.0
resolution: "node-addon-api@npm:8.5.0"
dependencies:
node-gyp: "npm:latest"
checksum: 10c0/e4de0b4e70998fed7ef41933946f60565fc3a17cb83b7d626a0c0bb1f734cf7852e0e596f12681e7c8ed424163ee3cdbb4f0abaa9cc269d03f48834c263ba162
languageName: node
linkType: hard
"node-addon-api@npm:8.0.0":
version: 8.0.0
resolution: "node-addon-api@npm:8.0.0"
@@ -10056,6 +10077,17 @@ __metadata:
languageName: node
linkType: hard
"node-gyp-build@npm:^4.8.1":
version: 4.8.4
resolution: "node-gyp-build@npm:4.8.4"
bin:
node-gyp-build: bin.js
node-gyp-build-optional: optional.js
node-gyp-build-test: build-test.js
checksum: 10c0/444e189907ece2081fe60e75368784f7782cfddb554b60123743dfb89509df89f1f29c03bbfa16b3a3e0be3f48799a4783f487da6203245fa5bed239ba7407e1
languageName: node
linkType: hard
"node-gyp@npm:^11.4.2, node-gyp@npm:latest":
version: 11.5.0
resolution: "node-gyp@npm:11.5.0"