typescriptelectronspectronipcmain

Reacting to ipcMain events in Spectron


I have an electron application that first starts a launcher window (in a renderer process) that starts several background services. After those background services started successfully it sends "services-running" on its ipcRenderer back to the main process which in turn reacts to that event by closing the launcher window and starting the main application window. The event is of course received by ipcMain.on('services-running',...)

I unit tested all the handlers separately so those are fine and now I want to integration test the events that pass through ipcMain.

This is how my integration test looks at the moment:

import { Application } from 'spectron';
import * as electron from "electron";
import { expect } from 'chai';
import * as chai from 'chai';
import * as chaiAsPromised from 'chai-as-promised';

let app: Application;

global.before(() => {
    app = new Application({
        path: "" + electron,
        args: ["app/main.js"],
        env: {
            ELECTRON_ENABLE_LOGGING: true,
            ELECTRON_ENABLE_STACK_DUMPING: true,
            NODE_ENV: "integrationtest"
        },
        startTimeout: 20000,
        chromeDriverLogPath: '../chromedriverlog.txt'
    });

    chai.use(chaiAsPromised);
    chai.should();
});

describe('Application', () => {

    before('Start Application', () => {
        return app.start();
    });

    after(() => {
        if(app && app.isRunning()){
            return app.stop();
        }
    });

    it('should start the launcher', async  () => {
        await app.client.waitUntilWindowLoaded();
        return app.client.getTitle().should.eventually.equal('Launcher');
    });

    it('should start all services before timing out', async (done) => {
        console.log('subscribed');
        app.electron.remote.ipcMain.on('services-running', () => {
            done();
        });
    });

});

The first test works fine. The second test will fail eventually after the timeout is reached although I can see subscribed on the shell before the main window pops up, so the event is definitely fired.

I read in the documentation that nodeIntegration needs to be enabled to access the full electron api with spectron, all my renderer processes are started with {nodeIntegration: true} in their respective webPreferences. But since I am interested in the main process I think this does not apply (or at least I think it shouldn't since the main process is a node process per se).

So my main question is, how would I bind to ipcMain events and include those in my assertions. Also how would I know when the launcher window is closed and the "main" window has been opened?

As a bonus I have some understanding issues with the spectron api.

  1. If I look at the spectron.d.ts the electron property of the Application is of type Electron.AllElectron which in turn is a MainInterface and directly has the ipcMain property. So in my understanding accessing ipcMain should be app.electron.ipcMain (which is undefined), where is that remote coming from and why is it invisible in the spectron.d.ts.

  2. The methods on SpectronClient all return Promise<void>. So I have to await or then those. If I look at the javascript examples they chain the client statements:

return app.client
  .waitUntilWindowLoaded()
  .getTitle().should.equal('Launcher');

This doesn't work in typescript because you can't chain to a Promise<void> obviously,... how does that work to in js?


Solution

  • So I tackled the problems separately. I migrated everything to classes and use fields / constructor injection to put in all the dependencies in my classes, so I can mock them, including the things that come from electron.

    export class LauncherRenderer implements Renderer {
    
        protected mongo: MongoProcess;
        protected logger: Logger;
        protected ipc: IpcRenderer;
    
        protected STATUS_LABEL: string = 'status-text';
    
        constructor() {
            this.ipc = ipcRenderer;
    
            this.mongo = new MongoProcess(this.ipc);
    
            this.logger = new Logger('launcher', this.ipc);
        }
    

    Inside the class I will always use this.ipc when subscribing to events. For unit tests I have a FakeIpc class:

    import { EventEmitter } from 'events';
    
    export class FakeIpc {
    
        public emitter: EventEmitter = new EventEmitter();
    
        public send(channel: string, message?: any): void { }
    
        public on(event: string, listener: () => void): void {
            this.emitter.on(event, listener);
        }
    
        public emit(event: string): void {
            this.emitter.emit(event);
        }
    }
    

    When setting up the Unit tests for LauncherRenderer I inject the FakeIpc into the renderer:

     beforeEach(() => {
            fakeIpc = new FakeIpc();
            spyOn(fakeIpc, 'on').and.callThrough();
            spyOn(fakeIpc, 'send').and.callThrough();
    
            mongoMock = createSpyObj('mongoMock', ['start', 'stop', 'forceStop']);
    
            underTest = new LauncherRenderer();
    
            underTest.mongo = mongoMock;
            underTest.ipc = fakeIpc;
        });
    

    This way I can spy on the ipc if the subscriptions have been done, or use the public trigger method to shoot ipc events and test if my class reacts correctly to it.

    For integration tests I recognized I should not care about internals like events (this is done in unit tests), only about the results of those (windows closing and opening). Like this:

        it('should start the launcher', async () => {
            await app.client.waitUntilWindowLoaded();
            const title: string = await app.client.getTitle();
            expect(title).toEqual('Launcher');
        });
    

    In the next test, I wait until the launcher dissapeared and a new window opens, that way the events must have worked or that will not happen.

        it('should open main window after all services started within 120s', async () => {
            let handles: any = await app.client.windowHandles();
    
            try {
                await Utils.waitForPredicate(async () => {
                    handles = await app.client.windowHandles();
                    return Promise.resolve(handles.value.length === 2);
                }, 120000);
                await app.client.windowByIndex(1);
            } catch (err) {
                return Promise.reject(err);
            }
    
            const title: string = await app.client.getTitle();
            expect(title).toEqual('Main Window');
        });
    

    The waitForPredicate is merely a helper method that waits for a promise to resolve or kills the test after a timeout is reached.

    public static waitForPredicate(
        predicate: () => Promise<boolean>,
        timeout: number = 10000,
        interval: number = 1000,
        expectation: boolean = true): Promise<void> {
            return new Promise<any>(async (res, rej) => {
                let currentTime: number = 0;
                while (currentTime < timeout) {
                    // performance.now() would be nicer, but that doesn't work in jasmin tests
                    const t0: number = Date.now();
                    const readyState: boolean | void = await predicate().catch(() => rej());
                    if (readyState === expectation) {
                        res();
                        return;
                    }
                    await Utils.sleep(interval);
                    const t1: number = Date.now();
                    currentTime += t1 - t0;
                }
                // timeout
                rej();
            });
    }
    
    public static sleep(ms: number): Promise<void> {
        if (this.skipSleep) {
            return Promise.resolve();
        }
        return new Promise<void>((res) => setTimeout(res, ms));
    }