Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(feat): jest/vitest like API mocking #255

Merged
merged 59 commits into from
Dec 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
34a1f76
(feat): support function execution in main process
christian-bromann Oct 31, 2023
114bebc
(feat): jest/vitest like API mocking
christian-bromann Nov 1, 2023
1bd6dbb
move npmrc to root
goosewobbler Nov 8, 2023
b3ac35a
fix test
goosewobbler Nov 8, 2023
aa12125
ignore fixtures lockfiles
goosewobbler Nov 8, 2023
a09e586
add filter to init-dev
goosewobbler Nov 8, 2023
4016091
update lockfiles
goosewobbler Nov 8, 2023
7d7aad7
use WebdriverClientFunc type
goosewobbler Nov 8, 2023
63d2e2a
use @vitest/spy
goosewobbler Nov 8, 2023
2118363
Merge branch 'main' into cb/better-mock
goosewobbler Nov 8, 2023
b7178f6
move npmrc back to example-cjs
goosewobbler Nov 8, 2023
0a9b505
use @vitest/spy
goosewobbler Nov 8, 2023
5bbd5a7
remove commonjs plugin from esm bundle
goosewobbler Nov 8, 2023
a79ecd6
Merge branch 'cb/better-mock' into sm/better-mock-updates
goosewobbler Nov 8, 2023
e861e45
update @vitest/spy, update lockfiles
goosewobbler Nov 8, 2023
35c9840
update deps
goosewobbler Nov 14, 2023
70c23e9
fix test
goosewobbler Nov 14, 2023
7923e8f
update @vitest/spy
goosewobbler Nov 18, 2023
553a6d8
update electron-forge
goosewobbler Nov 18, 2023
f43d9cd
chore: update deps
goosewobbler Nov 21, 2023
d0289f0
chore: update missing types, add debug
goosewobbler Nov 21, 2023
f94fdbe
chore: update mock tests for new approach
goosewobbler Nov 21, 2023
72b2c1a
feat: update mock
goosewobbler Nov 23, 2023
58715f8
chore: update lockfiles
goosewobbler Nov 23, 2023
8941c5f
test: update e2es for new mock
goosewobbler Nov 23, 2023
2b6849b
chore: fix linting
goosewobbler Nov 24, 2023
78e78ae
chore: update types
goosewobbler Nov 28, 2023
475ff96
test: expand mocking tests, misc fixes
goosewobbler Nov 28, 2023
2775064
feat: mockReturnValue
goosewobbler Nov 28, 2023
b8b0f00
chore: formatting
goosewobbler Nov 28, 2023
c9ffd74
chore: update lockfiles
goosewobbler Nov 28, 2023
98f885b
fix: type for internal globalThis.fn
goosewobbler Nov 28, 2023
e94c6c0
chore: update Electron
goosewobbler Nov 28, 2023
d9b122a
chore: standardise test imports
goosewobbler Nov 28, 2023
643477c
chore: rename clearMocks => removeMocks
goosewobbler Nov 28, 2023
bd17446
test: add removeMock units
goosewobbler Nov 28, 2023
8b62f23
chore: update imports
goosewobbler Nov 28, 2023
4bd0dcf
test: add execute units
goosewobbler Nov 28, 2023
dfede3a
test: temporarily reduce coverage
goosewobbler Nov 28, 2023
e054c03
chore: update dependencies
goosewobbler Nov 29, 2023
42dad2f
feat: single line mocking
goosewobbler Nov 29, 2023
0924870
refactor: streamlining mock API
goosewobbler Nov 29, 2023
f3e187f
feat: mockAll
goosewobbler Nov 30, 2023
75ceb33
chore: update lockfiles
goosewobbler Nov 30, 2023
60f4994
chore: update coverage
goosewobbler Nov 30, 2023
b13b0d1
Merge pull request #269 from webdriverio-community/sm/better-mock-upd…
goosewobbler Nov 30, 2023
30d0194
Merge branch 'main' into cb/better-mock
goosewobbler Nov 30, 2023
25e94e3
docs: remove old API functions
goosewobbler Nov 30, 2023
c88e46a
Merge branch 'main' into cb/better-mock
goosewobbler Nov 30, 2023
2dadcae
refactor: remove old api methods
goosewobbler Nov 30, 2023
2f79be1
test: update e2es for apis removal
goosewobbler Nov 30, 2023
4ca698c
test: update units for apis removal
goosewobbler Nov 30, 2023
0154a17
refactor: update & extract types
goosewobbler Nov 30, 2023
ac9f0cd
chore: update lockfiles
goosewobbler Nov 30, 2023
abe59cb
Merge pull request #310 from webdriverio-community/sm/remove-apis
goosewobbler Nov 30, 2023
c2808f1
test: update for strict equality check
goosewobbler Nov 30, 2023
5b3f4cf
docs: update for new mocking
goosewobbler Dec 1, 2023
ad134e9
refactor: standardise on runtime privacy
goosewobbler Dec 1, 2023
95f8cda
chore: update deps
goosewobbler Dec 1, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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