pw_web_ui: Create mocks for testing WebSerial

Node and the browser have different stream APIs. This CL creates mocks for testing web_serial_transport, which uses the browser API.

Change-Id: Ice382bb89b10a2fa23fd4a39f9217e1d0c32a9cd
diff --git a/package.json b/package.json
index e5a1881..cdda168 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
     "@bazel/typescript": "^1.7.0",
     "@types/jasmine": "^3.5.10",
     "jasmine": "^3.5.0",
+    "rxjs": "^6.5.5",
     "typescript": "^3.9.3"
   }
 }
diff --git a/pw_web_ui/src/transport/BUILD b/pw_web_ui/src/transport/BUILD
index a0387f3..8af9cb8 100644
--- a/pw_web_ui/src/transport/BUILD
+++ b/pw_web_ui/src/transport/BUILD
@@ -19,32 +19,61 @@
 
 
 ts_library(
-    name = "transport_lib",
+    name = "device_transport_lib",
     srcs = [
-        "web_serial_transport.ts",
+        "device_transport.ts",
     ],
-    deps = [],
+    deps = [
+        "@npm//rxjs",
+    ],
 )
 
 ts_library(
-    name = "transport_lib_test",
+    name = "serial_mock_lib",
+    srcs = [
+        "serial_mock.ts",
+    ],
+    deps = [
+        "//pw_web_ui/types:serial_lib",
+        "@npm//jasmine",
+        "@npm//@types/jasmine",
+        "@npm//rxjs",
+    ],
+)
+
+ts_library(
+    name = "web_serial_transport_lib",
+    srcs = [
+        "web_serial_transport.ts",
+    ],
+    deps = [
+        ":device_transport_lib",
+        "//pw_web_ui/types:serial_lib",
+        "@npm//rxjs",
+    ],
+)
+
+ts_library(
+    name = "web_serial_transport_lib_test",
     testonly = True,
     srcs = [
         "web_serial_transport_test.ts",
     ],
     deps = [
-        ":transport_lib",
+        ":serial_mock_lib",
+        ":web_serial_transport_lib",
         "@npm//jasmine",
         "@npm//@types/jasmine",
+        "@npm//rxjs",
     ],
 )
 
 jasmine_node_test(
     name = "test",
     srcs = [
-        ":transport_lib_test",
+        ":web_serial_transport_lib_test",
     ],
     deps = [
-        ":transport_lib",
+        ":web_serial_transport_lib",
     ],
 )
