fix: 🐛 migrate IPC to service-based window for reliable testing

This resolves a longstanding issue where IPC tests rely on the real
window.postMessage and MessageChannel APIs.  These are evented and
asynchronous, resulting in nondeterministic tests.  This commit updates
tests with synchronous mocked APIs.
This commit is contained in:
Randall Morey committed 2021-05-08 12:08:56 -04:00
1 parent fe56acd57f
commit 863e81bc01
4 files changed
+105 -45

No files matched your search

+4 -13
View File
@@ -1,5 +1,5 @@
import Service from '@ember/service';
import { getOwner } from '@ember/application';
import { inject as service } from '@ember/service';
import { Promise } from 'rsvp';
/**
@@ -58,7 +58,7 @@ export class IPCRequest {
this.method = method;
this.payload = payload;
this.origin = window?.location?.origin;
this.#channel = new MessageChannel();
this.#channel = new window.MessageChannel();
this.#promise = new Promise((resolve, reject) => {
this.#channel.port1.onmessage = (event) => {
this.close();
@@ -124,18 +124,9 @@ export class IPCRequest {
* }
*/
export default class IpcService extends Service {
// =attributes
// =services
/**
* Looks up the window object indirectly.
* @type {Window}
*/
get window() {
// The Ember way of accessing globals...
const document = getOwner(this).lookup('service:-document').documentElement;
// defaultView === window, but without using globals directly
return document.parentNode.defaultView;
}
@service('browser/window') window;
// =methods
+6 -32
View File
@@ -19,15 +19,17 @@ import {
// authenticateSession,
invalidateSession,
} from 'ember-simple-auth/test-support';
import { setupBrowserFakes } from 'ember-browser-services/test-support';
import WindowMockIPC from '../helpers/window-mock-ipc';
import config from '../../config/environment';
module('Acceptance | origin', function (hooks) {
setupApplicationTest(hooks);
setupBrowserFakes(hooks, { window: true });
setupMirage(hooks);
const currentOrigin = window.location.origin;
let mockIPC;
let messageHandler;
const instances = {
scopes: {
@@ -104,40 +106,12 @@ module('Acceptance | origin', function (hooks) {
urls.projects = `${urls.scopes.global}/projects`;
urls.targets = `${urls.projects}/targets`;
class MockIPC {
origin = null;
invoke(method, payload) {
return this[method](payload);
}
getOrigin() {
return this.origin;
}
setOrigin(origin) {
this.origin = origin;
return this.origin;
}
resetOrigin() {}
}
mockIPC = new MockIPC();
messageHandler = async function (event) {
if (event.origin !== currentOrigin) return;
const { method, payload } = event.data;
if (method) {
const response = await mockIPC.invoke(method, payload);
event.ports[0].postMessage(response);
}
};
window.addEventListener('message', messageHandler);
// Mock the postMessage interface used by IPC.
this.owner.register('service:browser/window', WindowMockIPC);
mockIPC = this.owner.lookup('service:browser/window').mockIPC;
});
hooks.afterEach(function () {
window.removeEventListener('message', messageHandler);
sinon.restore();
});
@@ -0,0 +1,75 @@
import Service from '@ember/service';
class MockIPC {
origin = null;
invoke(method, payload) {
return this[method](payload);
}
getOrigin() {
return this.origin;
}
setOrigin(origin) {
this.origin = origin;
return this.origin;
}
resetOrigin() {}
}
/**
* Mock window service with support for simplified postMessage and
* MessageChannel APIs. This mock implements just enough functionality
* to support the IPC mechanism.
*
* This mock is necessary to guarantee synchronous and deterministic
* operation. The window's own API is evented and asychronous, which
* cannot be relied on for testing purposes.
*
* @todo refactor mock postMessage and MessageChannel for reusability
* outside of tests
*/
export default class extends Service {
// =attributes
/**
*
*/
mockIPC = new MockIPC();
/**
*
*/
MessageChannel = class MessageChannel {
constructor() {
this.port1.postMessage = this.port1.postMessage.bind(this);
this.port2.postMessage = this.port2.postMessage.bind(this);
}
port1 = {
postMessage() {},
close() {},
};
port2 = {
postMessage(data) {
this.port1.onmessage({ data });
},
close() {},
};
};
// =methods
/**
*
*/
postMessage(data, origin, ports) {
if (origin !== window.location.origin) return;
const { method, payload } = data;
if (method) {
const response = this.mockIPC.invoke(method, payload);
ports[0].postMessage(response);
}
}
}
@@ -6,6 +6,23 @@ import sinon from 'sinon';
module('Unit | Service | ipc', function (hooks) {
setupTest(hooks);
class MessageChannelMock {
constructor() {
this.port1.postMessage = this.port1.postMessage.bind(this);
this.port2.postMessage = this.port2.postMessage.bind(this);
}
port1 = {
postMessage() {},
close() {},
};
port2 = {
postMessage(data) {
this.port1.onmessage({ data });
},
close() {},
};
}
hooks.beforeEach(() => {
// IPC requests talk to the main process indirectly via window.postMessage
// proxy layer (mediated by preload.js).
@@ -31,6 +48,7 @@ module('Unit | Service | ipc', function (hooks) {
assert.equal(request.method, 'hello');
assert.equal(request.payload, 'world');
},
MessageChannel: MessageChannelMock,
};
new IPCRequest('hello', 'world', myWindow);
});
@@ -45,6 +63,7 @@ module('Unit | Service | ipc', function (hooks) {
test: 42,
});
},
MessageChannel: MessageChannelMock,
};
const request = new IPCRequest('hello', 'world', myWindow);
const response = await request;
@@ -65,6 +84,7 @@ module('Unit | Service | ipc', function (hooks) {
// Post back an error on the response port
port.postMessage(new Error(stringifiedPayload));
},
MessageChannel: MessageChannelMock,
};
try {
await new IPCRequest('hello', 'world', myWindow);