Skip to content

Commit

Permalink
Merge pull request #255 from webdriverio-community/cb/better-mock
Browse files Browse the repository at this point in the history
  • Loading branch information
goosewobbler authored Dec 2, 2023
2 parents 25c67eb + 95f8cda commit 8d7a478
Show file tree
Hide file tree
Showing 38 changed files with 7,261 additions and 1,796 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,6 @@ wdio-logs

# Electron Forge output
out

# Fixtures lockfiles - installation shouldn't happen for fixtures
test/fixtures/**/pnpm-lock.yaml
98 changes: 60 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,71 +108,93 @@ if (process.env.NODE_ENV === 'test') {
}
```

The APIs should not work outside of WDIO but for security reasons it is encouraged to use dynamic imports wrapped in conditionals to ensure the APIs are only exposed when the app is being tested.
For security reasons it is encouraged to use dynamic imports wrapped in conditionals to ensure electron main process access is only available when the app is being tested.

After importing the scripts the APIs should now be available in tests.
### Execute Electron Scripts

Currently available APIs: [`app`](https://www.electronjs.org/docs/latest/api/app), [`browserWindow`](https://www.electronjs.org/docs/latest/api/browser-window), [`dialog`](https://www.electronjs.org/docs/latest/api/dialog), [`mainProcess`](https://www.electronjs.org/docs/latest/api/process).
You can execute arbitrary scripts within the context of your Electron application main process using `browser.electron.execute(...)`. This allows you to access the Electron APIs in a fluid way, in case you wish to manipulate your application at runtime or trigger certain events.

The service re-exports the WDIO browser object with the `.electron` namespace for API usage in your tests:
For example, you can trigger an message modal from your test via:

```ts
import { browser } from 'wdio-electron-service';

// in a test
const appName = await browser.electron.app('getName');
await browser.electron.execute(
(electron, param1, param2, param3) => {
const appWindow = electron.BrowserWindow.getFocusedWindow();
electron.dialog.showMessageBox(appWindow, {
message: 'Hello World!',
detail: `${param1} + ${param2} + ${param3} = ${param1 + param2 + param3}`,
});
},
1,
2,
3,
);
```

### Execute Electron Scripts
which will make the application trigger the following alert:

You can execute arbitrary scripts within the context of your Electron application main process using `browser.electron.execute(...)`. This allows you to potentially change the behavior of your application at runtime or trigger certain events.
![Execute Demo](./.github/assets/execute-demo.png 'Execute Demo')

For example you can trigger an message modal from your test via:
**Note:** The first argument of the function will be always the default export of the `electron` package that contains the [Electron API](https://www.electronjs.org/docs/latest/api/app).

### Mocking Electron APIs

You can mock Electron API functionality by calling the mock function with the API name and function name. e.g. in a spec file:

```ts
await browser.electron.execute((electron, param1, param2, param3) => {
const appWindow = electron.BrowserWindow.getFocusedWindow();
electron.dialog.showMessageBox(appWindow, {
message: 'Hello World!',
detail: `${param1} + ${param2} + ${param3} = ${param1 + param2 + param3}`
});
}, 1, 2, 3)
const showOpenDialog = await browser.electron.mock('dialog', 'showOpenDialog');
await browser.electron.execute(
async (electron) =>
await electron.dialog.showOpenDialog({
properties: ['openFile', 'openDirectory'],
}),
);

const mockedShowOpenDialog = await showOpenDialog.update();
expect(mockedShowOpenDialog).toHaveBeenCalledTimes(1);
expect(mockedShowOpenDialog).toHaveBeenCalledWith({
properties: ['openFile', 'openDirectory'],
});
```

which will make the application trigger the following alert:
Make sure to call `update()` on the mock before using it with `expect`.

![Execute Demo](./.github/assets/execute-demo.png "Execute Demo")
You can also pass a mockReturnValue, or set it after defining your mock:

__Note:__ The first argument of the function will be always the default export of the `electron` package that contains the [Electron API](https://www.electronjs.org/docs/latest/api/app).
```ts
const showOpenDialog = await browser.electron.mock('dialog', 'showOpenDialog', 'I opened a dialog!');
```

### Mocking Electron APIs
```ts
const showOpenDialog = await browser.electron.mock('dialog', 'showOpenDialog');
await showOpenDialog.mockReturnValue('I opened a dialog!');
```

You can mock Electron API functionality by calling the mock function with the API name, function name and mock return value. e.g. in a spec file:
Which results in the following:

```ts
await browser.electron.mock('dialog', 'showOpenDialog', 'dialog opened!');
const result = await browser.electron.dialog('showOpenDialog');
console.log(result); // 'dialog opened!'
const result = await browser.electron.execute(async (electron) => await electron.dialog.showOpenDialog());
expect(result).toBe('I opened a dialog!');
```

### Custom Electron API

You can also implement a custom API if you wish. To do this you will need to define a handler in your main process:
You can mock all functions from an API using `mockAll`, the mocks are returned as an object:

```ts
import { ipcMain } from 'electron';

