blob: d74fa1bcd568ef89a9ac4ae96f3e3f4856dc0597 [file] [log] [blame]
/** @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>)=> void,
reject: (reason?: any)=> void, event: T)=> void,
timeoutCallback: (resolve: (value: T | PromiseLike<T>)=> 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,
);
}