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

IPC Main <-> Renderer Example Test #184

Closed
petef19 opened this issue Apr 19, 2021 · 10 comments
Closed

IPC Main <-> Renderer Example Test #184

petef19 opened this issue Apr 19, 2021 · 10 comments

Comments

@petef19
Copy link

petef19 commented Apr 19, 2021

Following the Electron recommendations, I'm removing remote access completely in renderer and convert everything to be IPC via .invoke()

Is it possible to test IPC communication between Main/Renderer via e-mocha ?

If so, could you provide a simple e-mocha test on how to trigger an IPC event from renderer that is then received in main process (or vice versa) ?

I've looked through the provided test examples as well as projects that use e-mocha, but did not find a suitable example.

Thanks.

Electron 12.0.2
E-Mocha 9.3.3
Win x64

@inukshuk
Copy link
Collaborator

inukshuk commented Apr 19, 2021

Yes, you can test window creation and IPC communication with electron-mocha in the main process, but you have to create some kind of setup for this yourself. Basically, you need to create the window and load your code into it -- if that code requires some IPC input you need to provide that of course. You can then set up IPC listeners to confirm that your renderer code sends the correct messages.

By way of example, I can only point you to some of my own code and tests but this is highly specific to your app's window management.

@petef19
Copy link
Author

petef19 commented Apr 19, 2021

I've looked at the provided example, but I'm failing to see where you test the IPC.

In wm.js you create an IPC listener

async start() {
    ipc.on('wm', this.handleIpcMessage)
  } 

in wm_test.js you then initiate the IPC listener

before(() => wm.start())

but where is the IPC call from renderer that then should trigger the handleIpcMessage callback ?

Thanks.

@inukshuk
Copy link
Collaborator

There are various points at which the renderer code calls back (like here) with IPC messages handled by window manager. In this case the tests just check that the window manger received messages (specifically, that the ready promise resolves) but you could equally set up IPC listeners in your test setup.

@petef19
Copy link
Author

petef19 commented Apr 21, 2021

I'm running into a an interesting problem with ELectron 12.0.2, Sinon 10.0.0 and electron-mocha 9.3.3.

I'm converting all IPC calls to .invoke() on the renderer side and .handle() on the main process side.

My renderer does immediately fire off two IPC .invoke() calls when the app loads. And I can see the log messages in the main process that the IPC calls are being received before the e-mocha test is over, but the Sinon stub or spy never receives the call.

Electron main process is handled in a Typescript class, my_class.

Example 1 - fails:


// ---- Main process ----

export class My_Class{

	...
    constructor(){}
    ...

	init()
	{
		this.ipc_main.handle('test', () => {
			this.my_ipc_callback();	
		});
	}

	my_ipc_callback()
	{
		console.log('ipc callback triggered !');
	}
}

const my_class = new My_Class(...args);


// ---- E-mocha ----
it.only('IPC communication Renderer -> Main', async () => {
	// (1) arrange
	// stubs
	const stub = sinon.stub(my_class, 'my_ipc_callback');
	// (2) act
	// this creates the main window and loads the app
	await my_class.create_window();
	// (3) assert
	assert.strictEqual(stub.callCount, 1);
});

I can see the console.log of the callback method before the test has finished, yet the callCount is 0.

Example 2 - also fails:

// ---- Main process, same class ----

...
// moving the console.log directly into ipcMain
this.ipc_main.handle('test', () => {
    this.my_ipc_callback();	
});
...


// ---- E-mocha ----
it.only('IPC communication Renderer -> Main', async () => {
	// (1) arrange
	// stubs
	const stub = sinon.stub(my_class.ipc_main, 'handle');
	// (2) act
	// this creates the main window and loads the app
	await my_class.create_window();
	// (3) assert
	assert.strictEqual(stub.callCount, 1);
});

Here we're spying directly on ipcMain.
Again, I can see the console.log of ipcMain before the test has finished, yet the callCount is 0.

A possibly as source of error is the create_window() method, but since it's executed with await and fully waiting until the app has loaded and I can see the console messages of the IPC callback in main process (hence IPC was fired by renderer and received by main) before the e-mocha test has finished, I doubt it's the method.

It seems that Sinon stub cannot attach to the ipcMain.handle() method...

Any suggestions on how to make this work ?

Electron 12.0.2
Sinon 10.0.0
Electron-Mocha 9.3.3
Win 10 x64

Thanks.

@inukshuk
Copy link
Collaborator

Yes, best consult the Sinon documentation, because you're not using the API there as intended.

In the first example, you're stubbing a string object. That means, you're creating stubs for all the methods on the string instance 'my_ipc_callback' not the function itself. In example 2 you're stubbing ipcMain.handle alright, but you do this after you've already invoked the method -- from that point onwards it is not called anymore and hence the call count stays at zero. In example 3 you call the method once, after you stubbed it and you see that the call is registered, but this tests only Sinon's stub functionality and not if your code sent an IPC message.

@petef19
Copy link
Author

petef19 commented Apr 21, 2021

the examples were altered from real world as my main process is all Typescript in a dedicated class, I've updated above examples.

