[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', () => {