test: add interactive macOS dialog tests (#50528)

Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com>
Co-authored-by: Shelley Vohr <shelley.vohr@gmail.com>
This commit is contained in:
trop[bot]
2026-03-27 08:20:36 -04:00
committed by GitHub
parent 33a81b40c2
commit c63e0d8b96
11 changed files with 1453 additions and 1 deletions

1
.gitignore vendored
View File

@@ -42,6 +42,7 @@ spec/.hash
# Generated native addon files
/spec/fixtures/native-addon/echo/build/
/spec/fixtures/native-addon/dialog-helper/build/
# If someone runs tsc this is where stuff will end up
ts-gen

View File

@@ -2,9 +2,10 @@ import { dialog, BaseWindow, BrowserWindow } from 'electron/main';
import { expect } from 'chai';
import * as path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { ifit } from './lib/spec-helpers';
import { ifdescribe, ifit } from './lib/spec-helpers';
import { closeAllWindows } from './lib/window-helpers';
describe('dialog module', () => {
@@ -243,4 +244,785 @@ describe('dialog module', () => {
}).to.throw(/message must be a string/);
});
});
ifdescribe(process.platform === 'darwin' && !process.env.ELECTRON_SKIP_NATIVE_MODULE_TESTS)('end-to-end dialog interaction (macOS)', () => {
let dialogHelper: any;
before(() => {
dialogHelper = require('@electron-ci/dialog-helper');
});
afterEach(closeAllWindows);
// Poll for a sheet to appear on the given window.
async function waitForSheet (w: BrowserWindow): Promise<void> {
const handle = w.getNativeWindowHandle();
for (let i = 0; i < 50; i++) {
const info = dialogHelper.getDialogInfo(handle);
if (info.type !== 'none') return;
await setTimeout(100);
}
throw new Error('Timed out waiting for dialog sheet to appear');
}
describe('showMessageBox', () => {
it('shows the correct message and buttons', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Test message',
buttons: ['OK', 'Cancel']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.type).to.equal('message-box');
expect(info.message).to.equal('Test message');
const buttons = JSON.parse(info.buttons);
expect(buttons).to.include('OK');
expect(buttons).to.include('Cancel');
dialogHelper.clickMessageBoxButton(handle, 0);
await p;
});
it('shows detail text', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Main message',
detail: 'Extra detail text',
buttons: ['OK']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.message).to.equal('Main message');
expect(info.detail).to.equal('Extra detail text');
dialogHelper.clickMessageBoxButton(handle, 0);
await p;
});
it('returns the correct response when a specific button is clicked', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Choose a button',
buttons: ['First', 'Second', 'Third']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
dialogHelper.clickMessageBoxButton(handle, 1);
const result = await p;
expect(result.response).to.equal(1);
});
it('returns the correct response when the last button is clicked', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Choose a button',
buttons: ['Yes', 'No', 'Maybe']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
dialogHelper.clickMessageBoxButton(handle, 2);
const result = await p;
expect(result.response).to.equal(2);
});
it('shows a single button when no buttons are specified', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'No buttons specified'
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.type).to.equal('message-box');
// macOS adds a default "OK" button when none are specified.
const buttons = JSON.parse(info.buttons);
expect(buttons).to.have.lengthOf(1);
dialogHelper.clickMessageBoxButton(handle, 0);
const result = await p;
expect(result.response).to.equal(0);
});
it('renders checkbox with the correct label and initial state', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Checkbox test',
buttons: ['OK'],
checkboxLabel: 'Do not show again',
checkboxChecked: false
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.checkboxLabel).to.equal('Do not show again');
expect(info.checkboxChecked).to.be.false();
dialogHelper.clickMessageBoxButton(handle, 0);
const result = await p;
expect(result.checkboxChecked).to.be.false();
});
it('returns checkboxChecked as true when checkbox is initially checked', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Pre-checked checkbox',
buttons: ['OK'],
checkboxLabel: 'Remember my choice',
checkboxChecked: true
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.checkboxLabel).to.equal('Remember my choice');
expect(info.checkboxChecked).to.be.true();
dialogHelper.clickMessageBoxButton(handle, 0);
const result = await p;
expect(result.checkboxChecked).to.be.true();
});
it('can toggle checkbox and returns updated state', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Toggle test',
buttons: ['OK'],
checkboxLabel: 'Toggle me',
checkboxChecked: false
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
// Verify initially unchecked.
let info = dialogHelper.getDialogInfo(handle);
expect(info.checkboxChecked).to.be.false();
// Click the checkbox to check it.
dialogHelper.clickCheckbox(handle);
info = dialogHelper.getDialogInfo(handle);
expect(info.checkboxChecked).to.be.true();
dialogHelper.clickMessageBoxButton(handle, 0);
const result = await p;
expect(result.checkboxChecked).to.be.true();
});
it('strips access keys on macOS with normalizeAccessKeys', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Access key test',
buttons: ['&Save', '&Cancel'],
normalizeAccessKeys: true
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
// On macOS, ampersands are stripped by normalizeAccessKeys.
const buttons = JSON.parse(info.buttons);
expect(buttons).to.include('Save');
expect(buttons).to.include('Cancel');
expect(buttons).not.to.include('&Save');
expect(buttons).not.to.include('&Cancel');
dialogHelper.clickMessageBoxButton(handle, 0);
await p;
});
it('respects defaultId by making it the default button', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Default button test',
buttons: ['One', 'Two', 'Three'],
defaultId: 2
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
const buttons = JSON.parse(info.buttons);
expect(buttons).to.deep.equal(['One', 'Two', 'Three']);
dialogHelper.clickMessageBoxButton(handle, 2);
const result = await p;
expect(result.response).to.equal(2);
});
it('respects cancelId and returns it when cancelled via signal', async () => {
const controller = new AbortController();
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Cancel ID test',
buttons: ['OK', 'Dismiss', 'Abort'],
cancelId: 2,
signal: controller.signal
});
await waitForSheet(w);
controller.abort();
const result = await p;
expect(result.response).to.equal(2);
});
it('works with all message box types', async () => {
const types: Array<'none' | 'info' | 'warning' | 'error' | 'question'> =
['none', 'info', 'warning', 'error', 'question'];
for (const type of types) {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: `Type: ${type}`,
type,
buttons: ['OK']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.type).to.equal('message-box');
expect(info.message).to.equal(`Type: ${type}`);
dialogHelper.clickMessageBoxButton(handle, 0);
await p;
w.destroy();
// Allow the event loop to settle between iterations to avoid
// Chromium DCHECK failures from rapid window lifecycle churn.
await setTimeout(100);
}
});
});
describe('showOpenDialog', () => {
it('can cancel an open dialog', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
title: 'Test Open',
properties: ['openFile']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.type).to.equal('open-dialog');
dialogHelper.cancelFileDialog(handle);
const result = await p;
expect(result.canceled).to.be.true();
expect(result.filePaths).to.have.lengthOf(0);
});
it('sets a custom button label', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
buttonLabel: 'Select This',
properties: ['openFile']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.prompt).to.equal('Select This');
dialogHelper.cancelFileDialog(handle);
await p;
});
it('sets a message on the dialog', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
message: 'Choose a file to import',
properties: ['openFile']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.panelMessage).to.equal('Choose a file to import');
dialogHelper.cancelFileDialog(handle);
await p;
});
it('defaults to openFile with canChooseFiles enabled', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.canChooseFiles).to.be.true();
expect(info.canChooseDirectories).to.be.false();
expect(info.allowsMultipleSelection).to.be.false();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('enables directory selection with openDirectory', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.canChooseDirectories).to.be.true();
// openFile is not set, so canChooseFiles should be false
expect(info.canChooseFiles).to.be.false();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('enables both file and directory selection together', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile', 'openDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.canChooseFiles).to.be.true();
expect(info.canChooseDirectories).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('enables multiple selection with multiSelections', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile', 'multiSelections']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.allowsMultipleSelection).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('shows hidden files with showHiddenFiles', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile', 'showHiddenFiles']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.showsHiddenFiles).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('does not show hidden files by default', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.showsHiddenFiles).to.be.false();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('disables alias resolution with noResolveAliases', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile', 'noResolveAliases']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.resolvesAliases).to.be.false();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('resolves aliases by default', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.resolvesAliases).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('treats packages as directories with treatPackageAsDirectory', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile', 'treatPackageAsDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.treatsPackagesAsDirectories).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('enables directory creation with createDirectory', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile', 'createDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.canCreateDirectories).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('sets the default path directory', async () => {
const defaultDir = path.join(__dirname, 'fixtures');
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
defaultPath: defaultDir,
properties: ['openFile']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.directory).to.equal(defaultDir);
dialogHelper.cancelFileDialog(handle);
await p;
});
it('applies multiple properties simultaneously', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
title: 'Multi-Property Test',
buttonLabel: 'Pick',
message: 'Select items',
properties: [
'openFile',
'openDirectory',
'multiSelections',
'showHiddenFiles',
'createDirectory',
'treatPackageAsDirectory',
'noResolveAliases'
]
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.type).to.equal('open-dialog');
expect(info.prompt).to.equal('Pick');
expect(info.panelMessage).to.equal('Select items');
expect(info.canChooseFiles).to.be.true();
expect(info.canChooseDirectories).to.be.true();
expect(info.allowsMultipleSelection).to.be.true();
expect(info.showsHiddenFiles).to.be.true();
expect(info.canCreateDirectories).to.be.true();
expect(info.treatsPackagesAsDirectories).to.be.true();
expect(info.resolvesAliases).to.be.false();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('can accept an open dialog and return a file path', async () => {
const targetDir = path.join(__dirname, 'fixtures');
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
defaultPath: targetDir,
properties: ['openDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
dialogHelper.acceptFileDialog(handle);
const result = await p;
expect(result.canceled).to.be.false();
expect(result.filePaths).to.have.lengthOf(1);
expect(result.filePaths[0]).to.equal(targetDir);
});
});
describe('showSaveDialog', () => {
it('can cancel a save dialog', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
title: 'Test Save'
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.type).to.equal('save-dialog');
dialogHelper.cancelFileDialog(handle);
const result = await p;
expect(result.canceled).to.be.true();
expect(result.filePath).to.equal('');
});
it('can accept a save dialog with a filename', async () => {
const defaultDir = path.join(__dirname, 'fixtures');
const filename = 'test-save-output.txt';
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
title: 'Test Save',
defaultPath: path.join(defaultDir, filename)
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
dialogHelper.acceptFileDialog(handle);
const result = await p;
expect(result.canceled).to.be.false();
expect(result.filePath).to.equal(path.join(defaultDir, filename));
});
it('sets a custom button label', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
buttonLabel: 'Export'
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.prompt).to.equal('Export');
dialogHelper.cancelFileDialog(handle);
await p;
});
it('sets a message on the dialog', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
message: 'Choose where to save'
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.panelMessage).to.equal('Choose where to save');
dialogHelper.cancelFileDialog(handle);
await p;
});
it('sets a custom name field label', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
nameFieldLabel: 'Export As:'
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.nameFieldLabel).to.equal('Export As:');
dialogHelper.cancelFileDialog(handle);
await p;
});
it('sets the default filename from defaultPath', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
defaultPath: path.join(__dirname, 'fixtures', 'my-document.txt')
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.nameFieldValue).to.equal('my-document.txt');
dialogHelper.cancelFileDialog(handle);
await p;
});
it('sets the default directory from defaultPath', async () => {
const defaultDir = path.join(__dirname, 'fixtures');
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
defaultPath: path.join(defaultDir, 'some-file.txt')
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.directory).to.equal(defaultDir);
dialogHelper.cancelFileDialog(handle);
await p;
});
it('hides the tag field when showsTagField is false', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
showsTagField: false
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.showsTagField).to.be.false();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('shows the tag field by default', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.showsTagField).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('enables directory creation with createDirectory', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
properties: ['createDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.canCreateDirectories).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('shows hidden files with showHiddenFiles', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
properties: ['showHiddenFiles']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.showsHiddenFiles).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('does not show hidden files by default', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.showsHiddenFiles).to.be.false();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('treats packages as directories with treatPackageAsDirectory', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
properties: ['treatPackageAsDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.treatsPackagesAsDirectories).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('applies multiple options simultaneously', async () => {
const defaultDir = path.join(__dirname, 'fixtures');
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
buttonLabel: 'Save Now',
message: 'Pick a location',
nameFieldLabel: 'File Name:',
defaultPath: path.join(defaultDir, 'output.txt'),
showsTagField: false,
properties: ['showHiddenFiles', 'createDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.type).to.equal('save-dialog');
expect(info.prompt).to.equal('Save Now');
expect(info.panelMessage).to.equal('Pick a location');
expect(info.nameFieldLabel).to.equal('File Name:');
expect(info.nameFieldValue).to.equal('output.txt');
expect(info.directory).to.equal(defaultDir);
expect(info.showsTagField).to.be.false();
expect(info.showsHiddenFiles).to.be.true();
expect(info.canCreateDirectories).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
});
});
});