diff --git a/pw_web_ui/src/transport/serial_mock.ts b/pw_web_ui/src/transport/serial_mock.ts
new file mode 100644
index 0000000..38557c5
--- /dev/null
+++ b/pw_web_ui/src/transport/serial_mock.ts
@@ -0,0 +1,193 @@
+// Copyright 2020 The Pigweed Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+import { Subject } from 'rxjs';
+
+
+/**
+ * AsyncQueue is a queue that allows values to be dequeued
+ * before they are enqueued, returning a promise that resolves
+ * once the value is available.
+ */
+class AsyncQueue<T> {
+  private queue: T[] = [];
+  private requestQueue: Array<(val: T) => unknown> = [];
+
+  /**
+   * Enqueue val into the queue.
+   * @param {T} val
+   */
+  enqueue(val: T) {
+    const callback = this.requestQueue.shift();
+    if (callback) {
+      callback(val);
+    } else {
+      this.queue.push(val);
+    }
+  }
+
+  /**
+   * Dequeue a value from the queue, returning a promise
+   * if the queue is empty.
+   */
+  async dequeue(): Promise<T> {
+    const val = this.queue.shift();
+    if (val !== undefined) {
+      return val;
+    } else {
+      const queuePromise = new Promise<T>((resolve) => {
+        this.requestQueue.push(resolve);
+      });
+      return queuePromise;
+    }
+  }
+}
+
+/**
+ * SerialPortMock is a mock for Chrome's upcoming SerialPort interface.
+ * Since pw_web_ui only depends on a subset of the interface, this mock
+ * only implements that subset.
+ */
+class SerialPortMock implements SerialPort {
+  private deviceData = new AsyncQueue<{ data?: Uint8Array, done?: boolean, error?: Error }>();
+
+  /**
+   * Simulate the device sending data to the browser.
+   * @param {Uint8Array} data
+   */
+  dataFromDevice(data: Uint8Array) {
+    this.deviceData.enqueue({ data });
+  }
+
+  /**
+   * Simulate the device closing the connection with the browser.
+   */
+  closeFromDevice() {
+    this.deviceData.enqueue({
+      done: true
+    });
+  }
+
+  /**
+   * Simulate an error in the device's read stream.
+   * @param {Error} error
+   */
+  errorFromDevice(error: Error) {
+    this.deviceData.enqueue({
+      error
+    });
+  }
+
+  /**
+   * An rxjs subject tracking data sent to the (fake) device.
+   */
+  dataToDevice = new Subject<Uint8Array>();
+
+  /**
+   * The ReadableStream of bytes from the device.
+   */
+  readable = new ReadableStream<Uint8Array>({
+    pull: async (controller) => {
+      const { data, done, error } = await this.deviceData.dequeue();
+      if (done) {
+        controller.close();
+        return;
+      }
+      if (error) {
+        throw error;
+      }
+      if (data) {
+        controller.enqueue(data);
+      }
+    },
+  });
+
+  /**
+   * The WritableStream of bytes to the device.
+   */
+  writable = new WritableStream<Uint8Array>({
+    write: (chunk) => {
+      this.dataToDevice.next(chunk);
+    }
+  });
+
+  /**
+   * A spy for opening the serial port.
+   */
+  open = jasmine.createSpy('openSpy', async (options?: SerialOptions) => { });
+
+  /**
+   * A spy for closing the serial port.
+   */
+  close = jasmine.createSpy('closeSpy', () => { });
+}
+
+
+export class SerialMock implements Serial {
+  serialPort = new SerialPortMock();
+  dataToDevice = this.serialPort.dataToDevice;
+  dataFromDevice = (data: Uint8Array) => { this.serialPort.dataFromDevice(data); };
+  closeFromDevice = () => { this.serialPort.closeFromDevice(); };
+  errorFromDevice = (error: Error) => { this.serialPort.errorFromDevice(error); }
+
+  /**
+   * Request the port from the browser.
+   */
+  async requestPort(options?: SerialPortRequestOptions) {
+    return this.serialPort;
+  };
+
+  // The rest of the methods are unimplemented
+  // and only exist to ensure SerialMock implements Serial
+
+  onconnect(): ((this: this, ev: SerialConnectionEvent) => any) | null {
+    throw new Error('Method not implemented.');
+  }
+
+  ondisconnect(): ((this: this, ev: SerialConnectionEvent) => any) | null {
+    throw new Error('Method not implemented.');
+  }
+
+  getPorts(): Promise<SerialPort[]> {
+    throw new Error('Method not implemented.');
+  }
+
+  addEventListener(type: 'connect' | 'disconnect',
+    listener: (this: this, ev: SerialConnectionEvent) => any,
+    useCapture?: boolean): void;
+
+  addEventListener(type: string,
+    listener: EventListener | EventListenerObject | null,
+    options?: boolean | AddEventListenerOptions): void;
+
+  addEventListener(type: any, listener: any, options?: any) {
+    throw new Error('Method not implemented.');
+  }
+
+  removeEventListener(type: 'connect' | 'disconnect',
+    callback: (this: this, ev: SerialConnectionEvent) => any,
+    useCapture?: boolean): void;
+
+  removeEventListener(type: string,
+    callback: EventListener | EventListenerObject | null,
+    options?: boolean | EventListenerOptions): void;
+
+  removeEventListener(type: any, callback: any, options?: any) {
+    throw new Error('Method not implemented.');
+  }
+
+  dispatchEvent(event: Event): boolean {
+    throw new Error('Method not implemented.');
+  }
+}
diff --git a/yarn.lock b/yarn.lock
index 50bb783..10152b9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -691,6 +691,13 @@
   dependencies:
     glob "^7.1.3"
 
+rxjs@^6.5.5:
+  version "6.5.5"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.5.tgz#c5c884e3094c8cfee31bf27eb87e54ccfc87f9ec"
+  integrity sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ==
+  dependencies:
+    tslib "^1.9.0"
+
 "semver@2 || 3 || 4 || 5", semver@^5.5.0:
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
@@ -832,7 +839,7 @@
     read-pkg-up "^4.0.0"
     require-main-filename "^2.0.0"
 
-tslib@^1.8.1:
+tslib@^1.8.1, tslib@^1.9.0:
   version "1.13.0"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
   integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==