The IPC communications from renderer come through as I can see log messages of the IPC callback in main when I run the Electron app,

I got about 50 other e-mocha unit tests running on that same class and it's methods, Sinon stubbing/spying works just fine, but it does not seem to work with ipcMain.handle...

Have you ever stubbed ipcMain.handle method ?

@petef19
Copy link
Author

petef19 commented Apr 21, 2021

updated above examples, lmk if you ever managed to stub or spy on ipcMain.handle.

In your provided examples, you used the .on() method...

@inukshuk
Copy link
Collaborator

inukshuk commented Apr 21, 2021

If you stub a method, the original method is replaced on the context instance.

In the updated example 1 you allegedly stub a method, yet the original is invoked (there's console output) so this means my_class (the one you stubbed) is not the same instance as the one that was registered to receive the IPC messages. This is further confirmed by the fact that your stub was not called at all.

In the updated example 2:

  1. You call ipcMain.handle first
  2. Then you create a spy on ipcMain.handle
  3. You don't call ipcMain.handle again (it is not called on receiving IPC messages either)
  4. So obviously the call count is zero

There is nothing surprising about this. You seem to expect that ipcMain.handle will be invoked when an IPC message is received, but it is not. You set up the handler first (the function you pass as the second argument to ipcMain.handle) and only the handler will be invoked. If you want to stub/spy on the handler you need to create the stub/spy first to make sure that at the moment ipcMain.handle is called it is already the stub/spy that is registered not the original method.

So example 2 looks just fine, but the test makes no sense. Example 1 is a better way to test the fact that the IPC communication took place, but there must be an error in your setup. It looks like you create another My_Class instance somewhere and call its init() method -- while the init() method of the My_Class instance which you then use to stub the callback is not called at all.

This test works fine for me (test.html is an empty HTML page):

const { join } = require('path')
const sinon = require('sinon')
const { ipcMain, BrowserWindow } = require('electron')

describe.only('IPC Handlers', () => {
  let spy

  beforeEach(() => {
    spy = sinon.spy()
    ipcMain.handle('test', async () => spy())
  })

  afterEach(() => {
    ipcMain.removeHandler('test')
  })

  it('can be invoked', async () => {
    let win = new BrowserWindow({
      webPreferences: {
        contextIsolation: false,
        nodeIntegration: true
      }
    })

    await win.loadFile(join(__dirname, 'test.html'))

    win.webContents.executeJavaScript(
      `require('electron').ipcRenderer.invoke('test')`
    )

    // Allow some time for the IPC messages to be sent.
    await new Promise((resolve) => setTimeout(resolve, 500))

    win.destroy()

    expect(spy.callCount).to.equal(1)
  })
})

@petef19
Copy link
Author

petef19 commented Apr 21, 2021

@inukshuk

a big THANK YOU for this last response. You were dead on point, the init() method was not called on the class instance in my test. I had this all working using the .on method (like the example in your links), but then moved these IPC tests into a different describe block to test .invoke/.handle separately and forgot to run init() on the class instance that is created in the beforeEach() in that particular describe block.

Another big THANK YOU for the detailed example you posted, I'm sure this will help other folks that have been asking for more examples !

So here's a follow-up issue that I've been running into with e-mocha in regards to these IPC event handlers. I did not want to add this to the OP, but maybe you can chime in here.

As mentioned, in my Electron main.ts file, everything is run in a TS class. At the bottom of the file a class instance is then created and .init() is run, which configures and runs the Electron app:

const my_app = new MyClass(...args);
my_app.init();

in the class constructor, ipcMain is passed (alongside other packages/modules etc). This is all working and the Electron app is working.

In my e-mocha tests, I create a new instance of the class before every test, so that I can modify/mock/stub as needed.

The problem that I'm facing is that if I run the init() method (which configures the IPC event listeners/handlers) on that new class instance (created before every test), ipcMain throws an error complaining that Error: Attempted to register a second handler for '<channel name>'

But this instance itself had never registered any IPC handlers for that channel. After some testing, it appears that the class instance inside main.ts that registers IPC handlers when it calls .init() collides with the IPC handlers on the new class instance, I assume because they all share the same ipcMain object, although in my e-mocha scripts I import ipcMain again and pass that into each new class instance constructor.

I can unregister a handler via ipcMain.removeHandler(channel) but to unregister all handlers, I would need to know all handler names, because there is no .removeAllHandlers() method.

As of right now, I'm doing that globally for each handler top of the e-mocha script (outside of any describe block), directly on the ipcMain import object - this works.

Is there a better approach to avoid the IPC event handler collision ?

And to confirm, yes I can register/unregister new test handlers (with different names) like you did in your example and avoid collision like that, but in some tests I want to directly test the IPC handlers that the renderer in the real app uses, hence I need to run .init().

Thanks.

@inukshuk
Copy link
Collaborator

inukshuk commented Apr 22, 2021

In a given process, ipcMain is always the same thing no matter how many times you import/require it. If I'm not mistaken, there can only be a single handler per channel, so it's no problem to remove your test handler, after a test with using ipcMain.removeHandler(channel).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants