[infra-sk] test_util.ts: Add function eventSequencePromise().
Change-Id: Id71ae3edc912b1232069348069ecc3b303d2c1d7
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/306180
Commit-Queue: Leandro Lovisolo <lovisolo@google.com>
Reviewed-by: Kevin Lubick <kjlubick@google.com>
diff --git a/infra-sk/modules/test_util.ts b/infra-sk/modules/test_util.ts
index a7d1e8d..2b5c6e4 100644
--- a/infra-sk/modules/test_util.ts
+++ b/infra-sk/modules/test_util.ts
@@ -7,6 +7,7 @@
* </p>
*/
+import { deepCopy } from 'common-sk/modules/object';
import { expect } from 'chai';
/**
@@ -196,6 +197,106 @@
}
/**
+ * 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');
*/
diff --git a/infra-sk/modules/test_util_test.ts b/infra-sk/modules/test_util_test.ts
index a72416f..1660960 100644
--- a/infra-sk/modules/test_util_test.ts
+++ b/infra-sk/modules/test_util_test.ts
@@ -5,6 +5,7 @@
setUpElementUnderTest,
eventPromise,
noEventPromise,
+ eventSequencePromise,
expectQueryStringToEqual,
setQueryString,
} from './test_util';
@@ -196,6 +197,139 @@
}
});
});
+
+ describe('eventSequencePromise', () => {
+ it('resolves immediately if the event sequence is empty', async () => {
+ expect(await eventSequencePromise([])).to.have.length(0);
+ });
+
+ it('catches one event', async () => {
+ const sequence = eventSequencePromise<CustomEvent<string>>(['hello']);
+ el.dispatchEvent(new CustomEvent('hello', { bubbles: true, detail: 'hi' }));
+
+ const events = await sequence;
+
+ expect(events).to.have.length(1);
+ expect(events[0].detail).to.equal('hi');
+ });
+
+ it('catches a sequence of events', async () => {
+ const sequence = eventSequencePromise<CustomEvent<string>>(['hello', 'world', 'goodbye']);
+ el.dispatchEvent(new CustomEvent('hello', { bubbles: true, detail: 'first' }));
+ el.dispatchEvent(new CustomEvent('world', { bubbles: true, detail: 'second' }));
+ el.dispatchEvent(new CustomEvent('goodbye', { bubbles: true, detail: 'third' }));
+
+ const events = await sequence;
+
+ expect(events).to.have.length(3);
+ expect(events[0].detail).to.equal('first');
+ expect(events[1].detail).to.equal('second');
+ expect(events[2].detail).to.equal('third');
+ });
+
+ it('catches a sequence with repeated events', async () => {
+ const sequence = eventSequencePromise<CustomEvent<string>>([
+ 'hey', 'hey', 'hello', 'world', 'hey', 'world', 'goodbye'
+ ]);
+ el.dispatchEvent(new CustomEvent('hey', { bubbles: true, detail: 'first' }));
+ el.dispatchEvent(new CustomEvent('hey', { bubbles: true, detail: 'second' }));
+ el.dispatchEvent(new CustomEvent('hello', { bubbles: true, detail: 'third' }));
+ el.dispatchEvent(new CustomEvent('world', { bubbles: true, detail: 'fourth' }));
+ el.dispatchEvent(new CustomEvent('hey', { bubbles: true, detail: 'fifth' }));
+ el.dispatchEvent(new CustomEvent('world', { bubbles: true, detail: 'sixth' }));
+ el.dispatchEvent(new CustomEvent('goodbye', { bubbles: true, detail: 'seventh' }));
+
+ const events = await sequence;
+
+ expect(events).to.have.length(7);
+ expect(events[0].detail).to.equal('first');
+ expect(events[1].detail).to.equal('second');
+ expect(events[2].detail).to.equal('third');
+ expect(events[3].detail).to.equal('fourth');
+ expect(events[4].detail).to.equal('fifth');
+ expect(events[5].detail).to.equal('sixth');
+ expect(events[6].detail).to.equal('seventh');
+ });
+
+ it('ignores out-of-sequence events', async () => {
+ const sequence = eventSequencePromise<CustomEvent<string>>(['hello', 'world', 'goodbye']);
+
+ el.dispatchEvent(new CustomEvent('goodbye', { bubbles: true, detail: 'first' }));
+ el.dispatchEvent(new CustomEvent('world', { bubbles: true, detail: 'second' }));
+ el.dispatchEvent(new CustomEvent('hello', { bubbles: true, detail: 'third' }));
+ el.dispatchEvent(new CustomEvent('hello', { bubbles: true, detail: 'fourth' }));
+ el.dispatchEvent(new CustomEvent('goodbye', { bubbles: true, detail: 'fifth' }));
+ el.dispatchEvent(new CustomEvent('world', { bubbles: true, detail: 'sixth' }));
+ el.dispatchEvent(new CustomEvent('hello', { bubbles: true, detail: 'seventh' }));
+ el.dispatchEvent(new CustomEvent('goodbye', { bubbles: true, detail: 'eigth' }));
+
+ const events = await sequence;
+
+ expect(events).to.have.length(3);
+ expect(events[0].detail).to.equal('third');
+ expect(events[1].detail).to.equal('sixth');
+ expect(events[2].detail).to.equal('eigth');
+ });
+
+ it('does not catch incomplete sequences', async () => {
+ const sequence = eventSequencePromise<CustomEvent<string>>([
+ 'i', 'am', 'a', 'very', 'long', 'event', 'sequence']);
+ el.dispatchEvent(new CustomEvent('i', { bubbles: true, detail: 'first' }));
+ el.dispatchEvent(new CustomEvent('am', { bubbles: true, detail: 'second' }));
+
+ clock.tick(10000);
+
+ try {
+ await sequence;
+ expect.fail('promise should not have resolved');
+ } catch (error) {
+ expect(error).to.equal(
+ 'timed out after 200 ms while waiting to catch events ' +
+ '"a", "very", "long", "event", "sequence"');
+ }
+ });
+
+ it('does not catch permutations', async () => {
+ const sequence = eventSequencePromise<CustomEvent<string>>(['hello', 'world', 'goodbye']);
+ el.dispatchEvent(new CustomEvent('goodbye', { bubbles: true, detail: 'first' }));
+ el.dispatchEvent(new CustomEvent('world', { bubbles: true, detail: 'second' }));
+ el.dispatchEvent(new CustomEvent('hello', { bubbles: true, detail: 'third' }));
+
+ clock.tick(10000);
+
+ try {
+ await sequence;
+ expect.fail('promise should not have resolved');
+ } catch (error) {
+ expect(error).to.equal(
+ 'timed out after 200 ms while waiting to catch events "world", "goodbye"');
+ }
+ });
+
+ it('times out if nothing is caught', async () => {
+ const sequence = eventSequencePromise<CustomEvent<string>>(['hello']);
+ clock.tick(10000);
+
+ try {
+ await sequence;
+ expect.fail('promise should not have resolved');
+ } catch (error) {
+ expect(error).to.equal(
+ 'timed out after 200 ms while waiting to catch events "hello"');
+ }
+ });
+
+ it('never times out if timeoutMillis=0', async () => {
+ const sequence = eventSequencePromise<CustomEvent<string>>(['hello'], 0);
+ clock.tick(Number.MAX_SAFE_INTEGER);
+ el.dispatchEvent(new CustomEvent('hello', { bubbles: true, detail: 'hi' }));
+
+ const events = await sequence;
+
+ expect(events).to.have.length(1);
+ expect(events[0].detail).to.equal('hi');
+ });
+ });
});
describe('expectQueryStringToEqual', () => {