| /** @module infra-sk/test_util |
| * @description |
| * |
| * <p> |
| * A general set of useful functions for tests and demos, |
| * e.g. reducing boilerplate. |
| * </p> |
| */ |
| |
| import { deepCopy } from 'common-sk/modules/object'; |
| import { expect } from 'chai'; |
| |
| /** |
| * Takes a DOM element name (e.g. 'my-component-sk') and returns a factory |
| * function that can be used to obtain new instances of that element. |
| * |
| * The element returned by the factory function is attached to document.body, |
| * and an afterEach() hook is set to automatically remove the element from the |
| * DOM after each test. |
| * |
| * The returned factory function optionally takes a callback function that will |
| * be called with the newly instantiated element before it is attached to the |
| * DOM, giving client code a chance to finish setting up the element before e.g. |
| * the element's connectedCallback() method is invoked. |
| * |
| * Sample usage: |
| * |
| * describe('my-component-sk', () => { |
| * const newInstance = setUpElementUnderTest('my-component-sk'); |
| * |
| * it('should be correctly instantiated', () => { |
| * const myComponentSk = newInstance((el) => { |
| * // This is called before attaching the element to the DOM. |
| * el.setAttribute('hello', 'world'); |
| * }); |
| * |
| * expect(myComponentSk.parentElement).to.equal(document.body); |
| * expect(myComponentSk.getAttribute('hello')).to.equal('world'); |
| * }); |
| * }); |
| * |
| * @param elementName Name of the element to test, e.g. 'foo-sk'. |
| * @return A factory function that optionally takes a callback which is invoked |
| * with the newly instantiated element before it is attached to the DOM. |
| */ |
| export function setUpElementUnderTest<T extends HTMLElement>(elementName: string): (finishSetupCallback?: (instance: T) => void) => T { |
| let element: T | null; |
| |
| afterEach(() => { |
| if (element) { |
| document.body.removeChild(element); |
| element = null; |
| } |
| }); |
| |
| return (finishSetupCallbackFn?: (instance: T) => void) => { |
| element = document.createElement(elementName) as T; |
| if (finishSetupCallbackFn) { |
| finishSetupCallbackFn(element); |
| } |
| document.body.appendChild(element); |
| return element; |
| }; |
| } |
| |
| /** |
| * Returns a promise that will resolve when the given DOM event is caught at |
| * document, or reject if the event isn't caught within the given |
| * amount of time. |
| * |
| * Sample usage: |
| * |
| * // Code under test. |
| * function doSomethingThatTriggersCustomEvent() { |
| * ... |
| * this.dispatchEvent( |
| * new CustomEvent('my-event', {detail: 'hello world', bubbles: true}); |
| * } |
| * |
| * // Test. |
| * it('should trigger a custom event', async () => { |
| * const myEvent = eventPromise('my-event'); |
| * doSomethingThatTriggersCustomEvent(); |
| * const ev = await myEvent; |
| * expect(ev.detail).to.equal('hello world'); |
| * }); |
| * |
| * Set timeoutMillis = 0 to skip call to setTimeout(). This is necessary on |
| * tests that simulate the passing of time with sinon.useFakeTimers(), which |
| * could trigger the timeout before the promise has a chance to catch the event. |
| * |
| * @param event Name of event to catch. |
| * @param timeoutMillis How long to wait for the event before rejecting the |
| * returned promise. |
| * @return A promise that will resolve to the caught event. |
| */ |
| export function eventPromise<T extends Event>(event: string, timeoutMillis = 5000) { |
| const eventCaughtCallback = (resolve: (event: T) => void, _: any, e: T) => resolve(e); |
| const timeoutCallback = |
| (_: any, reject: (reason: any) => void) => reject(new Error( |
| `timed out after ${timeoutMillis} ms while waiting to catch event "${event}"`)); |
| return buildEventPromise<T>(event, timeoutMillis, eventCaughtCallback, timeoutCallback); |
| } |
| |
| /** |
| * Returns a promise that will resolve if the given DOM event is *not* caught at |
| * document after the given amount of time, or reject if the |
| * event is caught. |
| * |
| * Useful for testing code that emits an event based on some condition. |
| * |
| * Sample usage: |
| * |
| * // Code under test. |
| * function maybeTriggerCustomEvent(condition) { |
| * if (condition) { |
| * this.dispatchEvent( |
| * new CustomEvent('my-event', {detail: 'hello world', bubbles: true}); |
| * } else { |
| * // Do nothing. |
| * } |
| * } |
| * |
| * // Test. |
| * it('should not trigger a custom event', async () => { |
| * const noEvent = noEventPromise('my-event'); |
| * maybeTriggerCustomEvent(false); |
| * await noEvent; |
| * }); |
| * |
| * Set timeoutMillis = 0 to skip call to setTimeout(). This is necessary on |
| * tests that simulate the passing of time with sinon.useFakeTimers(), which |
| * could trigger the timeout before the promise has a chance to catch the event. |
| * |
| * @param event Name of event to catch. |
| * @param timeoutMillis How long to wait for the event before rejecting the |
| * returned promise. |
| * @return A promise that will resolve to the caught event. |
| */ |
| export function noEventPromise(event: string, timeoutMillis = 200) { |
| const eventCaughtCallback = |
| (_: any, reject: (reason: any) => void) => |
| reject(new Error(`event "${event}" was caught when none was expected`)); |
| const timeoutCallback = (resolve: () => void) => resolve(); |
| return buildEventPromise<void>( |
| event, |
| timeoutMillis, |
| eventCaughtCallback, |
| timeoutCallback); |
| } |
| |
| /** |
| * Helper function to construct promises based on DOM events. |
| * |
| * @param event Name of event to add a listener for at document. |
| * @param timeoutMillis Milliseconds to wait before timing out. |
| * @param eventCaughtCallback Called when the event is caught with parameters |
| * (resolve, reject, event), where resolve and reject are the functions |
| * passed to the promise's executor function, and event is the Event object |
| * that was caught. |
| * @param timeoutCallback Called after timeoutMillis if no event is caught, |
| * with arguments (resolve, reject) as passed to eventCaughtCallback. |
| * @return A promise that will resolve or reject based exclusively on what the |
| * callback functions do with the resolve and reject parameters. |
| */ |
| function buildEventPromise<T extends Event | void>( |
| event: string, |
| timeoutMillis: number, |
| eventCaughtCallback: (resolve: (value?: T | PromiseLike<T> | undefined) => void, |
| reject: (reason?: any) => void, event: T) => void, |
| timeoutCallback: (resolve: (value?: T | PromiseLike<T> | undefined) => void, |
| reject: (reason?: any) => void) => void, |
| ) { |
| // The executor function passed as a constructor argument to the Promise |
| // object is executed immediately. This guarantees that the event handler |
| // is added to document before returning. |
| return new Promise<T>((resolve, reject) => { |
| let timeout: number; |
| |
| const handler = (e: Event) => { |
| document.removeEventListener(event, handler); |
| window.clearTimeout(timeout); |
| eventCaughtCallback(resolve, reject, e as T); |
| }; |
| |
| // Skip setTimeout() call with timeoutMillis = 0. Useful when faking time in |
| // tests with sinon.useFakeTimers(). See |
| // https://sinonjs.org/releases/v7.5.0/fake-timers/. |
| if (timeoutMillis !== 0) { |
| timeout = window.setTimeout(() => { |
| document.removeEventListener(event, handler); |
| timeoutCallback(resolve, reject); |
| }, timeoutMillis); |
| } |
| document.addEventListener(event, handler); |
| }); |
| } |
| |
| /** |
| * Returns a promise that will resolve when the given sequence of DOM events is caught, or reject |
| * after timeoutMillis. The returned promise will resolve to an array with the caught events. |
| * |
| * This is a generalization of eventPromise() which can be used to catch multiple events of the same |
| * kind. Any out-of-sequence events will be ignored. |
| * |
| * @example |
| * |
| * // Code under test. |
| * function doSomething() { |
| * this.dispatchEvent(new CustomEvent('hey', {detail: 'a', bubbles: true}); |
| * this.dispatchEvent(new CustomEvent('there', {detail: 'b', bubbles: true}); // Unknown. |
| * this.dispatchEvent(new CustomEvent('hey', {detail: 'c', bubbles: true}); |
| * this.dispatchEvent(new CustomEvent('hey', {detail: 'd', bubbles: true}); // Ignored. |
| * this.dispatchEvent(new CustomEvent('hello', {detail: 'e', bubbles: true}); |
| * this.dispatchEvent(new CustomEvent('world', {detail: 'f', bubbles: true}); |
| * this.dispatchEvent(new CustomEvent('hey', {detail: 'g', bubbles: true}); // Ignored. |
| * this.dispatchEvent(new CustomEvent('world', {detail: 'h', bubbles: true}); // Ignored. |
| * this.dispatchEvent(new CustomEvent('ok', {detail: 'i', bubbles: true}); // Unknown. |
| * this.dispatchEvent(new CustomEvent('goodbye', {detail: 'j', bubbles: true}); |
| * } |
| * |
| * // Test. |
| * it('should trigger events', async () => { |
| * const sequence = |
| * eventSequencePromise<CustomEvent<string>>(['hey', 'hey', 'hello', 'world', 'goodbye']); |
| * doSomething(); |
| * const events = await sequence; |
| * expect(events).to.have.length(5) |
| * expect(events[0].detail).to.equal('a'); |
| * expect(events[1].detail).to.equal('c'); |
| * expect(events[2].detail).to.equal('e'); |
| * expect(events[3].detail).to.equal('f'); |
| * expect(events[4].detail).to.equal('j'); |
| * }); |
| */ |
| export async function eventSequencePromise<T extends Event>(events: string[], timeoutMillis = 200) { |
| if (events.length === 0) { |
| return []; |
| } |
| |
| return new Promise<T[]>((resolve, reject) => { |
| const eventsToGo = deepCopy(events); // We'll remove events from this list as we catch them. |
| const caughtEvents: T[] = []; // Will store any caught in-sequence events. |
| |
| // We'll keep a reference to each event listener we add so we can remove them later. |
| const eventHandlers = new Map<string, (event: T) => void>(); |
| |
| let timeout: number | null = null; |
| |
| // Called after resolving or rejecting to remove any event listeners and clear the timeout. |
| const cleanUp = () => { |
| if (timeout) { |
| window.clearTimeout(timeout); |
| } |
| |
| eventHandlers.forEach((handler, eventName) => { |
| document.removeEventListener(eventName, handler as (event: Event) => void); |
| }); |
| } |
| |
| // Set up the event handlers. |
| for(const eventName of events) { |
| // Adding a handler for each event once allows us to catch sequences with multiple instances |
| // of the same event. |
| if (!eventHandlers.has(eventName)) { |
| const handler = (event: T) => { |
| // Skip if the caught event is out of sequence. |
| if (eventsToGo[0] !== eventName) { |
| return; |
| } |
| |
| eventsToGo.splice(0, 1); // Remove the 0th event name from the sequence. |
| caughtEvents.push(event); |
| |
| // Resolve promise if we're done. |
| if (eventsToGo.length === 0) { |
| cleanUp(); |
| resolve(caughtEvents); |
| } |
| }; |
| |
| eventHandlers.set(eventName, handler); |
| document.addEventListener(eventName, handler as (event: Event) => void); |
| } |
| } |
| |
| // Set up the timeout. |
| if (timeoutMillis !== 0) { |
| timeout = window.setTimeout(() => { |
| cleanUp(); |
| reject( |
| `timed out after ${timeoutMillis} ms while waiting to catch events ` + |
| `"${eventsToGo.join(`", "`)}"`); |
| }, timeoutMillis); |
| } |
| }); |
| } |
| |
| /** |
| * Asserts that there the given string exactly matches the current query string |
| * of the url bar. For example, expectQueryStringToEqual('?foo=bar&foo=orange'); |
| */ |
| export function expectQueryStringToEqual(expected: string) { |
| expect(window.location.search).to.equal(expected); |
| } |
| |
| /** |
| * Sets the query string to be the provided value. Does *not* cause a page reload. |
| */ |
| export function setQueryString(q: string) { |
| history.pushState( |
| null, '', window.location.origin + window.location.pathname + q, |
| ); |
| } |