ipcMain.handle('wdio-electron', () => {
// access some Electron or Node things on the main process
return 'such api';
});
const dialog = await browser.electron.mockAll('dialog');
await dialog.showOpenDialog.mockReturnValue('I opened a dialog!');
await dialog.showMessageBox.mockReturnValue('I opened a message box!');
```

The custom API can then be called in a spec file:
Mocks can be removed by calling `removeMocks`, or directly by calling `unMock` on the mock itself:

```ts
const someValue = await browser.electron.api('wow'); // default
const someValue = await browser.electron.myCustomAPI('wow'); // configured using `customApiBrowserCommand`
// removes all mocked functions
await browser.electron.removeMocks();
// removes all mocked functions from the dialog API
await browser.electron.removeMocks('dialog');
// removes the showOpenDialog mock from the dialog API
const showOpenDialog = await browser.electron.mock('dialog', 'showOpenDialog');
await showOpenDialog.unMock();
```

## Example
Expand Down
2 changes: 1 addition & 1 deletion example-cjs/.npmrc
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Electron Forge doesn't tolerate PNPM symlinks
# can be removed when https://github.com/electron/forge/pull/3351 is merged
# can be removed when https://github.com/electron/forge/issues/2633 is fixed
node-linker=hoisted
195 changes: 133 additions & 62 deletions example-cjs/e2e/api.spec.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,154 @@
import fs from 'node:fs';
import path from 'node:path';
import { browser } from '@wdio/globals';

import { expect } from '@wdio/globals';
import { browser } from 'wdio-electron-service';

const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), { encoding: 'utf-8' }));
const { name, version } = packageJson;

describe('electron APIs', () => {
describe('app', () => {
it('should retrieve app metadata through the electron API', async () => {
const appName = await browser.electron.app('getName');
expect(appName).toEqual(name);
const appVersion = await browser.electron.app('getVersion');
expect(appVersion).toEqual(version);
});
const { version: appVersion } = packageJson as { name: string; version: string };

describe('mocking', () => {
afterEach(async () => {
await browser.electron.removeMocks();
});

describe('browserWindow', () => {
it('should retrieve the window title through the electron API', async () => {
let windowTitle;
await browser.waitUntil(
async () => {
windowTitle = await browser.electron.browserWindow('title');
if (windowTitle !== 'this is the title of the main window') {
return false;
}

return windowTitle;
},
{
timeoutMsg: 'Window title not updated',
},
describe('mock', () => {
it('should mock an electron API function', async () => {
const showOpenDialog = await browser.electron.mock('dialog', 'showOpenDialog');
await browser.electron.execute(
async (electron) =>
await electron.dialog.showOpenDialog({
title: 'my dialog',
properties: ['openFile', 'openDirectory'],
}),
);
expect(windowTitle).toEqual('this is the title of the main window');

const mockedShowOpenDialog = await showOpenDialog.update();
expect(mockedShowOpenDialog).toHaveBeenCalledTimes(1);
expect(mockedShowOpenDialog).toHaveBeenCalledWith({
title: 'my dialog',
properties: ['openFile', 'openDirectory'],
});
});
});

describe('custom', () => {
it('should return the expected response', async () => {
const result = await browser.electron.api();
expect(result).toEqual('test');
it('should mock a synchronous electron API function', async () => {
const showOpenDialogSync = await browser.electron.mock('dialog', 'showOpenDialogSync');
await browser.electron.execute((electron) =>
electron.dialog.showOpenDialogSync({
title: 'my dialog',
properties: ['openFile', 'openDirectory'],
}),
);

const mockedShowOpenDialogSync = await showOpenDialogSync.update();
expect(mockedShowOpenDialogSync).toHaveBeenCalledTimes(1);
expect(mockedShowOpenDialogSync).toHaveBeenCalledWith({
title: 'my dialog',
properties: ['openFile', 'openDirectory'],
});
});
});

describe('mainProcess', () => {
it('should retrieve the process type through the electron API', async () => {
const processType = await browser.electron.mainProcess('type');
expect(processType).toEqual('browser');
describe('mockImplementation', () => {
it('should mock an electron API function', async () => {
const showOpenDialog = await browser.electron.mock('dialog', 'showOpenDialog');
let callsCount = 0;
await showOpenDialog.mockImplementation(() => callsCount++);
await browser.electron.execute(
async (electron) =>
await electron.dialog.showOpenDialog({
title: 'my dialog',
properties: ['openFile', 'openDirectory'],
}),
);

const mockedShowOpenDialog = await showOpenDialog.update();
expect(mockedShowOpenDialog).toHaveBeenCalledTimes(1);
expect(mockedShowOpenDialog).toHaveBeenCalledWith({
title: 'my dialog',
properties: ['openFile', 'openDirectory'],
});
expect(callsCount).toBe(1);
});
});
});

describe('mock', () => {
it('should mock the expected electron API function', async () => {
await browser.electron.mock('dialog', 'showOpenDialog', 'I opened a dialog!');
const result = await browser.electron.dialog('showOpenDialog');
expect(result).toEqual('I opened a dialog!');
describe('mockReturnValue', () => {
it('should return the expected value from the mock API', async () => {
const mockGetName = await browser.electron.mock('app', 'getName');
await mockGetName.mockReturnValue('This is a mock');

const name = await browser.electron.execute((electron) => electron.app.getName());

expect(name).toBe('This is a mock');
});
});
});

describe('mockAll', () => {
it('should mock all functions on an API', async () => {
const mockedDialog = await browser.electron.mockAll('dialog');
await browser.electron.execute(
async (electron) =>
await electron.dialog.showOpenDialog({
title: 'my dialog',
properties: ['openFile', 'openDirectory'],
}),
);
await browser.electron.execute((electron) =>
electron.dialog.showOpenDialogSync({
title: 'my dialog',
properties: ['openFile', 'openDirectory'],
}),
);

it('should mock the expected synchronous electron API function', async () => {
await browser.electron.mock('dialog', 'showOpenDialogSync', 'I opened a dialog!');
const result = await browser.electron.dialog('showOpenDialogSync');
expect(result).toEqual('I opened a dialog!');
const mockedShowOpenDialog = await mockedDialog.showOpenDialog.update();
expect(mockedShowOpenDialog).toHaveBeenCalledTimes(1);
expect(mockedShowOpenDialog).toHaveBeenCalledWith({
title: 'my dialog',
properties: ['openFile', 'openDirectory'],
});
const mockedShowOpenDialogSync = await mockedDialog.showOpenDialogSync.update();
expect(mockedShowOpenDialogSync).toHaveBeenCalledTimes(1);
expect(mockedShowOpenDialogSync).toHaveBeenCalledWith({
title: 'my dialog',
properties: ['openFile', 'openDirectory'],
});
});
});

describe('execute', () => {
it('should allow to execute an arbitrary function in the main process', async () => {
expect(
await browser.electron.execute(
(electron, a, b, c) => {
const win = electron.BrowserWindow.getFocusedWindow();
return [typeof win, a + b + c];
},
1,
2,
3,
),
).toEqual(['object', 6]);

expect(await browser.electron.execute('return 1 + 2 + 3')).toBe(6);
describe('unMock', () => {
it('should remove an existing mock', async () => {
const getVersion = await browser.electron.mock('app', 'getVersion', 'mocked version');
let version = await browser.electron.execute((electron) => electron.app.getVersion());
expect(version).toBe('mocked version');

await getVersion.unMock();

expect(async () => await getVersion.update()).rejects.toThrowError(
'No mock registered for "electron.app.getVersion"',
);

version = await browser.electron.execute((electron) => electron.app.getVersion());
expect(version).toBe(appVersion);
});
});
});

describe('execute', () => {
it('should execute an arbitrary function in the main process', async () => {
expect(
await browser.electron.execute(
(electron, a, b, c) => {
const version = electron.app.getVersion();
return [version, a + b + c];
},
1,
2,
3,
),
).toEqual([appVersion, 6]);
});

it('should execute a string-based function in the main process', async () => {
expect(await browser.electron.execute('return 1 + 2 + 3')).toBe(6);
});
});
5 changes: 4 additions & 1 deletion example-cjs/e2e/application.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { expect } from '@wdio/globals';
import { browser } from 'wdio-electron-service';

describe('application loading', () => {
describe('App', () => {
it('should launch the application', async () => {
Expand All @@ -7,7 +10,7 @@ describe('application loading', () => {

it('should pass args through to the launched application', async () => {
// custom args are set in the wdio.conf.js file as they need to be set before WDIO starts
const argv = await browser.electron.mainProcess('argv');
const argv = await browser.electron.execute(() => process.argv);
expect(argv).toContain('--foo');
expect(argv).toContain('--bar=baz');
});
Expand Down
2 changes: 2 additions & 0 deletions example-cjs/e2e/dom.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { expect } from '@wdio/globals';
import { browser } from 'wdio-electron-service';
import { setupBrowser, type WebdriverIOQueries } from '@testing-library/webdriverio';

describe('application loading', () => {
Expand Down
Loading

0 comments on commit 8d7a478

Please sign in to comment.