View File

@@ -0,0 +1,7 @@
/node_modules
/build
*.swp
*.log
*~
.node-version
package-lock.json

View File

@@ -0,0 +1,23 @@
{
'targets': [
{
'target_name': 'dialog_helper',
'conditions': [
['OS=="mac"', {
'sources': [
'src/main.cc',
'src/dialog_helper_mac.mm',
],
'libraries': [
'$(SDKROOT)/System/Library/Frameworks/AppKit.framework',
],
'xcode_settings': {
'OTHER_CFLAGS': ['-fobjc-arc'],
},
}, {
'type': 'none',
}],
],
}
]
}

View File

@@ -0,0 +1,2 @@
const binding = require('../build/Release/dialog_helper.node');
module.exports = binding;

View File

@@ -0,0 +1,10 @@
{
"name": "@electron-ci/dialog-helper",
"version": "0.0.1",
"main": "./lib/index.js",
"private": true,
"licenses": "MIT",
"scripts": {
"install": "node-gyp configure && node-gyp build"
}
}

View File

@@ -0,0 +1,68 @@
#ifndef SRC_DIALOG_HELPER_H_
#define SRC_DIALOG_HELPER_H_
#include <cstddef>
#include <string>
namespace dialog_helper {
struct DialogInfo {
// "message-box", "open-dialog", "save-dialog", or "none"
std::string type;
// Button titles for message boxes
std::string buttons; // JSON array string, e.g. '["OK","Cancel"]'
// Message text (NSAlert messageText or panel title)
std::string message;
// Detail / informative text (NSAlert informativeText)
std::string detail;
// Checkbox (suppression button) label, empty if none
std::string checkbox_label;
// Whether the checkbox is checked
bool checkbox_checked = false;
// File dialog properties (open/save panels)
std::string prompt; // Button label (NSSavePanel prompt)
std::string panel_message; // Panel message text (NSSavePanel message)
std::string directory; // Current directory URL path
// NSSavePanel-specific properties
std::string name_field_label; // Label for the name field
std::string name_field_value; // Current value of the name field
bool shows_tag_field = true;
// NSOpenPanel-specific properties
bool can_choose_files = false;
bool can_choose_directories = false;
bool allows_multiple_selection = false;
// Shared panel properties (open and save)
bool shows_hidden_files = false;
bool resolves_aliases = true;
bool treats_packages_as_directories = false;
bool can_create_directories = false;
};
// Get information about the sheet dialog attached to the window identified
// by the given native handle buffer (NSView* on macOS).
DialogInfo GetDialogInfo(char* handle, size_t size);
// Click a button at the given index on an NSAlert sheet attached to the window.
// Returns true if a message box was found and the button was clicked.
bool ClickMessageBoxButton(char* handle, size_t size, int button_index);
// Toggle the checkbox (suppression button) on an NSAlert sheet.
// Returns true if a checkbox was found and clicked.
bool ClickCheckbox(char* handle, size_t size);
// Cancel the file dialog (NSOpenPanel/NSSavePanel) sheet attached to the window.
// Returns true if a file dialog was found and cancelled.
bool CancelFileDialog(char* handle, size_t size);
// Accept the file dialog sheet attached to the window.
// For save dialogs, |filename| is set in the name field before accepting.
// Returns true if a file dialog was found and accepted.
bool AcceptFileDialog(char* handle, size_t size, const std::string& filename);
} // namespace dialog_helper
#endif // SRC_DIALOG_HELPER_H_

