Compare commits

...

1 Commits

Author SHA1 Message Date
Shelley Vohr
f3c5346e00 test: print tests on macOS/Windows 2026-03-23 15:50:39 +01:00
11 changed files with 416 additions and 0 deletions

View File

@@ -326,6 +326,67 @@ describe('webContents module', () => {
});
}).to.not.throw();
});
ifdescribe(process.platform !== 'linux')('print dialog', () => {
const printHandler = require('./fixtures/native-addon/print-handler');
beforeEach(async () => {
w = new BrowserWindow({ show: false });
await w.loadURL('data:text/html,<h1>Print Test</h1>');
w.show();
});
afterEach(() => {
printHandler.stopWatching();
closeAllWindows();
});
it('can cancel the print dialog', async () => {
// Arm the watcher BEFORE print() — the modal dialog blocks the
// JS event loop, so the watcher must already be running.
printHandler.startWatching('cancel');
const { success } = await new Promise<{ success: boolean; reason: string }>((resolve) => {
w.webContents.print({}, (success, reason) => {
resolve({ success, reason });
});
});
expect(printHandler.stopWatching()).to.equal(true);
expect(success).to.equal(false);
});
it('can show the print dialog with default options', async () => {
printHandler.startWatching('cancel');
const { success } = await new Promise<{ success: boolean; reason: string }>((resolve) => {
w.webContents.print({ silent: false }, (success, reason) => {
resolve({ success, reason });
});
});
expect(printHandler.stopWatching()).to.equal(true);
expect(success).to.equal(false);
});
it('can show the print dialog with custom options', async () => {
printHandler.startWatching('cancel');
const { success } = await new Promise<{ success: boolean; reason: string }>((resolve) => {
w.webContents.print({
silent: false,
copies: 2,
landscape: true,
color: false
}, (success, reason) => {
resolve({ success, reason });
});
});
expect(printHandler.stopWatching()).to.equal(true);
expect(success).to.equal(false);
});
});
});
describe('webContents.executeJavaScript', () => {

View File

@@ -0,0 +1 @@
build/

View File

@@ -0,0 +1,33 @@
{
'targets': [
{
'target_name': 'print_handler',
'sources': [
'src/main.cc',
],
'conditions': [
['OS=="mac"', {
'sources': [
'src/print_handler_mac.mm',
],
'libraries': [
'$(SDKROOT)/System/Library/Frameworks/AppKit.framework',
],
'xcode_settings': {
'CLANG_ENABLE_OBJC_ARC': 'YES',
},
}],
['OS=="win"', {
'sources': [
'src/print_handler_win.cc',
],
}],
['OS not in ["mac", "win"]', {
'sources': [
'src/print_handler_stub.cc',
],
}],
],
}
]
}

View File

@@ -0,0 +1,26 @@
const native = require('./build/Release/print_handler.node');
/**
* Arm the print-dialog watcher. Call this BEFORE triggering
* webContents.print() — the watcher uses an NSTimer in
* NSRunLoopCommonModes so it fires during the modal run loop
* that runModalWithPrintInfo: enters.
*
* @param {'cancel'|'print'} [action='cancel']
* @param {number} [timeoutMs=5000]
*/
function startWatching (action = 'cancel', timeoutMs = 5000) {
native.startWatching(action, timeoutMs);
}
/**
* Stop the watcher and return whether a dialog was dismissed.
* Call after the print callback has fired.
*
* @returns {boolean}
*/
function stopWatching () {
return native.stopWatching();
}
module.exports = { startWatching, stopWatching };

View File

@@ -0,0 +1,9 @@
{
"name": "@electron-ci/print-handler",
"version": "0.0.1",
"main": "./index.js",
"private": true,
"scripts": {
"install": "node-gyp configure && node-gyp build"
}
}

View File

@@ -0,0 +1,60 @@
#include <cstring>
#include <node_api.h>
#include "print_handler.h"
namespace {
// startWatching(action: string, timeoutMs?: number)
// Starts polling for a modal print dialog. Must be called BEFORE the dialog
// appears (i.e. before webContents.print()), because the modal dialog blocks
// the JS event loop.
napi_value StartWatching(napi_env env, napi_callback_info info) {
size_t argc = 2;
napi_value args[2];
napi_get_cb_info(env, info, &argc, args, NULL, NULL);
char action[16];
size_t action_len;
napi_get_value_string_utf8(env, args[0], action, sizeof(action), &action_len);
bool should_print = (strcmp(action, "print") == 0);
int timeout_ms = 5000;
if (argc >= 2) {
napi_valuetype vtype;
napi_typeof(env, args[1], &vtype);
if (vtype == napi_number)
napi_get_value_int32(env, args[1], &timeout_ms);
}
print_handler::StartWatching(should_print, timeout_ms);
return NULL;
}
// stopWatching() → boolean
// Stops the poller and returns true if a dialog was successfully dismissed.
napi_value StopWatching(napi_env env, napi_callback_info info) {
bool dismissed = print_handler::StopWatching();
napi_value result;
napi_get_boolean(env, dismissed, &result);
return result;
}
napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor descriptors[] = {
{"startWatching", NULL, StartWatching, NULL, NULL, NULL, napi_default,
NULL},
{"stopWatching", NULL, StopWatching, NULL, NULL, NULL, napi_default,
NULL},
};
napi_define_properties(
env, exports, sizeof(descriptors) / sizeof(*descriptors), descriptors);
return exports;
}
} // namespace
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)

