diff --git a/.babelrc b/.babelrc
new file mode 100644
index 0000000..f2f3806
--- /dev/null
+++ b/.babelrc
@@ -0,0 +1,3 @@
+{
+ "presets": ["@nx/js/babel"]
+}
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..c38e81c
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,27 @@
+{
+ "extends": ["../../.eslintrc.client-base.json"],
+ "ignorePatterns": ["!**/*"],
+ "overrides": [
+ {
+ "files": ["*.tsx", "*.ts"],
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "project": ["libs/frame-events-api/tsconfig.*?.json"]
+ }
+ },
+ {
+ "files": ["ParentFrame.ts", "ChildFrame.ts"],
+ "rules": {
+ "no-console": "off",
+ "@typescript-eslint/no-explicit-any": "off"
+ }
+ },
+ {
+ "files": ["*.spec.ts"],
+ "rules": {
+ "@typescript-eslint/no-empty-function": "off",
+ "@typescript-eslint/ban-ts-comment": "off"
+ }
+ }
+ ]
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fdefde4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+coverage
+*.vscode
+build
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d4cdb45
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,116 @@
+# Changelog
+
+This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
+
+## [2.2.5](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.2.4...frame-events-api@2.2.5) (2024-05-15)
+
+
+
+## [2.2.4](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.2.3...frame-events-api@2.2.4) (2024-04-24)
+
+
+
+## [2.2.3](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.2.2...frame-events-api@2.2.3) (2024-04-10)
+
+
+
+## [2.2.2](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.2.1...frame-events-api@2.2.2) (2024-03-19)
+
+
+
+## [2.2.1](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.2.0...frame-events-api@2.2.1) (2024-03-13)
+
+
+### Bug Fixes
+
+* **desktop-web-renderer:** send action API method ([#935](https://github.com/WeTransfer/adtech-monorepo/issues/935)) ([8f339f3](https://github.com/WeTransfer/adtech-monorepo/commit/8f339f3026d703f530f9c49ab1643063ea624c52))
+
+
+
+# [2.2.0](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.1.2...frame-events-api@2.2.0) (2024-03-05)
+
+
+### Features
+
+* **frame-events-api:** clone third-party script inline content ([#916](https://github.com/WeTransfer/adtech-monorepo/issues/916)) ([c524b7b](https://github.com/WeTransfer/adtech-monorepo/commit/c524b7b75308f74e304ef9f237c278f38d63ddf8))
+
+
+
+## [2.1.2](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.1.1...frame-events-api@2.1.2) (2024-02-29)
+
+
+
+## [2.1.1](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.1.0...frame-events-api@2.1.1) (2023-12-07)
+
+# [2.1.0](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.0.6...frame-events-api@2.1.0) (2023-10-09)
+
+### Features
+
+- **frame-events-api:** new event emitter helper ([#789](https://github.com/WeTransfer/adtech-monorepo/issues/789)) ([b01ee2b](https://github.com/WeTransfer/adtech-monorepo/commit/b01ee2ba91d1b92424588e2979c30b7c6f1cf075))
+
+## [2.0.6](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.0.5...frame-events-api@2.0.6) (2023-05-04)
+
+## [2.0.5](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.0.4...frame-events-api@2.0.5) (2023-05-01)
+
+## [2.0.4](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.0.3...frame-events-api@2.0.4) (2023-04-25)
+
+## [2.0.3](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.0.2...frame-events-api@2.0.3) (2023-04-12)
+
+## [2.0.2](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.0.1...frame-events-api@2.0.2) (2023-04-12)
+
+### Bug Fixes
+
+- **ad-sdk:** update parent api state ([#552](https://github.com/WeTransfer/adtech-monorepo/issues/552)) ([975891d](https://github.com/WeTransfer/adtech-monorepo/commit/975891db5ed7e9cf022214d6022b3c5ebdedf458))
+
+## [2.0.1](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@2.0.0...frame-events-api@2.0.1) (2023-03-30)
+
+# [2.0.0](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@1.1.0...frame-events-api@2.0.0) (2023-01-24)
+
+- [ADT-745]: upgrade nx (#440) ([bc2ef3a](https://github.com/WeTransfer/adtech-monorepo/commit/bc2ef3accd24f723a3316a795e2b62bc903bb618)), closes [#440](https://github.com/WeTransfer/adtech-monorepo/issues/440) [#ADT-745](https://github.com/WeTransfer/adtech-monorepo/issues/ADT-745) [#ADT-745](https://github.com/WeTransfer/adtech-monorepo/issues/ADT-745)
+
+### BREAKING CHANGES
+
+- wallpaper.unmount is not supported anymore
+
+- upgrade packages (fix ReactCSSTransition error with react 18)
+
+- specify node version via nvmrc
+
+- align version with transfer FE - needed for rewire-ts
+
+- downgrade react to keep being compatible with FE
+
+- nvm file conflicts with husky, revert node setup
+
+- update @jscutlery/semver
+
+- use lts/fermium to support rewire
+
+- review - remove test inclusion
+
+- cast wallpaper for ts compilation
+
+- fix import.media.url outside of module with publicPath
+
+- removed unneeded packages - added by @nrwl/web
+
+# [1.1.0](https://github.com/WeTransfer/adtech-monorepo/compare/frame-events-api@1.0.0...frame-events-api@1.1.0) (2023-01-04)
+
+### Features
+
+- **mobile-web:** request new ad ([#435](https://github.com/WeTransfer/adtech-monorepo/issues/435)) ([ab048ef](https://github.com/WeTransfer/adtech-monorepo/commit/ab048efeb0868b610915bc1fcb24a4d49cf3f43a))
+
+# 1.0.0 (2022-12-13)
+
+### Features
+
+- **frame-events-api:** enable several placements per app ([#389](https://github.com/WeTransfer/adtech-monorepo/issues/389)) ([a22568b](https://github.com/WeTransfer/adtech-monorepo/commit/a22568bd17bba5a49ee7aa91c0d05438d8138522))
+- **frame-events-api:** rename-padre-y-marco clean ([#376](https://github.com/WeTransfer/adtech-monorepo/issues/376)) ([57c2486](https://github.com/WeTransfer/adtech-monorepo/commit/57c248609f595be61b49d6f9ebb36a0130ba985e))
+
+### BREAKING CHANGES
+
+- **frame-events-api:** Frames must agree on a placement name
+
+- Use query parameters to define placement
+
+- Docs and cleanup
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2ef2f01
--- /dev/null
+++ b/README.md
@@ -0,0 +1,136 @@
+# frame-events-api
+
+Frame Events API is a library for establishing secure parent and child 2-way communication when working with iframes and the `window.postMessage` method.
+
+## How it works
+
+The library consists in two classes, `ParentFrame`, to be instantiated in the parent document and `ChildFrame`, to be run in the embedded document. They both make use of the `Window.postMessage()` method and the `onmessage` event handler.
+
+[Receiver and emitter diagram](./docs/event_flow.drawio)
+
+When a ParentFrame instance defines an interface it sends a ready event to the ChildFrame instance in the embedded document. When the ChildFrame instance receives the ready event it runs the subscriber callback.
+
+[Subscriber callback diagram](./docs/subscriber_callback.drawio)
+
+## Using ParentFrame
+
+```typescript
+new ParentFrame(options);
+```
+
+### Options
+
+| Name | Type | |
+| --------- | ------------------- | ---------- |
+| child | `HTMLIFrameElement` | `required` |
+| methods | `object` | |
+| listeners | `string[]` | |
+| scripts | `string[]` | |
+
+#### child
+
+A child is a `HTMLIFrameElement` that is embedding a document with a ChildFrame instance into the parent document. This iframe must be attached to the DOM and ready to receive events.
+
+When building your iframe source you must specify the parent origin in order to establish a secure connection.
+
+```html
+
+```
+
+#### methods
+
+This is an object with methods that can be fired by the embedded document. When defining method make sure you:
+
+- Don't use any reserved words like `ready`.
+- Add descriptive meaningful function names, they will later be exposed.
+- By design, the methods provided can only take one parameter.
+
+#### listeners
+
+Listeners is an array of event names that you are opening for subscription in the embedded document.
+
+#### scripts
+
+An array of html script tags that you want to ad to the embedded document head.
+
+## Using ChildFrame
+
+```typescript
+new ChildFrame(myCallbackMethod);
+```
+
+### Options
+
+| Name | Type | | |
+| -------- | ---------- | ---------- | ------------------------------------------- |
+| callback | `function` | `required` | Fires when the parent sends the ready event |
+
+#### callback
+
+A function that will execute when the ChildFrame instance gets the ready signal from the parent frame. This function takes as an argument all event names you can listen to and every command you can execute.
+
+## Example
+
+In the main document:
+
+```typescript
+const state = {
+ counter: 0,
+};
+const myAPI = new ParentFrame({
+ child: document.querySelector('iframe'),
+ methods: {
+ updateCounter: function () {
+ state.counter = state.counter++;
+ this.send('counterUpdated', {
+ counter: state.counter,
+ });
+ },
+ },
+ listeners: ['counterUpdated'],
+ scripts: ['', ''],
+});
+```
+
+Remember to pass the parent origin and the placement as query parameters: `_origin=PARENT_HOST&_placement=PLACEMENT_NAME`:
+
+```html
+
+```
+
+In the embedded document:
+
+```typescript
+const myChildAPI = new ChildFrame(function (data) {
+ // Communication was succesfully established
+ const { listeners, methods } = data;
+
+ // Listen for events
+ myChildAPI.listeners.counterUpdated((event) => {});
+
+ // Fire commands
+ document.querySelector('button').addEventListener('click', function () {
+ myChildAPI.run.updateCounter();
+ });
+});
+```
+
+## Known Issues
+
+- IntelliSense won't work due to how we add the methods to the namespace.
+
+## Build
+
+```
+ nx build frame-events-api --prod
+```
+
+This library was generated with [Nx](https://nx.dev).
+
+## Running unit tests
+
+Run `nx test frame-events-api` to execute the unit tests via [Jest](https://jestjs.io).
diff --git a/docs/event_flow.drawio b/docs/event_flow.drawio
new file mode 100644
index 0000000..e8f06be
--- /dev/null
+++ b/docs/event_flow.drawio
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/subscriber_callback.drawio b/docs/subscriber_callback.drawio
new file mode 100644
index 0000000..73105c8
--- /dev/null
+++ b/docs/subscriber_callback.drawio
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/jest.config.ts b/jest.config.ts
new file mode 100644
index 0000000..9f21728
--- /dev/null
+++ b/jest.config.ts
@@ -0,0 +1,13 @@
+import type { Config } from 'jest';
+
+const config: Config = {
+ displayName: 'frame-events-api',
+
+ transform: {
+ '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }],
+ },
+ moduleFileExtensions: ['ts', 'js', 'html'],
+ preset: '../../jest.preset.js',
+};
+
+export default config;
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..3c222ac
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@wetransfer/frame-events-api",
+ "version": "2.2.5",
+ "type": "commonjs",
+ "license": "Proprietary",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/WeTransfer/adtech-monorepo.git"
+ },
+ "homepage": "https://github.com/WeTransfer/adtech-monorepo/tree/main/libs/frame-events-api/README.md",
+ "publishConfig": {
+ "registry": "https://npm.pkg.github.com"
+ }
+}
diff --git a/project.json b/project.json
new file mode 100644
index 0000000..9b9bcb9
--- /dev/null
+++ b/project.json
@@ -0,0 +1,47 @@
+{
+ "name": "frame-events-api",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "libs/frame-events-api/src",
+ "projectType": "library",
+ "targets": {
+ "build": {
+ "executor": "@nx/js:tsc",
+ "outputs": ["{options.outputPath}"],
+ "options": {
+ "outputPath": "dist/libs/frame-events-api",
+ "main": "libs/frame-events-api/src/index.ts",
+ "tsConfig": "libs/frame-events-api/tsconfig.lib.json",
+ "assets": ["libs/frame-events-api/*.md"]
+ }
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint",
+ "outputs": ["{options.outputFile}"],
+ "options": {
+ "lintFilePatterns": ["libs/frame-events-api/**/*.ts"]
+ }
+ },
+ "test": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/libs/frame-events-api"],
+ "options": {
+ "jestConfig": "libs/frame-events-api/jest.config.ts"
+ },
+ "configurations": {
+ "ci": {
+ "codeCoverage": true
+ }
+ }
+ },
+ "version": {
+ "executor": "@jscutlery/semver:version",
+ "options": {
+ "baseBranch": "main",
+ "versionTagPrefix": "${target}@",
+ "commitMessageFormat": "ci(${projectName}): 🚀 release to ${version} [cz-release]",
+ "trackDeps": true
+ }
+ }
+ },
+ "tags": ["scope:shared"]
+}
diff --git a/src/ChildFrame.ts b/src/ChildFrame.ts
new file mode 100644
index 0000000..cd9f334
--- /dev/null
+++ b/src/ChildFrame.ts
@@ -0,0 +1,105 @@
+import { InitialFrameEvent, RESERVED_READY_COMMAND } from './ParentFrame';
+import ERROR_MESSAGES from './constants/error-messages';
+import Events, { SubscriberCallback } from './helpers/event-emitter';
+import { loadScriptTags } from './helpers/load-script-tags';
+
+export default class ChildFrame {
+ private callback: SubscriberCallback;
+ readonly endpoint: string | null;
+ readonly listeners: { [key: string]: (...args: any[]) => void };
+ readonly run: { [key: string]: (...args: any[]) => void };
+ public parentPlacement: string | null;
+ readonly eventEmitter: Events = new Events();
+
+ constructor(initCallback: SubscriberCallback) {
+ // Register endpoint
+ const urlParams = new URLSearchParams(window.location.search);
+ this.endpoint = urlParams.get('_origin');
+ if (!this.endpoint) {
+ throw new Error(ERROR_MESSAGES.CANT_VALIDATE_ORIGIN);
+ }
+
+ // Get parent placement from location
+ this.parentPlacement = urlParams.get('_placement');
+ if (!this.parentPlacement) {
+ throw new Error(ERROR_MESSAGES.CANT_VALIDATE_PLACEMENT);
+ }
+
+ this.callback = initCallback;
+
+ this.eventEmitter.on(
+ RESERVED_READY_COMMAND,
+ this.onParentReady.bind(this) as SubscriberCallback
+ );
+
+ this.listeners = {};
+ this.run = {};
+
+ window.addEventListener('message', this.receiveEvent.bind(this));
+ }
+
+ receiveEvent(event: MessageEvent): void {
+ // Verify the origin
+ if (event.origin !== this.endpoint) return;
+
+ try {
+ const { command, payload, parentPlacement } = this.parseMessage(event);
+
+ // Check placement
+ // Only process messages coming from the parent placement
+ if (parentPlacement !== this.parentPlacement) return;
+
+ if (command === RESERVED_READY_COMMAND) this.onParentReady(event.data);
+ else this.eventEmitter.emit(command, payload);
+ } catch (error) {
+ console.error(error);
+ }
+ }
+
+ parseMessage(event: MessageEvent): {
+ command: string;
+ payload: unknown;
+ parentPlacement: string;
+ } {
+ return {
+ command: event.data.command,
+ payload: event.data.payload,
+ parentPlacement: event.data.placement,
+ };
+ }
+
+ onParentReady(payload: InitialFrameEvent): void {
+ const { availableListeners, availableMethods, scripts } = payload;
+
+ // Attach listeners and commands
+ availableListeners &&
+ availableListeners.forEach((name: string) => {
+ this.listeners[name] = (fn: SubscriberCallback) => {
+ this.eventEmitter.on(name, fn);
+ };
+ });
+
+ availableMethods &&
+ availableMethods.forEach((command: string) => {
+ this.run[command] = (data) => {
+ this.sendCommand(command, data);
+ };
+ });
+
+ // Add third party scripts
+ scripts && loadScriptTags(scripts);
+
+ // Fire custom callback
+ this.callback(payload);
+ }
+
+ sendCommand(command: string, payload: unknown): void {
+ const data = {
+ command,
+ payload,
+ placement: this.parentPlacement,
+ };
+
+ window.parent.postMessage(data, this.endpoint as string);
+ }
+}
diff --git a/src/ParentFrame.ts b/src/ParentFrame.ts
new file mode 100644
index 0000000..8e641ac
--- /dev/null
+++ b/src/ParentFrame.ts
@@ -0,0 +1,165 @@
+import ERROR_MESSAGES from './constants/error-messages';
+import Events, { SubscriberCallback } from './helpers/event-emitter';
+
+export interface ParentFrameMethods {
+ [key: string]: (...args: never[]) => void;
+}
+
+export interface ParentFrameOptions {
+ childFrameNode: HTMLIFrameElement;
+ methods?: ParentFrameMethods;
+ listeners?: string[];
+ scripts?: string[];
+}
+
+export interface FrameEvent {
+ command: string;
+ payload: Payload;
+}
+
+export interface InitialFrameEvent extends FrameEvent {
+ availableListeners: string[] | null;
+ availableMethods: string[] | null;
+ scripts?: string[];
+ placement: string;
+}
+
+export const RESERVED_READY_COMMAND = 'ready';
+
+export default class ParentFrame {
+ readonly child: HTMLIFrameElement;
+ readonly creativeUrl: URL;
+ readonly origin: string;
+ readonly listeners: string[] | null;
+ readonly methods: string[];
+ readonly scripts?: string[];
+ readonly placement: string;
+ readonly events: unknown[] = [];
+ readonly eventEmitter: Events = new Events();
+
+ constructor({
+ childFrameNode,
+ listeners,
+ methods = {},
+ scripts,
+ }: ParentFrameOptions) {
+ if (!childFrameNode.src) {
+ throw new Error(ERROR_MESSAGES.EMPTY_IFRAME);
+ }
+ this.child = childFrameNode;
+ this.origin = window.origin;
+ this.creativeUrl = new URL(this.child.src);
+
+ // A placement name must be defined in the embedded document source
+ const urlParams = new URLSearchParams(this.child.src);
+ this.placement = urlParams.get('_placement') || '';
+ if (!this.placement || this.placement === '') {
+ throw new Error(ERROR_MESSAGES.CANT_VALIDATE_PLACEMENT);
+ }
+
+ this.scripts = scripts;
+
+ this.listeners = listeners || null;
+ this.methods = Object.keys(methods);
+
+ window.addEventListener('message', this.receiveEvent.bind(this));
+
+ this.methods &&
+ this.methods.forEach((command: string) => {
+ if (command === RESERVED_READY_COMMAND) {
+ console.error(ERROR_MESSAGES.CANT_USE_READY_COMMAND);
+ return;
+ }
+
+ const event = this.eventEmitter.on(
+ command,
+ methods[command] as SubscriberCallback
+ );
+
+ this.events.push(event);
+ });
+
+ this.send(RESERVED_READY_COMMAND, undefined);
+ }
+
+ receiveEvent(event: MessageEvent): void {
+ // Check origin
+ // Because of browser's security restrictions, we need to know
+ // the remote host of wallpapers, and specify this value
+ // when the message is sent.
+ if (this.creativeUrl.origin !== event.origin) return;
+
+ try {
+ const { command, payload, placement } = this.parseMessage(event);
+
+ // Check placement
+ // Only process events coming from the placement embedded doc
+ if (this.placement !== placement) return;
+
+ this.eventEmitter.emit(command, payload);
+ } catch (error) {
+ console.error(error);
+ }
+ }
+
+ parseMessage(event: MessageEvent): {
+ command: string;
+ payload: unknown;
+ placement: string;
+ } {
+ return {
+ command: event.data.command,
+ payload: event.data.payload,
+ placement: event.data.placement,
+ };
+ }
+
+ buildEventPayload(
+ command: string,
+ payload: unknown
+ ): FrameEvent | InitialFrameEvent {
+ const res = {} as InitialFrameEvent;
+
+ if (command === RESERVED_READY_COMMAND) {
+ res.availableListeners = this.listeners;
+ res.availableMethods = this.methods;
+ res.scripts = this.scripts;
+ }
+
+ return {
+ ...res,
+ command,
+ payload,
+ placement: this.placement,
+ };
+ }
+
+ send(command: string, event: unknown): void {
+ if (
+ this.listeners &&
+ !this.listeners.includes(command) &&
+ command !== RESERVED_READY_COMMAND
+ ) {
+ throw new Error(ERROR_MESSAGES.NOT_DEFINED_EVENT_NAME);
+ }
+
+ if (!this.child.contentWindow) return;
+
+ const { origin: creativeOrigin } = this.creativeUrl;
+ if (!creativeOrigin) return;
+
+ try {
+ const payload = this.buildEventPayload(command, event);
+ this.child.contentWindow.postMessage(payload, creativeOrigin);
+ } catch (error) {
+ console.error(error);
+ }
+ }
+
+ destroy(): void {
+ window.removeEventListener('message', this.receiveEvent.bind(this));
+ this.events.forEach((event: any) => {
+ event.off();
+ });
+ }
+}
diff --git a/src/__tests__/ChildFrame.spec.ts b/src/__tests__/ChildFrame.spec.ts
new file mode 100644
index 0000000..81bc739
--- /dev/null
+++ b/src/__tests__/ChildFrame.spec.ts
@@ -0,0 +1,269 @@
+import ChildFrame from '../ChildFrame';
+import Events from '../helpers/event-emitter';
+import { loadScriptTags } from '../helpers/load-script-tags';
+import { InitialFrameEvent } from '../ParentFrame';
+
+jest.mock('../helpers/event-emitter');
+jest.mock('../helpers/load-script-tags');
+
+describe('ChildFrame class', () => {
+ describe('construct class', () => {
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('should throw an error if no origin is present is the URL', () => {
+ // @ts-ignore
+ delete window.location;
+ window.location = {
+ search: '?_placement=myParentPlacement',
+ } as Location;
+
+ expect(() => {
+ new ChildFrame(jest.fn);
+ }).toThrowError(
+ `Can't validate origin! please add ?_origin=PARENT_HOST to the iframe source`
+ );
+ });
+
+ it('should throw an error if no placement is present is the URL', () => {
+ // @ts-ignore
+ delete window.location;
+ window.location = {
+ search: '?_origin=http://parent:1',
+ } as Location;
+
+ expect(() => {
+ new ChildFrame(jest.fn);
+ }).toThrowError(
+ `Can't validate placement! please add ?_placement=PLACEMENT_NAME to the iframe source`
+ );
+ });
+
+ it('should get the parent origin from the URL', () => {
+ // @ts-ignore
+ delete window.location;
+ window.location = {
+ search: '?_origin=http://parent:1&_placement=myParentPlacement',
+ } as Location;
+ const marco = new ChildFrame(jest.fn);
+
+ expect(marco.endpoint).toBe('http://parent:1');
+ });
+
+ it('should start listening for message events', () => {
+ jest.spyOn(window, 'addEventListener');
+ new ChildFrame(jest.fn);
+
+ expect(window.addEventListener).toHaveBeenCalledTimes(1);
+ expect(window.addEventListener).toHaveBeenCalledWith(
+ 'message',
+ expect.any(Function)
+ );
+ });
+
+ it('should define a parent placement', () => {
+ const marco = new ChildFrame(jest.fn);
+
+ expect(marco.parentPlacement).toBe('myParentPlacement');
+ });
+ });
+
+ describe('receiveEvent method', () => {
+ let event: MessageEvent;
+ let parseMessageMock: jest.SpyInstance;
+ let onParentReadyMock: jest.SpyInstance;
+ let consoleErrorSpy: jest.SpyInstance;
+ let emitEventSpy: jest.SpyInstance;
+
+ beforeAll(() => {
+ parseMessageMock = jest.spyOn(ChildFrame.prototype, 'parseMessage');
+ parseMessageMock.mockImplementation(() => Promise.resolve());
+ onParentReadyMock = jest.spyOn(ChildFrame.prototype, 'onParentReady');
+ onParentReadyMock.mockImplementation(() => Promise.resolve());
+ consoleErrorSpy = jest.spyOn(console, 'error');
+ consoleErrorSpy.mockImplementation(() => {});
+ emitEventSpy = jest.spyOn(Events.prototype, 'emit');
+
+ event = new MessageEvent('message');
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('should reject events not coming from the parent', () => {
+ global.dispatchEvent(event);
+
+ expect(parseMessageMock).not.toHaveBeenCalled();
+ });
+
+ it('should try to parse the message', () => {
+ jest.spyOn(event, 'origin', 'get').mockReturnValueOnce('http://parent:1');
+ global.dispatchEvent(event);
+
+ expect(parseMessageMock).toHaveBeenCalled();
+ });
+
+ it('should log parsing errors', () => {
+ parseMessageMock.mockImplementationOnce(() => {
+ throw new Error('Parsing error');
+ });
+ jest.spyOn(event, 'origin', 'get').mockReturnValueOnce('http://parent:1');
+ global.dispatchEvent(event);
+
+ expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(new Error('Parsing error'));
+ });
+
+ it('should not emit an internal event if the message comes from a different placement', () => {
+ parseMessageMock.mockReturnValueOnce({
+ payload: {},
+ command: 'myCommand',
+ parentPlacement: 'otherPlacement',
+ });
+
+ jest.spyOn(event, 'origin', 'get').mockReturnValueOnce('http://parent:1');
+ global.dispatchEvent(event);
+
+ expect(emitEventSpy).not.toBeCalled();
+ });
+
+ it('should emit an internal event', () => {
+ parseMessageMock.mockReturnValueOnce({
+ payload: {},
+ command: 'myCommand',
+ parentPlacement: 'myParentPlacement',
+ });
+ jest.spyOn(event, 'origin', 'get').mockReturnValueOnce('http://parent:1');
+ global.dispatchEvent(event);
+
+ expect(emitEventSpy).toHaveBeenCalledWith('myCommand', {});
+ });
+
+ it('should fire the onParentReady callback if the event is the default ready event', () => {
+ parseMessageMock.mockReturnValueOnce({
+ command: 'ready',
+ parentPlacement: 'myParentPlacement',
+ });
+ jest.spyOn(event, 'origin', 'get').mockReturnValueOnce('http://parent:1');
+ jest.spyOn(event, 'data', 'get').mockReturnValueOnce('event:data');
+ global.dispatchEvent(event);
+
+ expect(onParentReadyMock).toBeCalledWith('event:data');
+ });
+ });
+
+ describe('parseMessage method', () => {
+ let marco: InstanceType;
+ let event: MessageEvent;
+
+ beforeAll(() => {
+ // @ts-ignore
+ delete window.location;
+ window.location = {
+ search: '?_origin=http://parent:1&_placement=myParentPlacement',
+ } as Location;
+ marco = new ChildFrame(jest.fn);
+ event = new MessageEvent('message');
+ jest.spyOn(event, 'data', 'get').mockReturnValue({
+ command: 'ready',
+ payload: {},
+ placement: 'myParentPlacement',
+ });
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('should parse the message', async () => {
+ const { payload, command, parentPlacement } = marco.parseMessage(event);
+
+ expect(command).toEqual('ready');
+ expect(payload).toEqual({});
+ expect(parentPlacement).toEqual('myParentPlacement');
+ });
+ });
+
+ describe('onParentReady method', () => {
+ let marco: InstanceType;
+ const callbackMock = jest.fn();
+ let payload: InitialFrameEvent;
+
+ beforeAll(() => {
+ // @ts-ignore
+ delete window.location;
+ window.location = {
+ search: '?_origin=http://parent:1&_placement=myParentPlacement',
+ } as Location;
+ marco = new ChildFrame(callbackMock);
+ payload = {
+ availableMethods: ['method1', 'method2'],
+ availableListeners: ['myListener'],
+ command: '',
+ payload: {},
+ placement: 'myParentPlacement',
+ scripts: [''],
+ } as unknown as InitialFrameEvent;
+
+ marco.onParentReady(payload);
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('should add available event listeners', () => {
+ expect(marco.listeners['myListener']).toBeDefined();
+ });
+
+ it('should register available methods', () => {
+ expect(marco.run['method1']).toBeDefined();
+ expect(marco.run['method2']).toBeDefined();
+ });
+
+ it('should fire the init callback', () => {
+ expect(callbackMock).toHaveBeenCalledTimes(1);
+ expect(callbackMock).toHaveBeenCalledWith(payload);
+ });
+
+ it('should pass the scripts to the helper method', () => {
+ expect(loadScriptTags).toHaveBeenCalledTimes(1);
+ expect(loadScriptTags).toHaveBeenCalledWith(payload.scripts);
+ });
+ });
+
+ describe('sendCommand method', () => {
+ let marco: InstanceType;
+ const callbackMock = jest.fn();
+
+ beforeAll(() => {
+ // @ts-ignore
+ delete window.location;
+ window.location = {
+ search: '?_origin=http://parent:1&_placement=myParentPlacement',
+ } as Location;
+ window.parent.postMessage = jest.fn();
+ marco = new ChildFrame(callbackMock);
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('should post a message', () => {
+ marco.sendCommand('myCommand', {});
+
+ expect(window.parent.postMessage).toHaveBeenCalledTimes(1);
+ expect(window.parent.postMessage).toHaveBeenCalledWith(
+ {
+ command: 'myCommand',
+ payload: {},
+ placement: 'myParentPlacement',
+ },
+ 'http://parent:1'
+ );
+ });
+ });
+});
diff --git a/src/__tests__/ParentFrame.spec.ts b/src/__tests__/ParentFrame.spec.ts
new file mode 100644
index 0000000..fc3f7e0
--- /dev/null
+++ b/src/__tests__/ParentFrame.spec.ts
@@ -0,0 +1,406 @@
+import ParentFrame, { ParentFrameOptions } from '../ParentFrame';
+import Events from '../helpers/event-emitter';
+
+jest.mock('../helpers/event-emitter');
+
+describe('ParentFrame class', () => {
+ it('should throw an error if the iframe element does not have a source', () => {
+ expect(() => {
+ const childFrameNode = document.createElement('iframe');
+ const options: ParentFrameOptions = {
+ childFrameNode,
+ };
+ new ParentFrame(options);
+ }).toThrowError(
+ `Not src found. You can't run ParentFrame on an empty iframe element`
+ );
+ });
+
+ describe('Construct class', () => {
+ const childFrameNode = document.createElement('iframe');
+ childFrameNode.src =
+ 'http://child:1/?_origin=http://parent:2&_placement=myParentPlacement';
+ const options: ParentFrameOptions = {
+ childFrameNode,
+ methods: {
+ myMethod() {
+ jest.fn();
+ },
+ myOtherMethod() {
+ jest.fn();
+ },
+ },
+ listeners: ['eventName1', 'eventName2'],
+ scripts: [''],
+ };
+ let padre: InstanceType;
+ let consoleErrorSpy: jest.SpyInstance;
+ let onEventSpy: jest.SpyInstance;
+
+ beforeAll(() => {
+ jest.spyOn(window, 'addEventListener');
+ consoleErrorSpy = jest.spyOn(console, 'error');
+ consoleErrorSpy.mockImplementation(() => {});
+
+ padre = new ParentFrame(options);
+
+ onEventSpy = jest.spyOn(Events.prototype, 'on');
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('should define a child frame node', () => {
+ expect(padre.child).toEqual(childFrameNode);
+ });
+
+ it('should expose an array of defined method names', () => {
+ expect(padre.methods).toEqual(['myMethod', 'myOtherMethod']);
+ });
+
+ it('should expose the collection of 3rd party scripts', () => {
+ expect(padre.scripts).toEqual([
+ '',
+ ]);
+ });
+
+ it('should expose the collection of 3rd party scripts', () => {
+ expect(padre.scripts).toEqual([
+ '',
+ ]);
+ });
+
+ it('should expose the defined array of listeners', () => {
+ expect(padre.listeners).toEqual(['eventName1', 'eventName2']);
+ });
+
+ it('should expose the creative source as a URL object', () => {
+ expect(padre.creativeUrl).toEqual(
+ new URL('http://child:1/?_origin=http://parent:2')
+ );
+ });
+
+ it('should register a listener for message events', () => {
+ expect(window.addEventListener).toHaveBeenCalledTimes(1);
+ expect(window.addEventListener).toHaveBeenCalledWith(
+ 'message',
+ expect.any(Function)
+ );
+ });
+
+ it('should create event subscribers for defined methods', () => {
+ expect(onEventSpy).toBeCalledTimes(2);
+ expect((onEventSpy as jest.Mock).mock.calls).toEqual([
+ ['myMethod', expect.any(Function)],
+ ['myOtherMethod', expect.any(Function)],
+ ]);
+ });
+
+ it('should define a placement', () => {
+ expect(padre.placement).toEqual('myParentPlacement');
+ });
+
+ it('should throw an error if the placement name is not defined', () => {
+ jest.clearAllMocks();
+ const childFrameNode = document.createElement('iframe');
+ childFrameNode.src = 'http://child:1/?_origin=http://parent:2';
+ const options: ParentFrameOptions = {
+ childFrameNode,
+ methods: {
+ ready() {
+ jest.fn();
+ },
+ },
+ };
+
+ expect(() => {
+ new ParentFrame(options);
+ }).toThrowError(
+ `Can't validate placement! please add ?_placement=PLACEMENT_NAME to the iframe source`
+ );
+ });
+
+ it('should throw a warning when registering with a reserved name method', () => {
+ jest.clearAllMocks();
+ jest.spyOn(window, 'addEventListener');
+ const childFrameNode = document.createElement('iframe');
+ childFrameNode.src =
+ 'http://child:1/?_origin=http://parent:2&_placement=myParentPlacement';
+ const options: ParentFrameOptions = {
+ childFrameNode,
+ methods: {
+ ready() {
+ jest.fn();
+ },
+ },
+ };
+ padre = new ParentFrame(options);
+
+ expect(onEventSpy).not.toHaveBeenCalled();
+ expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ `ready is a reserved command`
+ );
+ });
+ });
+
+ describe('receiveEvent method', () => {
+ let event: MessageEvent;
+ let parseMessageMock: jest.SpyInstance;
+ let consoleErrorSpy: jest.SpyInstance;
+ let emitEventSpy: jest.SpyInstance;
+
+ beforeAll(() => {
+ const childFrameNode = document.createElement('iframe');
+ childFrameNode.src =
+ 'http://child:1/?_origin=http://parent:2&_placement=myParentPlacement';
+
+ parseMessageMock = jest.spyOn(ParentFrame.prototype, 'parseMessage');
+ parseMessageMock.mockImplementation(() => Promise.resolve());
+ consoleErrorSpy = jest.spyOn(console, 'error');
+ consoleErrorSpy.mockImplementation(() => {});
+ emitEventSpy = jest.spyOn(Events.prototype, 'emit');
+ event = new MessageEvent('message');
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('should not process events not coming from the child', () => {
+ global.dispatchEvent(event);
+
+ expect(parseMessageMock).not.toHaveBeenCalled();
+ });
+
+ it('should try to parse the message', () => {
+ jest.spyOn(event, 'origin', 'get').mockReturnValueOnce('http://child:1');
+ global.dispatchEvent(event);
+
+ expect(parseMessageMock).toHaveBeenCalled();
+ });
+
+ it('should log parsing errors', () => {
+ parseMessageMock.mockImplementationOnce(() => {
+ throw new Error('Parsing error');
+ });
+ jest.spyOn(event, 'origin', 'get').mockReturnValueOnce('http://child:1');
+ global.dispatchEvent(event);
+
+ expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(new Error('Parsing error'));
+ });
+
+ it('should not emit an internal event if the events come from a different frame/placement', () => {
+ parseMessageMock.mockReturnValueOnce({
+ payload: {},
+ command: 'myCommand',
+ placement: 'otherPlacement',
+ });
+ jest.spyOn(event, 'origin', 'get').mockReturnValueOnce('http://child:1');
+ global.dispatchEvent(event);
+
+ expect(emitEventSpy).not.toHaveBeenCalled();
+ });
+
+ it('should emit an internal event', () => {
+ parseMessageMock.mockReturnValueOnce({
+ payload: {},
+ command: 'myCommand',
+ placement: 'myParentPlacement',
+ });
+ jest.spyOn(event, 'origin', 'get').mockReturnValueOnce('http://child:1');
+ global.dispatchEvent(event);
+
+ expect(emitEventSpy).toHaveBeenCalledWith('myCommand', {});
+ });
+ });
+
+ describe('parseMessage method', () => {
+ let padre: InstanceType;
+ let event: MessageEvent;
+
+ beforeAll(() => {
+ const childFrameNode = document.createElement('iframe');
+ childFrameNode.src =
+ 'http://child:1/?_origin=http://parent:2&_placement=myParentPlacement';
+ const options: ParentFrameOptions = {
+ childFrameNode,
+ };
+ padre = new ParentFrame(options);
+ event = new MessageEvent('message');
+ jest.spyOn(event, 'data', 'get').mockReturnValue({
+ command: 'ready',
+ payload: {},
+ placement: 'myPlacement',
+ });
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should parse the message', () => {
+ const { payload, command, placement } = padre.parseMessage(event);
+
+ expect(command).toEqual('ready');
+ expect(payload).toEqual({});
+ expect(placement).toEqual('myPlacement');
+ });
+ });
+
+ describe('buildEventPayload method', () => {
+ let padre: InstanceType;
+
+ beforeAll(() => {
+ const childFrameNode = document.createElement('iframe');
+ childFrameNode.src =
+ 'http://child:1/?_origin=http://parent:2&_placement=myParentPlacement';
+ const options: ParentFrameOptions = {
+ childFrameNode,
+ methods: {
+ myMethod() {},
+ myOtherMethod() {},
+ },
+ listeners: ['myListener'],
+ scripts: [''],
+ };
+ padre = new ParentFrame(options);
+ });
+
+ describe('Regular events', () => {
+ it('should return a FrameEvent', () => {
+ const eP = padre.buildEventPayload('commandName', {
+ key: 'value',
+ });
+
+ expect(eP).toEqual({
+ command: 'commandName',
+ payload: {
+ key: 'value',
+ },
+ placement: 'myParentPlacement',
+ });
+ });
+ });
+
+ describe('Initial event', () => {
+ it('should return an InitialFrameEvent', () => {
+ const eP = padre.buildEventPayload('ready', {
+ key: 'value',
+ });
+
+ expect(eP).toEqual({
+ command: 'ready',
+ payload: {
+ key: 'value',
+ },
+ availableMethods: ['myMethod', 'myOtherMethod'],
+ availableListeners: ['myListener'],
+ scripts: [''],
+ placement: 'myParentPlacement',
+ });
+ });
+ });
+ });
+
+ describe('send method', () => {
+ let padre: InstanceType;
+ let buildEventPayloadMock: jest.SpyInstance;
+ let postMessageMock: jest.Mock;
+ let consoleErrorSpy: jest.SpyInstance;
+
+ beforeAll(() => {
+ const childFrameNode = document.createElement('iframe');
+ childFrameNode.src =
+ 'http://child:1/?_origin=http://parent:2&_placement=myParentPlacement';
+ const options: ParentFrameOptions = {
+ childFrameNode,
+ listeners: ['myListener'],
+ };
+
+ buildEventPayloadMock = jest.spyOn(
+ ParentFrame.prototype,
+ 'buildEventPayload'
+ );
+ buildEventPayloadMock.mockReturnValue({
+ command: 'myListener',
+ payload: {},
+ });
+ consoleErrorSpy = jest.spyOn(console, 'error');
+ consoleErrorSpy.mockImplementation(() => {});
+ postMessageMock = jest.fn();
+ jest.spyOn(console, 'error');
+
+ padre = new ParentFrame(options);
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('should throw if the event you are firing was not previously defined', () => {
+ expect(() => {
+ padre.send('unknown', {});
+ }).toThrowError(
+ `Can't send a not defined event name. Make sure you add your event name first`
+ );
+ });
+
+ it('should return if no content window is defined', () => {
+ padre.send('ready', {});
+ Object.defineProperty(padre, 'child', {
+ value: null,
+ });
+
+ expect(buildEventPayloadMock).not.toHaveBeenCalled();
+ });
+
+ it('should return if no child creative origin is defined', () => {
+ Object.defineProperty(padre, 'child', {
+ value: {
+ contentWindow: {
+ postMessage: postMessageMock,
+ },
+ },
+ });
+ Object.defineProperty(padre, 'creativeUrl', {
+ value: { origin: null },
+ });
+ padre.send('ready', {});
+
+ expect(buildEventPayloadMock).not.toHaveBeenCalled();
+ });
+
+ it('should post a message to the child window', () => {
+ Object.defineProperty(padre, 'creativeUrl', {
+ value: { origin: 'childhost' },
+ });
+ padre.send('ready', {
+ key: 'value',
+ });
+
+ expect(buildEventPayloadMock).toHaveBeenCalledTimes(1);
+ expect(postMessageMock).toHaveBeenCalledTimes(1);
+ expect(postMessageMock).toHaveBeenCalledWith(
+ {
+ command: 'myListener',
+ payload: {},
+ },
+ 'childhost'
+ );
+ });
+
+ it('should log sending errors', () => {
+ buildEventPayloadMock.mockImplementationOnce(() => {
+ throw new Error('Parsing error');
+ });
+ padre.send('ready', {
+ key: 'value',
+ });
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(new Error('Parsing error'));
+ });
+ });
+});
diff --git a/src/constants/__tests__/__snapshots__/error-messages.spec.ts.snap b/src/constants/__tests__/__snapshots__/error-messages.spec.ts.snap
new file mode 100644
index 0000000..5de967c
--- /dev/null
+++ b/src/constants/__tests__/__snapshots__/error-messages.spec.ts.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Error messages should match snapshot 1`] = `
+{
+ "CANT_USE_READY_COMMAND": "ready is a reserved command",
+ "CANT_VALIDATE_ORIGIN": "Can't validate origin! please add ?_origin=PARENT_HOST to the iframe source",
+ "CANT_VALIDATE_PLACEMENT": "Can't validate placement! please add ?_placement=PLACEMENT_NAME to the iframe source",
+ "EMPTY_IFRAME": "Not src found. You can't run ParentFrame on an empty iframe element",
+ "NOT_DEFINED_EVENT_NAME": "Can't send a not defined event name. Make sure you add your event name first.",
+}
+`;
diff --git a/src/constants/__tests__/error-messages.spec.ts b/src/constants/__tests__/error-messages.spec.ts
new file mode 100644
index 0000000..0059854
--- /dev/null
+++ b/src/constants/__tests__/error-messages.spec.ts
@@ -0,0 +1,7 @@
+import ERROR_MESSAGES from '../error-messages';
+
+describe('Error messages', () => {
+ it('should match snapshot', () => {
+ expect(ERROR_MESSAGES).toMatchSnapshot();
+ });
+});
diff --git a/src/constants/error-messages.ts b/src/constants/error-messages.ts
new file mode 100644
index 0000000..d3a18e3
--- /dev/null
+++ b/src/constants/error-messages.ts
@@ -0,0 +1,7 @@
+export default {
+ CANT_VALIDATE_ORIGIN: `Can't validate origin! please add ?_origin=PARENT_HOST to the iframe source`,
+ CANT_VALIDATE_PLACEMENT: `Can't validate placement! please add ?_placement=PLACEMENT_NAME to the iframe source`,
+ CANT_USE_READY_COMMAND: `ready is a reserved command`,
+ EMPTY_IFRAME: `Not src found. You can't run ParentFrame on an empty iframe element`,
+ NOT_DEFINED_EVENT_NAME: `Can't send a not defined event name. Make sure you add your event name first.`,
+};
diff --git a/src/helpers/__tests__/event-emitter.spec.ts b/src/helpers/__tests__/event-emitter.spec.ts
new file mode 100644
index 0000000..c8602f3
--- /dev/null
+++ b/src/helpers/__tests__/event-emitter.spec.ts
@@ -0,0 +1,29 @@
+import Events from '../event-emitter';
+
+describe('Event Emitter helper', () => {
+ const eventEmitter = new Events();
+ const callback1 = jest.fn();
+ const event1 = eventEmitter.on('event1', callback1);
+ const callback2 = jest.fn();
+ const event2 = eventEmitter.on('event2', callback2);
+
+ it('should fire callback methods for all registered events', () => {
+ eventEmitter.emit('event1', { payload: '' });
+ eventEmitter.emit('event2', { payload: '' });
+
+ expect(callback1).toHaveBeenCalledTimes(1);
+ expect(callback1).toHaveBeenCalledWith({ payload: '' });
+ expect(callback2).toHaveBeenCalledTimes(1);
+ expect(callback2).toHaveBeenCalledWith({ payload: '' });
+ });
+
+ it('should allow to unsubscribe from events', () => {
+ event1.off();
+ event2.off();
+ eventEmitter.emit('event1', { payload: '' });
+ eventEmitter.emit('event2', { payload: '' });
+
+ expect(callback1).toHaveBeenCalledTimes(1);
+ expect(callback2).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/helpers/__tests__/load-script-tags.spec.ts b/src/helpers/__tests__/load-script-tags.spec.ts
new file mode 100644
index 0000000..e0e632a
--- /dev/null
+++ b/src/helpers/__tests__/load-script-tags.spec.ts
@@ -0,0 +1,77 @@
+import { loadScriptTags } from '../load-script-tags';
+
+describe('loadScriptTags method', () => {
+ const mockTag = '';
+ const mockTagWithAttributes =
+ '';
+ const createElementSpy = jest.spyOn(document, 'createElement');
+ const querySelectorSpy = jest.spyOn(document, 'querySelector');
+
+ afterEach(() => {
+ const head = document.querySelector('head') as HTMLHeadElement;
+ const scripts = head.querySelectorAll('script') as NodeList;
+ for (let i = 0; i < scripts.length; ++i) {
+ head.removeChild(scripts[i]);
+ }
+ });
+
+ it('should do nothing if no scripts are provided', () => {
+ loadScriptTags(null as unknown as string[]);
+ loadScriptTags([]);
+
+ expect(createElementSpy).not.toHaveBeenCalled();
+ });
+
+ it('should do nothing if for some crazy af reason the document has no head element', () => {
+ querySelectorSpy.mockImplementationOnce(() => null);
+
+ loadScriptTags([mockTag]);
+
+ expect(createElementSpy).not.toHaveBeenCalled();
+ });
+
+ it('should create HTML elements and append them to the head element', () => {
+ loadScriptTags([mockTag]);
+
+ const head = document.querySelector('head') as HTMLHeadElement;
+ const script = head.querySelector('script') as HTMLScriptElement;
+ expect(script).not.toBe(undefined);
+ expect(script.src).toBe('https://moat.com/script.js');
+ });
+
+ it('should keep all attributes in place', () => {
+ loadScriptTags([mockTagWithAttributes]);
+
+ const head = document.querySelector('head') as HTMLHeadElement;
+ const script = head.querySelector('script') as HTMLScriptElement;
+ expect(script).not.toBe(undefined);
+ expect(script.src).toBe('https://moat.com/script-with-attr.js');
+ expect(script.getAttribute('aria-title')).toBe('MOAT Analytics');
+ });
+
+ it('should clone inline content as well', () => {
+ loadScriptTags(['']);
+
+ const head = document.querySelector('head') as HTMLHeadElement;
+ const script = head.querySelector('script') as HTMLScriptElement;
+ expect(script).not.toBe(undefined);
+ expect(script.innerHTML.toString()).toBe('var INLINE_CONTENT;');
+ });
+
+ it('should handle more than one script', () => {
+ loadScriptTags([mockTag, mockTagWithAttributes]);
+
+ const head = document.querySelector('head') as HTMLHeadElement;
+ const scripts = head.querySelectorAll('script') as NodeList;
+ expect(scripts).not.toBe(undefined);
+ expect((scripts[0] as HTMLIFrameElement).src).toBe(
+ 'https://moat.com/script.js'
+ );
+ expect((scripts[1] as HTMLIFrameElement).src).toBe(
+ 'https://moat.com/script-with-attr.js'
+ );
+ expect((scripts[1] as HTMLIFrameElement).getAttribute('aria-title')).toBe(
+ 'MOAT Analytics'
+ );
+ });
+});
diff --git a/src/helpers/event-emitter.ts b/src/helpers/event-emitter.ts
new file mode 100644
index 0000000..454862b
--- /dev/null
+++ b/src/helpers/event-emitter.ts
@@ -0,0 +1,35 @@
+export interface SubscriberCallback {
+ (...args: unknown[]): void;
+}
+export interface Subscribers {
+ [key: string]: SubscriberCallback[];
+}
+
+export default class Events {
+ readonly subscribers: Subscribers;
+
+ constructor() {
+ this.subscribers = {};
+ }
+
+ on(event: string, callback: SubscriberCallback) {
+ if (!this.subscribers[event]) {
+ this.subscribers[event] = [];
+ }
+
+ const index = this.subscribers[event].push(callback) - 1;
+
+ return {
+ off: () => {
+ this.subscribers[event].splice(index, 1);
+ },
+ };
+ }
+
+ emit(event: string, data: unknown) {
+ if (!this.subscribers[event]) return;
+ this.subscribers[event].forEach((subscriberCallback: SubscriberCallback) =>
+ subscriberCallback(data)
+ );
+ }
+}
diff --git a/src/helpers/load-script-tags.ts b/src/helpers/load-script-tags.ts
new file mode 100644
index 0000000..90dd77a
--- /dev/null
+++ b/src/helpers/load-script-tags.ts
@@ -0,0 +1,33 @@
+export const loadScriptTags = (scripts: string[]) => {
+ // Are there any scripts to load?
+ if (!scripts || !scripts.length) return;
+
+ // If this wallpaper doesn't have a the rest won't work.
+ const head = document.querySelector('head');
+ if (!head) return;
+
+ // Loop through scripts
+ for (const script of scripts) {
+ const mySandbox = document.createElement('div');
+ mySandbox.innerHTML = script;
+
+ // Manual 'clone' because actual cloneNode didn't seem to work.
+ const scriptEl = mySandbox.querySelector('script');
+ if (!scriptEl || !scriptEl.attributes) continue;
+
+ // Create a new script element
+ const newScript = document.createElement('script');
+ for (let j = scriptEl.attributes.length; j--; ) {
+ newScript.setAttribute(
+ scriptEl.attributes[j].name,
+ scriptEl.attributes[j].value
+ );
+ }
+
+ // Copy inline content as well
+ newScript.innerHTML = scriptEl.innerHTML;
+
+ // Append the new "cloned" script element
+ head.appendChild(newScript);
+ }
+};
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..31c272a
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,4 @@
+import ParentFrame from './ParentFrame';
+import ChildFrame from './ChildFrame';
+
+export { ParentFrame, ChildFrame };
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..f5b8565
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "commonjs",
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ]
+}
diff --git a/tsconfig.lib.json b/tsconfig.lib.json
new file mode 100644
index 0000000..e85ef50
--- /dev/null
+++ b/tsconfig.lib.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "declaration": true,
+ "types": []
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"]
+}
diff --git a/tsconfig.spec.json b/tsconfig.spec.json
new file mode 100644
index 0000000..546f128
--- /dev/null
+++ b/tsconfig.spec.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "module": "commonjs",
+ "types": ["jest", "node"]
+ },
+ "include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"]
+}