View File

@@ -0,0 +1,320 @@
#include "dialog_helper.h"
#import <Cocoa/Cocoa.h>
namespace {
// Extract the NSWindow* from the native handle buffer.
// The buffer contains an NSView* (the content view of the window).
NSWindow* GetNSWindowFromHandle(char* handle, size_t size) {
if (size != sizeof(void*))
return nil;
// Read the raw pointer from the buffer, then bridge to ARC.
void* raw = *reinterpret_cast<void**>(handle);
NSView* view = (__bridge NSView*)raw;
if (!view || ![view isKindOfClass:[NSView class]])
return nil;
return [view window];
}
} // namespace
namespace dialog_helper {
DialogInfo GetDialogInfo(char* handle, size_t size) {
DialogInfo info;
info.type = "none";
NSWindow* window = GetNSWindowFromHandle(handle, size);
if (!window)
return info;
NSWindow* sheet = [window attachedSheet];
if (!sheet)
return info;
// NSOpenPanel is a subclass of NSSavePanel, so check NSOpenPanel first.
if ([sheet isKindOfClass:[NSOpenPanel class]]) {
info.type = "open-dialog";
NSOpenPanel* panel = (NSOpenPanel*)sheet;
info.message = [[panel title] UTF8String] ?: "";
info.prompt = [[panel prompt] UTF8String] ?: "";
info.panel_message = [[panel message] UTF8String] ?: "";
if ([panel directoryURL])
info.directory = [[[panel directoryURL] path] UTF8String] ?: "";
info.can_choose_files = [panel canChooseFiles];
info.can_choose_directories = [panel canChooseDirectories];
info.allows_multiple_selection = [panel allowsMultipleSelection];
info.shows_hidden_files = [panel showsHiddenFiles];
info.resolves_aliases = [panel resolvesAliases];
info.treats_packages_as_directories = [panel treatsFilePackagesAsDirectories];
info.can_create_directories = [panel canCreateDirectories];
return info;
}
if ([sheet isKindOfClass:[NSSavePanel class]]) {
info.type = "save-dialog";
NSSavePanel* panel = (NSSavePanel*)sheet;
info.message = [[panel title] UTF8String] ?: "";
info.prompt = [[panel prompt] UTF8String] ?: "";
info.panel_message = [[panel message] UTF8String] ?: "";
if ([panel directoryURL])
info.directory = [[[panel directoryURL] path] UTF8String] ?: "";
info.name_field_label = [[panel nameFieldLabel] UTF8String] ?: "";
info.name_field_value = [[panel nameFieldStringValue] UTF8String] ?: "";
info.shows_tag_field = [panel showsTagField];
info.shows_hidden_files = [panel showsHiddenFiles];
info.treats_packages_as_directories =
[panel treatsFilePackagesAsDirectories];
info.can_create_directories = [panel canCreateDirectories];
return info;
}
// For NSAlert, the sheet window is not an NSSavePanel.
// Check if it contains typical NSAlert button structure.
// NSAlert's window contains buttons as subviews in its content view.
NSView* contentView = [sheet contentView];
NSMutableArray<NSButton*>* buttons = [NSMutableArray array];
// Recursively find all NSButton instances in the view hierarchy.
NSMutableArray<NSView*>* stack =
[NSMutableArray arrayWithObject:contentView];
while ([stack count] > 0) {
NSView* current = [stack lastObject];
[stack removeLastObject];
if ([current isKindOfClass:[NSButton class]]) {
NSButton* btn = (NSButton*)current;
// Filter to push-type buttons (not checkboxes, radio buttons, etc.)
if ([btn bezelStyle] == NSBezelStyleRounded ||
[btn bezelStyle] == NSBezelStyleRegularSquare) {
[buttons addObject:btn];
}
}
for (NSView* subview in [current subviews]) {
[stack addObject:subview];
}
}
if ([buttons count] > 0) {
info.type = "message-box";
// Sort buttons by tag to maintain the order they were added.
[buttons sortUsingComparator:^NSComparisonResult(NSButton* a, NSButton* b) {
if ([a tag] < [b tag])
return NSOrderedAscending;
if ([a tag] > [b tag])
return NSOrderedDescending;
return NSOrderedSame;
}];
std::string btn_json = "[";
for (NSUInteger i = 0; i < [buttons count]; i++) {
if (i > 0)
btn_json += ",";
btn_json += "\"";
NSString* title = [[buttons objectAtIndex:i] title];
btn_json += [title UTF8String] ?: "";
btn_json += "\"";
}
btn_json += "]";
info.buttons = btn_json;
// NSAlert's content view contains static NSTextFields for message and
// detail text. The first non-editable text field with content is the
// message; the second is the detail (informative text).
int text_field_index = 0;
// Walk all subviews (non-recursive — NSAlert places labels directly).
for (NSView* subview in [contentView subviews]) {
if ([subview isKindOfClass:[NSTextField class]]) {
NSTextField* field = (NSTextField*)subview;
if (![field isEditable] && [[field stringValue] length] > 0) {
if (text_field_index == 0) {
info.message = [[field stringValue] UTF8String];
} else if (text_field_index == 1) {
info.detail = [[field stringValue] UTF8String];
}
text_field_index++;
}
}
}
// Check for the suppression (checkbox) button.
// NSAlert's suppression button is a non-bordered NSButton, unlike
// push buttons which are bordered. This reliably identifies it
// across macOS versions where the accessibility role may differ.
NSMutableArray<NSView*>* cbStack =
[NSMutableArray arrayWithObject:contentView];
while ([cbStack count] > 0) {
NSView* current = [cbStack lastObject];
[cbStack removeLastObject];
if ([current isKindOfClass:[NSButton class]]) {
NSButton* btn = (NSButton*)current;
if (![btn isBordered]) {
NSString* title = [btn title];
if (title && [title length] > 0) {
info.checkbox_label = [title UTF8String];
info.checkbox_checked =
([btn state] == NSControlStateValueOn);
}
}
}
for (NSView* sub in [current subviews]) {
[cbStack addObject:sub];
}
}
}
return info;
}
bool ClickMessageBoxButton(char* handle, size_t size, int button_index) {
NSWindow* window = GetNSWindowFromHandle(handle, size);
if (!window)
return false;
NSWindow* sheet = [window attachedSheet];
if (!sheet)
return false;
// Find buttons in the sheet, sorted by tag.
NSView* contentView = [sheet contentView];
NSMutableArray<NSButton*>* buttons = [NSMutableArray array];
NSMutableArray<NSView*>* stack =
[NSMutableArray arrayWithObject:contentView];
while ([stack count] > 0) {
NSView* current = [stack lastObject];
[stack removeLastObject];
if ([current isKindOfClass:[NSButton class]]) {
NSButton* btn = (NSButton*)current;
if ([btn bezelStyle] == NSBezelStyleRounded ||
[btn bezelStyle] == NSBezelStyleRegularSquare) {
[buttons addObject:btn];
}
}
for (NSView* subview in [current subviews]) {
[stack addObject:subview];
}
}
[buttons sortUsingComparator:^NSComparisonResult(NSButton* a, NSButton* b) {
if ([a tag] < [b tag])
return NSOrderedAscending;
if ([a tag] > [b tag])
return NSOrderedDescending;
return NSOrderedSame;
}];
if (button_index < 0 || button_index >= (int)[buttons count])
return false;
NSButton* target = [buttons objectAtIndex:button_index];
[target performClick:nil];
return true;
}
bool ClickCheckbox(char* handle, size_t size) {
NSWindow* window = GetNSWindowFromHandle(handle, size);
if (!window)
return false;
NSWindow* sheet = [window attachedSheet];
if (!sheet)
return false;
// Find the suppression/checkbox button — it is a non-bordered NSButton,
// unlike the push buttons which are bordered.
NSView* contentView = [sheet contentView];
NSMutableArray<NSView*>* stack =
[NSMutableArray arrayWithObject:contentView];
while ([stack count] > 0) {
NSView* current = [stack lastObject];
[stack removeLastObject];
if ([current isKindOfClass:[NSButton class]]) {
NSButton* btn = (NSButton*)current;
if (![btn isBordered] && [[btn title] length] > 0) {
[btn performClick:nil];
return true;
}
}
for (NSView* subview in [current subviews]) {
[stack addObject:subview];
}
}
return false;
}
bool CancelFileDialog(char* handle, size_t size) {
NSWindow* window = GetNSWindowFromHandle(handle, size);
if (!window)
return false;
NSWindow* sheet = [window attachedSheet];
if (!sheet)
return false;
// sheet is the NSSavePanel/NSOpenPanel window itself when presented as a
// sheet. We need to find the actual panel object. On macOS, when an
// NSSavePanel is run as a sheet, [window attachedSheet] returns the panel's
// window. The panel can be retrieved because NSSavePanel IS the window.
if ([sheet isKindOfClass:[NSSavePanel class]]) {
NSSavePanel* panel = (NSSavePanel*)sheet;
[panel cancel:nil];
return true;
}
// If it's not a recognized panel type, try ending the sheet directly.
[NSApp endSheet:sheet returnCode:NSModalResponseCancel];
return true;
}
bool AcceptFileDialog(char* handle, size_t size, const std::string& filename) {
NSWindow* window = GetNSWindowFromHandle(handle, size);
if (!window)
return false;
NSWindow* sheet = [window attachedSheet];
if (!sheet)
return false;
if (![sheet isKindOfClass:[NSSavePanel class]])
return false;
NSSavePanel* panel = (NSSavePanel*)sheet;
// Set the filename if provided (for save dialogs).
if (!filename.empty()) {
NSString* name = [NSString stringWithUTF8String:filename.c_str()];
[panel setNameFieldStringValue:name];
// Resign first responder to commit the name field edit. Without this,
// the panel may still use the previous value (e.g. "Untitled") when
// the accept button is clicked immediately after.
[sheet makeFirstResponder:nil];
}
NSView* contentView = [sheet contentView];
// Search for the default button (key equivalent "\r") in the view hierarchy.
NSMutableArray<NSView*>* stack =
[NSMutableArray arrayWithObject:contentView];
while ([stack count] > 0) {
NSView* current = [stack lastObject];
[stack removeLastObject];
if ([current isKindOfClass:[NSButton class]]) {
NSButton* btn = (NSButton*)current;
if ([[btn keyEquivalent] isEqualToString:@"\r"]) {
[btn performClick:nil];
return true;
}
}
for (NSView* subview in [current subviews]) {
[stack addObject:subview];
}
}
[NSApp endSheet:sheet returnCode:NSModalResponseOK];
return true;
}
} // namespace dialog_helper