View File

@@ -0,0 +1,21 @@
#ifndef SRC_PRINT_HANDLER_H_
#define SRC_PRINT_HANDLER_H_
namespace print_handler {
// Starts an NSTimer (in NSRunLoopCommonModes) that polls for a modal print
// dialog. When one appears, it dismisses it via [NSApp stopModalWithCode:].
// The timer fires during the modal run loop, which is required because
// runModalWithPrintInfo: blocks the main thread.
//
// |should_print| - true sends NSModalResponseOK, false sends
// NSModalResponseCancel.
// |timeout_ms| - give up after this many milliseconds.
void StartWatching(bool should_print, int timeout_ms);
// Stops the watcher timer and returns whether a modal dialog was dismissed.
bool StopWatching();
} // namespace print_handler
#endif // SRC_PRINT_HANDLER_H_

View File

@@ -0,0 +1,97 @@
#include "print_handler.h"
#import <Cocoa/Cocoa.h>
// Poller that runs during modal run loops (NSRunLoopCommonModes) to detect
// and dismiss the system print dialog shown by [NSPrintPanel
// runModalWithPrintInfo:].
@interface PrintDialogPoller : NSObject {
NSTimer* _timer;
BOOL _shouldPrint;
int _remainingTicks;
BOOL _dismissed;
}
@property(nonatomic, readonly) BOOL dismissed;
- (instancetype)initWithShouldPrint:(BOOL)shouldPrint timeoutMs:(int)timeoutMs;
- (void)start;
- (void)stop;
@end
@implementation PrintDialogPoller
@synthesize dismissed = _dismissed;
- (instancetype)initWithShouldPrint:(BOOL)shouldPrint timeoutMs:(int)timeoutMs {
if ((self = [super init])) {
_shouldPrint = shouldPrint;
_remainingTicks = timeoutMs / 100;
_dismissed = NO;
}
return self;
}
- (void)start {
// Use timerWithTimeInterval + addTimer:forMode: so the timer fires during
// modal event processing (NSPrintPanel's runModalWithPrintInfo: enters a
// nested run loop in NSModalPanelRunLoopMode, which is part of
// NSRunLoopCommonModes).
_timer = [NSTimer timerWithTimeInterval:0.1
target:self
selector:@selector(tick:)
userInfo:nil
repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
}
- (void)stop {
[_timer invalidate];
_timer = nil;
}
- (void)tick:(NSTimer*)timer {
--_remainingTicks;
if ([NSApp modalWindow] != nil) {
NSModalResponse code =
_shouldPrint ? NSModalResponseOK : NSModalResponseCancel;
[NSApp stopModalWithCode:code];
_dismissed = YES;
[self stop];
return;
}
if (_remainingTicks <= 0) {
[self stop];
}
}
@end
// ---------- C++ API ----------
static PrintDialogPoller* g_poller = nil;
namespace print_handler {
void StartWatching(bool should_print, int timeout_ms) {
// Stop any previously running poller.
if (g_poller) {
[g_poller stop];
g_poller = nil;
}
g_poller = [[PrintDialogPoller alloc] initWithShouldPrint:should_print
timeoutMs:timeout_ms];
[g_poller start];
}
bool StopWatching() {
if (!g_poller)
return false;
bool result = [g_poller dismissed];
[g_poller stop];
g_poller = nil;
return result;
}
} // namespace print_handler

View File

@@ -0,0 +1,11 @@
#include "print_handler.h"
namespace print_handler {
void StartWatching(bool should_print, int timeout_ms) {}
bool StopWatching() {
return false;
}
} // namespace print_handler

View File

@@ -0,0 +1,91 @@
#include "print_handler.h"
#include <windows.h>
#include <atomic>
#include <thread>
namespace {
std::atomic<bool> g_watching{false};
std::atomic<bool> g_dismissed{false};
std::atomic<bool> g_should_print{false};
std::thread g_thread;
// EnumWindows callback — looks for a visible dialog box (#32770) belonging
// to the current process. In the test environment the only dialog that
// appears after webContents.print() is the system print dialog shown by
// PrintDlgEx().
BOOL CALLBACK FindPrintDialog(HWND hwnd, LPARAM lParam) {
// Must be visible.
if (!IsWindowVisible(hwnd))
return TRUE;
// Must belong to our process.
DWORD pid = 0;
GetWindowThreadProcessId(hwnd, &pid);
if (pid != GetCurrentProcessId())
return TRUE;
// Must be a standard dialog class (#32770).
char class_name[64];
GetClassNameA(hwnd, class_name, sizeof(class_name));
if (strcmp(class_name, "#32770") != 0)
return TRUE;
*reinterpret_cast<HWND*>(lParam) = hwnd;
return FALSE; // stop enumeration
}
void WatcherThread(int timeout_ms) {
const int kIntervalMs = 100;
int elapsed = 0;
while (g_watching.load() && elapsed < timeout_ms) {
HWND dialog = NULL;
EnumWindows(FindPrintDialog, reinterpret_cast<LPARAM>(&dialog));
if (dialog) {
// IDOK = Print, IDCANCEL = Cancel. PostMessage is safe from any
// thread — the message is processed by the nested message loop
// inside PrintDlgEx().
WPARAM cmd = g_should_print.load() ? IDOK : IDCANCEL;
PostMessage(dialog, WM_COMMAND, cmd, 0);
g_dismissed.store(true);
g_watching.store(false);
return;
}
Sleep(kIntervalMs);
elapsed += kIntervalMs;
}
g_watching.store(false);
}
} // namespace
namespace print_handler {
void StartWatching(bool should_print, int timeout_ms) {
// Tear down any previous watcher.
StopWatching();
g_should_print.store(should_print);
g_dismissed.store(false);
g_watching.store(true);
g_thread = std::thread(WatcherThread, timeout_ms);
}
bool StopWatching() {
g_watching.store(false);
if (g_thread.joinable())
g_thread.join();
bool result = g_dismissed.load();
g_dismissed.store(false);
return result;
}
} // namespace print_handler

View File

@@ -642,6 +642,12 @@ __metadata:
languageName: unknown
linkType: soft
"@electron-ci/print-handler@workspace:spec/fixtures/native-addon/print-handler":
version: 0.0.0-use.local
resolution: "@electron-ci/print-handler@workspace:spec/fixtures/native-addon/print-handler"
languageName: unknown
linkType: soft
"@electron-ci/uv-dlopen@npm:*, @electron-ci/uv-dlopen@workspace:spec/fixtures/native-addon/uv-dlopen":
version: 0.0.0-use.local
resolution: "@electron-ci/uv-dlopen@workspace:spec/fixtures/native-addon/uv-dlopen"