mirror of
https://github.com/electron/electron.git
synced 2026-04-10 03:01:51 -04:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
7
spec/fixtures/native-addon/dialog-helper/.gitignore
vendored
Normal file
7
spec/fixtures/native-addon/dialog-helper/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/node_modules
|
||||
/build
|
||||
*.swp
|
||||
*.log
|
||||
*~
|
||||
.node-version
|
||||
package-lock.json
|
||||
23
spec/fixtures/native-addon/dialog-helper/binding.gyp
vendored
Normal file
23
spec/fixtures/native-addon/dialog-helper/binding.gyp
vendored
Normal 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',
|
||||
}],
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
2
spec/fixtures/native-addon/dialog-helper/lib/index.js
vendored
Normal file
2
spec/fixtures/native-addon/dialog-helper/lib/index.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
const binding = require('../build/Release/dialog_helper.node');
|
||||
module.exports = binding;
|
||||
10
spec/fixtures/native-addon/dialog-helper/package.json
vendored
Normal file
10
spec/fixtures/native-addon/dialog-helper/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
68
spec/fixtures/native-addon/dialog-helper/src/dialog_helper.h
vendored
Normal file
68
spec/fixtures/native-addon/dialog-helper/src/dialog_helper.h
vendored
Normal 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_
|
||||
320
spec/fixtures/native-addon/dialog-helper/src/dialog_helper_mac.mm
vendored
Normal file
320
spec/fixtures/native-addon/dialog-helper/src/dialog_helper_mac.mm
vendored
Normal 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
|
||||
231
spec/fixtures/native-addon/dialog-helper/src/main.cc
vendored
Normal file
231
spec/fixtures/native-addon/dialog-helper/src/main.cc
vendored
Normal 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)
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
Reference in New Issue
Block a user