diff --git a/src/index.js b/src/index.js index 86bb840..c53c986 100644 --- a/src/index.js +++ b/src/index.js @@ -10,6 +10,7 @@ const DATA_CLONE_ERROR = 'DataCloneError'; export const ERR_CONNECTION_DESTROYED = 'ConnectionDestroyed'; export const ERR_CONNECTION_TIMEOUT = 'ConnectionTimeout'; export const ERR_NOT_IN_IFRAME = 'NotInIframe'; +export const ERR_IFRAME_ALREADY_ATTACHED_TO_DOM = 'IframeAlreadyAttachedToDom'; const DEFAULT_PORTS = { 'http:': '80', @@ -22,6 +23,7 @@ const Penpal = { ERR_CONNECTION_DESTROYED, ERR_CONNECTION_TIMEOUT, ERR_NOT_IN_IFRAME, + ERR_IFRAME_ALREADY_ATTACHED_TO_DOM, /** * Promise implementation. @@ -336,8 +338,17 @@ const connectCallReceiver = (info, methods, destructionPromise) => { * for the child to respond before rejecting the connection promise. * @return {Child} */ -Penpal.connectToChild = ({ url, appendTo, methods = {}, timeout }) => { +Penpal.connectToChild = ({ url, appendTo, iframe, methods = {}, timeout }) => { + if (iframe && iframe.parentNode) { + const error = new Error( + 'connectToChild() must not be called with an iframe already attached to DOM' + ); + error.code = ERR_IFRAME_ALREADY_ATTACHED_TO_DOM; + throw error; + } + let destroy; + const connectionDestructionPromise = new DestructionPromise( resolveConnectionDestructionPromise => { destroy = resolveConnectionDestructionPromise; @@ -345,9 +356,8 @@ Penpal.connectToChild = ({ url, appendTo, methods = {}, timeout }) => { ); const parent = window; - const iframe = document.createElement('iframe'); - - (appendTo || document.body).appendChild(iframe); + iframe = iframe || document.createElement('iframe'); + iframe.src = url; connectionDestructionPromise.then(() => { if (iframe.parentNode) { @@ -355,7 +365,6 @@ Penpal.connectToChild = ({ url, appendTo, methods = {}, timeout }) => { } }); - const child = iframe.contentWindow || iframe.contentDocument.parentWindow; const childOrigin = getOriginFromUrl(url); const promise = new Penpal.Promise((resolveConnectionPromise, reject) => { let connectionTimeoutId; @@ -380,6 +389,7 @@ Penpal.connectToChild = ({ url, appendTo, methods = {}, timeout }) => { let destroyCallReceiver; const handleMessage = event => { + const child = iframe.contentWindow || iframe.contentDocument.parentWindow; if ( event.source === child && event.origin === childOrigin && @@ -456,7 +466,7 @@ Penpal.connectToChild = ({ url, appendTo, methods = {}, timeout }) => { }); log('Parent: Loading iframe'); - iframe.src = url; + (appendTo || document.body).appendChild(iframe); }); return { diff --git a/test/index.js b/test/index.js index 8b2d17a..4f28750 100644 --- a/test/index.js +++ b/test/index.js @@ -6,323 +6,404 @@ describe('Penpal', () => { Penpal.debug = false; // Set to true when debugging tests. }); - it('completes a handshake', (done) => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html` - }); + describe('connectToChild without iframe', () => { + it('completes a handshake', (done) => { + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html` + }); - connection.promise.then(() => { - connection.destroy(); - done(); + connection.promise.then(() => { + connection.destroy(); + done(); + }); }); - }); - it('creates an iframe and add it to document.body', () => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html` + it('creates an iframe and adds it to document.body', () => { + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html` + }); + + expect(connection.iframe).toBeDefined(); + expect(connection.iframe.src).toBe(`${CHILD_SERVER}/child.html`); + expect(connection.iframe.parentNode).toBe(document.body); }); - expect(connection.iframe).toBeDefined(); - expect(connection.iframe.src).toBe(`${CHILD_SERVER}/child.html`); - expect(connection.iframe.parentNode).toBe(document.body); - }); + it('creates an iframe and adds it to a specific element', () => { + const container = document.createElement('div'); + document.body.appendChild(container); - it('creates an iframe and add it to a specific element', () => { - const container = document.createElement('div'); - document.body.appendChild(container); + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html`, + appendTo: container + }); - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html`, - appendTo: container + expect(connection.iframe).toBeDefined(); + expect(connection.iframe.src).toBe(`${CHILD_SERVER}/child.html`); + expect(connection.iframe.parentNode).toBe(container); }); - - expect(connection.iframe).toBeDefined(); - expect(connection.iframe.src).toBe(`${CHILD_SERVER}/child.html`); - expect(connection.iframe.parentNode).toBe(container); }); - it('calls a function on the child', (done) => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html` - }); + describe('connectToChild with iframe', () => { + it('completes a handshake', (done) => { + const iframeToUse = document.createElement('iframe'); + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html`, + iframe: iframeToUse + }); - connection.promise.then((child) => { - child.multiply(2, 5).then((value) => { - expect(value).toEqual(10); + connection.promise.then(() => { connection.destroy(); done(); }); }); - }); - - it('calls a function on the child with origin set', (done) => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/childOrigin.html` - }); - connection.promise.then((child) => { - child.multiply(2, 5).then((value) => { - expect(value).toEqual(10); - connection.destroy(); - done(); + it('adds the iframe to document.body', () => { + const iframeToUse = document.createElement('iframe'); + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html`, + iframe: iframeToUse }); + + expect(connection.iframe).toBe(iframeToUse); + expect(connection.iframe.src).toBe(`${CHILD_SERVER}/child.html`); + expect(connection.iframe.parentNode).toBe(document.body); }); - }); - it('calls an asynchronous function on the child', (done) => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html` + it('overrides the iframe source with the url parameter', () => { + const iframeToUse = document.createElement('iframe'); + iframeToUse.src = './to_be_override.html'; + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html`, + iframe: iframeToUse + }); + + expect(connection.iframe).toBe(iframeToUse); + expect(connection.iframe.src).toBe(`${CHILD_SERVER}/child.html`); }); - connection.promise.then((child) => { - child.multiplyAsync(2, 5).then((value) => { - expect(value).toEqual(10); - connection.destroy(); - done(); + it('adds the iframe to a specific element', () => { + const container = document.createElement('div'); + const iframeToUse = document.createElement('iframe'); + document.body.appendChild(container); + + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html`, + appendTo: container, + iframe: iframeToUse }); + + expect(connection.iframe).toBe(iframeToUse); + expect(connection.iframe.src).toBe(`${CHILD_SERVER}/child.html`); + expect(connection.iframe.parentNode).toBe(container); }); - }); - it('calls a function on the parent', (done) => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html`, - methods: { - add: (num1, num2) => { - return num1 + num2; - } + it('throws ERR_IFRAME_ALREADY_ATTACHED_TO_DOM error if the iframe is already attached to DOM', () => { + const iframeToUse = document.createElement('iframe'); + document.body.appendChild(iframeToUse); + + let error; + try { + Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html`, + iframe: iframeToUse + }); + } catch (err) { + error = err; } + + expect(error).toBeDefined(); + expect(error.code).toBe(Penpal.ERR_IFRAME_ALREADY_ATTACHED_TO_DOM); }); + }); - connection.promise.then((child) => { - child.addUsingParent().then(() => { - child.getParentReturnValue().then((value) => { - expect(value).toEqual(9); + describe('communication between parent and child', () => { + + it('calls a function on the child', (done) => { + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html` + }); + + connection.promise.then((child) => { + child.multiply(2, 5).then((value) => { + expect(value).toEqual(10); connection.destroy(); done(); }); }); }); - }); - - it('handles promises rejected with strings', (done) => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html`, - }); - connection.promise.then((child) => { - child.getRejectedPromiseString().catch((error) => { - expect(error).toBe('test error string'); - connection.destroy(); - done(); + it('calls a function on the child with origin set', (done) => { + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/childOrigin.html` }); - }); - }); - it('handles promises rejected with error objects', (done) => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html`, + connection.promise.then((child) => { + child.multiply(2, 5).then((value) => { + expect(value).toEqual(10); + connection.destroy(); + done(); + }); + }); }); - connection.promise.then((child) => { - child.getRejectedPromiseError().catch((error) => { - expect(error).toEqual(jasmine.any(Error)); - expect(error.name).toBe('TypeError'); - expect(error.message).toBe('test error object'); - // In IE, errors only get `stack` set when an error is raised. In this test case, the - // promise rejected with the error and never raised, so no stack. - // expect(error.stack).toEqual(jasmine.any(String)); - connection.destroy(); - done(); + it('calls an asynchronous function on the child', (done) => { + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html` }); - }); - }); - it('handles thrown errors', (done) => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html`, + connection.promise.then((child) => { + child.multiplyAsync(2, 5).then((value) => { + expect(value).toEqual(10); + connection.destroy(); + done(); + }); + }); }); - connection.promise.then((child) => { - child.throwError().catch((error) => { - expect(error).toEqual(jasmine.any(Error)); - expect(error.message).toBe('Oh nos!'); - connection.destroy(); - done(); + it('calls a function on the parent', (done) => { + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html`, + methods: { + add: (num1, num2) => { + return num1 + num2; + } + } }); - }); - }); - it('handles unclonable values', (done) => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html`, + connection.promise.then((child) => { + child.addUsingParent().then(() => { + child.getParentReturnValue().then((value) => { + expect(value).toEqual(9); + connection.destroy(); + done(); + }); + }); + }); }); - connection.promise.then((child) => { - child.getUnclonableValue().catch((error) => { - expect(error).toEqual(jasmine.any(Error)); - expect(error.name).toBe('DataCloneError'); - connection.destroy(); - done(); + it('handles promises rejected with strings', (done) => { + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html`, }); - }); - }); - it('doesn\'t connect to iframe connecting to parent with different origin', (done) => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/childDiffOrigin.html` + connection.promise.then((child) => { + child.getRejectedPromiseString().catch((error) => { + expect(error).toBe('test error string'); + connection.destroy(); + done(); + }); + }); }); - const spy = jasmine.createSpy(); - - connection.promise.then(spy); + it('handles promises rejected with error objects', (done) => { + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html`, + }); - connection.iframe.addEventListener('load', function() { - // Give Penpal time to try to make a handshake. - setTimeout(() => { - expect(spy).not.toHaveBeenCalled(); - done(); - }, 100); + connection.promise.then((child) => { + child.getRejectedPromiseError().catch((error) => { + expect(error).toEqual(jasmine.any(Error)); + expect(error.name).toBe('TypeError'); + expect(error.message).toBe('test error object'); + // In IE, errors only get `stack` set when an error is raised. In this test case, the + // promise rejected with the error and never raised, so no stack. + // expect(error.stack).toEqual(jasmine.any(String)); + connection.destroy(); + done(); + }); + }); }); - }); - it('reconnects after child reloads', (done) => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html` - }); + it('handles thrown errors', (done) => { + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html`, + }); - connection.promise.then((child) => { - const previousMultiply = child.multiply; + connection.promise.then((child) => { + child.throwError().catch((error) => { + expect(error).toEqual(jasmine.any(Error)); + expect(error.message).toBe('Oh nos!'); + connection.destroy(); + done(); + }); + }); + }); - const intervalId = setInterval(function() { - // Detect reconnection - if (child.multiply !== previousMultiply) { - clearInterval(intervalId); - child.multiply(2, 4).then((value) => { - expect(value).toEqual(8); - connection.destroy(); - done(); - }); - } - }, 10); + it('handles unclonable values', (done) => { + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html`, + }); - child.reload(); + connection.promise.then((child) => { + child.getUnclonableValue().catch((error) => { + expect(error).toEqual(jasmine.any(Error)); + expect(error.name).toBe('DataCloneError'); + connection.destroy(); + done(); + }); + }); }); }); - // Issue #18 - it('properly disconnects previous call receiver upon reconnection', (done) => { - const add = jasmine.createSpy().and.callFake((num1, num2) => { - return num1 + num2; - }); + describe('connection management', () => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html`, - methods: { - add - } - }); + it('doesn\'t connect to iframe connecting to parent with different origin', (done) => { + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/childDiffOrigin.html` + }); - connection.promise.then((child) => { - const previousAddUsingParent = child.addUsingParent; + const spy = jasmine.createSpy(); - const intervalId = setInterval(function() { - // Detect reconnection - if (child.addUsingParent !== previousAddUsingParent) { - clearInterval(intervalId); - child.addUsingParent().then(() => { - expect(add.calls.count()).toEqual(1); - connection.destroy(); - done(); - }); - } - }, 10); + connection.promise.then(spy); - child.reload(); + connection.iframe.addEventListener('load', function () { + // Give Penpal time to try to make a handshake. + setTimeout(() => { + expect(spy).not.toHaveBeenCalled(); + done(); + }, 100); + }); }); - }); - it('reconnects after child navigates to other page with different methods', (done) => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html` + it('reconnects after child reloads', (done) => { + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html` + }); + + connection.promise.then((child) => { + const previousMultiply = child.multiply; + + const intervalId = setInterval(function () { + // Detect reconnection + if (child.multiply !== previousMultiply) { + clearInterval(intervalId); + child.multiply(2, 4).then((value) => { + expect(value).toEqual(8); + connection.destroy(); + done(); + }); + } + }, 10); + + child.reload(); + }); }); - connection.promise.then((child) => { - const intervalId = setInterval(function() { - // Detect reconnection - if (child.divide) { - clearInterval(intervalId); - expect(child.multiply).not.toBeDefined(); - child.divide(6, 3).then((value) => { - expect(value).toEqual(2); - connection.destroy(); - done(); - }); + // Issue #18 + it('properly disconnects previous call receiver upon reconnection', (done) => { + const add = jasmine.createSpy().and.callFake((num1, num2) => { + return num1 + num2; + }); + + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html`, + methods: { + add } - }, 10); + }); - child.navigate(); + connection.promise.then((child) => { + const previousAddUsingParent = child.addUsingParent; + + const intervalId = setInterval(function () { + // Detect reconnection + if (child.addUsingParent !== previousAddUsingParent) { + clearInterval(intervalId); + child.addUsingParent().then(() => { + expect(add.calls.count()).toEqual(1); + connection.destroy(); + done(); + }); + } + }, 10); + + child.reload(); + }); }); - }); - it('rejects promise if connectToChild times out', (done) => { - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html`, - timeout: 0 - }); + it('reconnects after child navigates to other page with different methods', (done) => { + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html` + }); - connection.promise.catch((error) => { - expect(error).toEqual(jasmine.any(Error)); - expect(error.message).toBe('Connection to child timed out after 0ms'); - expect(error.code).toBe(Penpal.ERR_CONNECTION_TIMEOUT); - done(); + connection.promise.then((child) => { + const intervalId = setInterval(function () { + // Detect reconnection + if (child.divide) { + clearInterval(intervalId); + expect(child.multiply).not.toBeDefined(); + child.divide(6, 3).then((value) => { + expect(value).toEqual(2); + connection.destroy(); + done(); + }); + } + }, 10); + + child.navigate(); + }); }); - }); - it('doesn\'t destroy connection if connection succeeds then ' + - 'timeout passes (connectToChild)', (done) => { - jasmine.clock().install(); + it('rejects promise if connectToChild times out', (done) => { + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html`, + timeout: 0 + }); - const connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/child.html`, - timeout: 100000 + connection.promise.catch((error) => { + expect(error).toEqual(jasmine.any(Error)); + expect(error.message).toBe('Connection to child timed out after 0ms'); + expect(error.code).toBe(Penpal.ERR_CONNECTION_TIMEOUT); + done(); + }); }); - connection.promise.then(() => { - jasmine.clock().tick(100001); + it('doesn\'t destroy connection if connection succeeds then ' + + 'timeout passes (connectToChild)', (done) => { + jasmine.clock().install(); - expect(connection.iframe.parentNode).not.toBeNull(); + const connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/child.html`, + timeout: 100000 + }); - jasmine.clock().uninstall(); - connection.destroy(); - done(); - }); - }); + connection.promise.then(() => { + jasmine.clock().tick(100001); - it('doesn\'t destroy connection if connection succeeds then ' + - 'timeout passes (connectToParent)', (done) => { + expect(connection.iframe.parentNode).not.toBeNull(); - var connection = Penpal.connectToChild({ - url: `${CHILD_SERVER}/childTimeoutAfterSucceeded.html`, - methods: { - reportStillConnected() { + jasmine.clock().uninstall(); connection.destroy(); done(); - } - } - }); - }); + }); + }); - it('rejects promise if connectToParent times out', (done) => { - const connection = Penpal.connectToParent({ - timeout: 0 - }); + it('doesn\'t destroy connection if connection succeeds then ' + + 'timeout passes (connectToParent)', (done) => { + + var connection = Penpal.connectToChild({ + url: `${CHILD_SERVER}/childTimeoutAfterSucceeded.html`, + methods: { + reportStillConnected() { + connection.destroy(); + done(); + } + } + }); + }); - connection.promise.catch((error) => { - expect(error).toEqual(jasmine.any(Error)); - expect(error.message).toBe('Connection to parent timed out after 0ms'); - expect(error.code).toBe(Penpal.ERR_CONNECTION_TIMEOUT); - connection.destroy(); - done(); + it('rejects promise if connectToParent times out', (done) => { + const connection = Penpal.connectToParent({ + timeout: 0 + }); + + connection.promise.catch((error) => { + expect(error).toEqual(jasmine.any(Error)); + expect(error.message).toBe('Connection to parent timed out after 0ms'); + expect(error.code).toBe(Penpal.ERR_CONNECTION_TIMEOUT); + connection.destroy(); + done(); + }); }); }); diff --git a/types/index.d.ts b/types/index.d.ts index aa4acf8..cacc5b9 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -46,6 +46,7 @@ export type ConnectionMethods = { [P in keyof T]: () => Promise }; type ERR_CONNECTION_DESTROYED = 'ConnectionDestroyed'; type ERR_CONNECTION_TIMEOUT = 'ConnectionTimeout'; type ERR_NOT_IN_IFRAME = 'NotInIframe'; +type ERR_IFRAME_ALREADY_ATTACHED_TO_DOM = 'IframeAlreadyAttachedToDom'; export interface IConnectionOptions { methods?: ConnectionMethods; @@ -55,6 +56,7 @@ export interface IConnectionOptions { export interface IChildConnectionOptions extends IConnectionOptions { url: string; appendTo?: HTMLElement; + iframe?: HTMLIFrameElement; } export interface IParentConnectionOptions extends IConnectionOptions { diff --git a/types/type-tests.ts b/types/type-tests.ts index 3a96a5e..c7bb4f2 100644 --- a/types/type-tests.ts +++ b/types/type-tests.ts @@ -27,11 +27,16 @@ const childMethods = { const parentContainer = document.getElementById('iframeContainer'); if (!parentContainer) throw new Error('Parent container not found'); +const iframeToUse = document.createElement('iframe'); +if (!iframeToUse) throw new Error('Parent iframe element has not been created'); + const permissiveParentConnection = Penpal.connectToChild({ // URL of page to load into iframe. url: 'http://example.com/iframe.html', // Container to which the iframe should be appended. appendTo: parentContainer, + // iframe to use + iframe: iframeToUse, // Methods parent is exposing to child methods: parentMethods }); @@ -46,6 +51,8 @@ const strictParentConnection = Penpal.connectToChild({ url: 'http://example.com/iframe.html', // Container to which the iframe should be appended. appendTo: parentContainer, + // iframe to use + iframe: iframeToUse, // Methods parent is exposing to child methods: parentMethods });