View File

@@ -0,0 +1,231 @@
#include <js_native_api.h>
#include <node_api.h>
#include <string>
#include "dialog_helper.h"
namespace {
// Helper: extract (char* data, size_t length) from the first Buffer argument.
bool GetHandleArg(napi_env env, napi_callback_info info, size_t expected_argc,
napi_value* args, char** data, size_t* length) {
size_t argc = expected_argc;
napi_status status = napi_get_cb_info(env, info, &argc, args, NULL, NULL);
if (status != napi_ok || argc < 1)
return false;
bool is_buffer;
status = napi_is_buffer(env, args[0], &is_buffer);
if (status != napi_ok || !is_buffer) {
napi_throw_error(env, NULL, "First argument must be a Buffer (native window handle)");
return false;
}
status = napi_get_buffer_info(env, args[0], (void**)data, length);
return status == napi_ok;
}
napi_value GetDialogInfo(napi_env env, napi_callback_info info) {
napi_value args[1];
char* data;
size_t length;
if (!GetHandleArg(env, info, 1, args, &data, &length))
return NULL;
dialog_helper::DialogInfo di = dialog_helper::GetDialogInfo(data, length);
napi_value result;
napi_create_object(env, &result);
// Message box properties
napi_value type_val;
napi_create_string_utf8(env, di.type.c_str(), di.type.size(), &type_val);
napi_set_named_property(env, result, "type", type_val);
napi_value buttons_val;
napi_create_string_utf8(env, di.buttons.c_str(), di.buttons.size(), &buttons_val);
napi_set_named_property(env, result, "buttons", buttons_val);
napi_value message_val;
napi_create_string_utf8(env, di.message.c_str(), di.message.size(), &message_val);
napi_set_named_property(env, result, "message", message_val);
napi_value detail_val;
napi_create_string_utf8(env, di.detail.c_str(), di.detail.size(), &detail_val);
napi_set_named_property(env, result, "detail", detail_val);
napi_value checkbox_label_val;
napi_create_string_utf8(env, di.checkbox_label.c_str(),
di.checkbox_label.size(), &checkbox_label_val);
napi_set_named_property(env, result, "checkboxLabel", checkbox_label_val);
napi_value checkbox_checked_val;
napi_get_boolean(env, di.checkbox_checked, &checkbox_checked_val);
napi_set_named_property(env, result, "checkboxChecked", checkbox_checked_val);
// File dialog properties
napi_value prompt_val;
napi_create_string_utf8(env, di.prompt.c_str(), di.prompt.size(), &prompt_val);
napi_set_named_property(env, result, "prompt", prompt_val);
napi_value panel_message_val;
napi_create_string_utf8(env, di.panel_message.c_str(),
di.panel_message.size(), &panel_message_val);
napi_set_named_property(env, result, "panelMessage", panel_message_val);
napi_value directory_val;
napi_create_string_utf8(env, di.directory.c_str(), di.directory.size(),
&directory_val);
napi_set_named_property(env, result, "directory", directory_val);
// NSSavePanel-specific string/boolean properties
napi_value name_field_label_val;
napi_create_string_utf8(env, di.name_field_label.c_str(),
di.name_field_label.size(), &name_field_label_val);
napi_set_named_property(env, result, "nameFieldLabel", name_field_label_val);
napi_value name_field_value_val;
napi_create_string_utf8(env, di.name_field_value.c_str(),
di.name_field_value.size(), &name_field_value_val);
napi_set_named_property(env, result, "nameFieldValue", name_field_value_val);
napi_value shows_tag_field_val;
napi_get_boolean(env, di.shows_tag_field, &shows_tag_field_val);
napi_set_named_property(env, result, "showsTagField", shows_tag_field_val);
// NSOpenPanel-specific properties
napi_value can_choose_files_val;
napi_get_boolean(env, di.can_choose_files, &can_choose_files_val);
napi_set_named_property(env, result, "canChooseFiles", can_choose_files_val);
napi_value can_choose_dirs_val;
napi_get_boolean(env, di.can_choose_directories, &can_choose_dirs_val);
napi_set_named_property(env, result, "canChooseDirectories",
can_choose_dirs_val);
napi_value allows_multi_val;
napi_get_boolean(env, di.allows_multiple_selection, &allows_multi_val);
napi_set_named_property(env, result, "allowsMultipleSelection",
allows_multi_val);
// Shared panel properties (open and save)
napi_value shows_hidden_val;
napi_get_boolean(env, di.shows_hidden_files, &shows_hidden_val);
napi_set_named_property(env, result, "showsHiddenFiles", shows_hidden_val);
napi_value resolves_aliases_val;
napi_get_boolean(env, di.resolves_aliases, &resolves_aliases_val);
napi_set_named_property(env, result, "resolvesAliases", resolves_aliases_val);
napi_value treats_packages_val;
napi_get_boolean(env, di.treats_packages_as_directories, &treats_packages_val);
napi_set_named_property(env, result, "treatsPackagesAsDirectories",
treats_packages_val);
napi_value can_create_dirs_val;
napi_get_boolean(env, di.can_create_directories, &can_create_dirs_val);
napi_set_named_property(env, result, "canCreateDirectories",
can_create_dirs_val);
return result;
}
napi_value ClickMessageBoxButton(napi_env env, napi_callback_info info) {
napi_value args[2];
char* data;
size_t length;
if (!GetHandleArg(env, info, 2, args, &data, &length))
return NULL;
int32_t button_index;
napi_status status = napi_get_value_int32(env, args[1], &button_index);
if (status != napi_ok) {
napi_throw_error(env, NULL, "Second argument must be a number (button index)");
return NULL;
}
bool ok = dialog_helper::ClickMessageBoxButton(data, length, button_index);
napi_value result;
napi_get_boolean(env, ok, &result);
return result;
}
napi_value ClickCheckbox(napi_env env, napi_callback_info info) {
napi_value args[1];
char* data;
size_t length;
if (!GetHandleArg(env, info, 1, args, &data, &length))
return NULL;
bool ok = dialog_helper::ClickCheckbox(data, length);
napi_value result;
napi_get_boolean(env, ok, &result);
return result;
}
napi_value CancelFileDialog(napi_env env, napi_callback_info info) {
napi_value args[1];
char* data;
size_t length;
if (!GetHandleArg(env, info, 1, args, &data, &length))
return NULL;
bool ok = dialog_helper::CancelFileDialog(data, length);
napi_value result;
napi_get_boolean(env, ok, &result);
return result;
}
napi_value AcceptFileDialog(napi_env env, napi_callback_info info) {
napi_value args[2];
char* data;
size_t length;
if (!GetHandleArg(env, info, 2, args, &data, &length))
return NULL;
std::string filename;
// Second argument (filename) is optional.
napi_valuetype vtype;
napi_typeof(env, args[1], &vtype);
if (vtype == napi_string) {
size_t str_len;
napi_get_value_string_utf8(env, args[1], NULL, 0, &str_len);
filename.resize(str_len);
napi_get_value_string_utf8(env, args[1], &filename[0], str_len + 1,
&str_len);
}
bool ok = dialog_helper::AcceptFileDialog(data, length, filename);
napi_value result;
napi_get_boolean(env, ok, &result);
return result;
}
napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor descriptors[] = {
{"getDialogInfo", NULL, GetDialogInfo, NULL, NULL, NULL,
napi_enumerable, NULL},
{"clickMessageBoxButton", NULL, ClickMessageBoxButton, NULL, NULL, NULL,
napi_enumerable, NULL},
{"clickCheckbox", NULL, ClickCheckbox, NULL, NULL, NULL,
napi_enumerable, NULL},
{"cancelFileDialog", NULL, CancelFileDialog, NULL, NULL, NULL,
napi_enumerable, NULL},
{"acceptFileDialog", NULL, AcceptFileDialog, NULL, NULL, NULL,
napi_enumerable, NULL},
};
napi_define_properties(env, exports,
sizeof(descriptors) / sizeof(*descriptors),
descriptors);
return exports;
}
} // namespace
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)

View File

@@ -7,6 +7,7 @@
"node-gyp-install": "node-gyp install"
},
"devDependencies": {
"@electron-ci/dialog-helper": "*",
"@electron-ci/echo": "*",
"@electron-ci/external-ab": "*",
"@electron-ci/is-valid-window": "*",

View File

@@ -647,6 +647,12 @@ __metadata:
languageName: unknown
linkType: soft
"@electron-ci/dialog-helper@npm:*, @electron-ci/dialog-helper@workspace:spec/fixtures/native-addon/dialog-helper":
version: 0.0.0-use.local
resolution: "@electron-ci/dialog-helper@workspace:spec/fixtures/native-addon/dialog-helper"
languageName: unknown
linkType: soft
"@electron-ci/echo@npm:*, @electron-ci/echo@workspace:spec/fixtures/native-addon/echo":
version: 0.0.0-use.local
resolution: "@electron-ci/echo@workspace:spec/fixtures/native-addon/echo"
@@ -4899,6 +4905,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "electron-test-main@workspace:spec"
dependencies:
"@electron-ci/dialog-helper": "npm:*"
"@electron-ci/echo": "npm:*"
"@electron-ci/external-ab": "npm:*"
"@electron-ci/is-valid-window": "npm:*"