First Web Platform test using the WebXR Test API.

https://github.com/immersive-web/webxr-test-api

Change-Id: Ic741d23bf0607726d9a938f08f7964a5f9c957d9
Reviewed-on: https://chromium-review.googlesource.com/1070778
Reviewed-by: Robert Ma <robertma@chromium.org>
Reviewed-by: Daniel Cheng <dcheng@chromium.org>
Reviewed-by: David Dorwin <ddorwin@chromium.org>
Reviewed-by: Brandon Jones <bajones@chromium.org>
Reviewed-by: Reilly Grant <reillyg@chromium.org>
Commit-Queue: Anna Offenwanger <offenwanger@chromium.org>
Cr-Commit-Position: refs/heads/master@{#576869}
diff --git a/resources/chromium/webxr-test.js b/resources/chromium/webxr-test.js
new file mode 100644
index 0000000..74be1f0
--- /dev/null
+++ b/resources/chromium/webxr-test.js
@@ -0,0 +1,383 @@
+'use strict';
+
+// This polyfill library implements the WebXR Test API as specified here:
+// https://github.com/immersive-web/webxr-test-api
+
+class ChromeXRTest {
+  constructor() {
+    this.mockVRService_ = new MockVRService(mojo.frameInterfaces);
+  }
+
+  simulateDeviceConnection(init_params) {
+    return Promise.resolve(this.mockVRService_.addDevice(init_params));
+  }
+
+  simulateUserActivation(callback) {
+    return new Promise(resolve => {
+      let button = document.createElement('button');
+      button.textContent = 'click to continue test';
+      button.style.display = 'block';
+      button.style.fontSize = '20px';
+      button.style.padding = '10px';
+      button.onclick = () => {
+        resolve(callback());
+        document.body.removeChild(button);
+      };
+      document.body.appendChild(button);
+      test_driver.click(button);
+    });
+  }
+}
+
+// Mocking class definitions
+class MockVRService {
+  constructor() {
+    this.bindingSet_ = new mojo.BindingSet(device.mojom.VRService);
+    this.devices_ = [];
+
+    this.interceptor_ =
+        new MojoInterfaceInterceptor(device.mojom.VRService.name);
+    this.interceptor_.oninterfacerequest = e =>
+        this.bindingSet_.addBinding(this, e.handle);
+    this.interceptor_.start();
+  }
+
+  // Test methods
+  addDevice(fakeDeviceInit) {
+    let device = new MockDevice(fakeDeviceInit, this);
+    this.devices_.push(device);
+
+    return device;
+  }
+
+  // VRService implementation.
+  setClient(client) {
+    this.client_ = client;
+    for (let i = 0; i < this.devices_.length; i++) {
+      this.devices_[i].notifyClientOfDisplay();
+    }
+
+    return Promise.resolve();
+  }
+}
+
+// Implements both VRDisplayHost and VRMagicWindowProvider. Maintains a mock for
+// VRPresentationProvider.
+class MockDevice {
+  constructor(fakeDeviceInit, service) {
+    this.displayClient_ = new device.mojom.VRDisplayClientPtr();
+    this.presentation_provider_ = new MockVRPresentationProvider();
+
+    this.service_ = service;
+
+    this.framesOfReference = {};
+
+    if (fakeDeviceInit.supportsImmersive) {
+      this.displayInfo_ = this.getImmersiveDisplayInfo();
+    } else {
+      this.displayInfo_ = this.getNonImmersiveDisplayInfo();
+    }
+
+    if (service.client_) {
+      this.notifyClientOfDisplay();
+    }
+  }
+
+  // Functions for setup.
+  // This function calls to the backend to add this device to the list.
+  notifyClientOfDisplay() {
+    let displayPtr = new device.mojom.VRDisplayHostPtr();
+    let displayRequest = mojo.makeRequest(displayPtr);
+    let displayBinding =
+        new mojo.Binding(device.mojom.VRDisplayHost, this, displayRequest);
+
+    let clientRequest = mojo.makeRequest(this.displayClient_);
+    this.service_.client_.onDisplayConnected(
+        displayPtr, clientRequest, this.displayInfo_);
+  }
+
+  // Test methods.
+  setXRPresentationFrameData(poseMatrix, views) {
+    if (poseMatrix == null) {
+      this.presentation_provider_.pose_ = null;
+    } else {
+      this.presentation_provider_.setPoseFromMatrix(poseMatrix);
+    }
+
+    if (views) {
+      let changed = false;
+      for (let i = 0; i < views.length; i++) {
+        if (views[i].eye == 'left') {
+          this.displayInfo_.leftEye = this.getEye(views[i]);
+          changed = true;
+        } else if (views[i].eye == 'right') {
+          this.displayInfo_.rightEye = this.getEye(views[i]);
+          changed = true;
+        }
+      }
+
+      if (changed) {
+        this.displayClient_.onChanged(this.displayInfo_);
+      }
+    }
+  }
+
+  getNonImmersiveDisplayInfo() {
+    let displayInfo = this.getImmersiveDisplayInfo();
+
+    displayInfo.capabilities.canPresent = false;
+    displayInfo.leftEye = null;
+    displayInfo.rightEye = null;
+
+    return displayInfo;
+  }
+
+  // Function to generate some valid display information for the device.
+  getImmersiveDisplayInfo() {
+    return {
+      displayName: 'FakeDevice',
+      capabilities: {
+        hasPosition: false,
+        hasExternalDisplay: false,
+        canPresent: true,
+        maxLayers: 1
+      },
+      stageParameters: null,
+      leftEye: {
+        fieldOfView: {
+          upDegrees: 48.316,
+          downDegrees: 50.099,
+          leftDegrees: 50.899,
+          rightDegrees: 35.197
+        },
+        offset: [-0.032, 0, 0],
+        renderWidth: 20,
+        renderHeight: 20
+      },
+      rightEye: {
+        fieldOfView: {
+          upDegrees: 48.316,
+          downDegrees: 50.099,
+          leftDegrees: 50.899,
+          rightDegrees: 35.197
+        },
+        offset: [0.032, 0, 0],
+        renderWidth: 20,
+        renderHeight: 20
+      },
+      webxrDefaultFramebufferScale: 0.7,
+    };
+  }
+
+  // This function converts between the matrix provided by the WebXR test API
+  // and the internal data representation.
+  getEye(fakeXRViewInit) {
+    let m = fakeXRViewInit.projectionMatrix;
+
+    function toDegrees(tan) {
+      return Math.atan(tan) * 180 / Math.PI;
+    }
+
+    let xScale = m[0];
+    let yScale = m[5];
+    let near = m[14] / (m[10] - 1);
+    let far = m[14] / (m[10] - 1);
+    let leftTan = (1 - m[8]) / m[0];
+    let rightTan = (1 + m[8]) / m[0];
+    let upTan = (1 + m[9]) / m[5];
+    let downTan = (1 - m[9]) / m[5];
+
+    return {
+      fieldOfView: {
+        upDegrees: toDegrees(upTan),
+        downDegrees: toDegrees(downTan),
+        leftDegrees: toDegrees(leftTan),
+        rightDegrees: toDegrees(rightTan)
+      },
+      offset: [0, 0, 0],
+      renderWidth: 20,
+      renderHeight: 20
+    };
+  }
+
+  // Mojo function implementations.
+
+  // VRMagicWindowProvider implementation.
+
+  getFrameData() {
+    // Convert current document time to monotonic time.
+    let now = window.performance.now() / 1000.0;
+    let diff = now - internals.monotonicTimeToZeroBasedDocumentTime(now);
+    now += diff;
+    now *= 1000000;
+
+    return Promise.resolve({
+      frameData: {
+        pose: this.presentation_provider_.pose_,
+        bufferHolder: null,
+        bufferSize: {},
+        timeDelta: [],
+        projectionMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
+      }
+    });
+  }
+
+  updateSessionGeometry(frame_size, display_rotation) {
+    // This function must exist to ensure that calls to it do not crash, but we
+    // do not have any use for this data at present.
+  }
+
+  // VRDisplayHost implementation.
+
+  requestSession(sessionOptions, was_activation) {
+    return this.supportsSession(sessionOptions).then((result) => {
+      // The JavaScript bindings convert c_style_names to camelCase names.
+      let options = new device.mojom.VRDisplayFrameTransportOptions();
+      options.transportMethod =
+          device.mojom.VRDisplayFrameTransportMethod.SUBMIT_AS_MAILBOX_HOLDER;
+      options.waitForTransferNotification = true;
+      options.waitForRenderNotification = true;
+
+      let connection;
+      if (result.supportsSession) {
+        connection = {
+          clientRequest: this.presentation_provider_.getClientRequest(),
+          provider: this.presentation_provider_.bindProvider(sessionOptions),
+          transportOptions: options
+        };
+
+        let magicWindowPtr = new device.mojom.VRMagicWindowProviderPtr();
+        let magicWindowRequest = mojo.makeRequest(magicWindowPtr);
+        let magicWindowBinding = new mojo.Binding(
+            device.mojom.VRMagicWindowProvider, this, magicWindowRequest);
+
+        return Promise.resolve({
+          session:
+              {connection: connection, magicWindowProvider: magicWindowPtr}
+        });
+      } else {
+        return Promise.resolve({session: null});
+      }
+    });
+  }
+
+  supportsSession(options) {
+    return Promise.resolve({
+      supportsSession:
+          !options.exclusive || this.displayInfo_.capabilities.canPresent
+    });
+  };
+}
+
+class MockVRPresentationProvider {
+  constructor() {
+    this.binding_ = new mojo.Binding(device.mojom.VRPresentationProvider, this);
+    this.pose_ = null;
+    this.next_frame_id_ = 0;
+    this.submit_frame_count_ = 0;
+    this.missing_frame_count_ = 0;
+  }
+
+  bindProvider(request) {
+    let providerPtr = new device.mojom.VRPresentationProviderPtr();
+    let providerRequest = mojo.makeRequest(providerPtr);
+
+    this.binding_.close();
+
+    this.binding_ = new mojo.Binding(
+        device.mojom.VRPresentationProvider, this, providerRequest);
+
+    return providerPtr;
+  }
+
+  getClientRequest() {
+    this.submitFrameClient_ = new device.mojom.VRSubmitFrameClientPtr();
+    return mojo.makeRequest(this.submitFrameClient_);
+  }
+
+  setPoseFromMatrix(poseMatrix) {
+    this.pose_ = {
+      orientation: null,
+      position: null,
+      angularVelocity: null,
+      linearVelocity: null,
+      angularAcceleration: null,
+      linearAcceleration: null,
+      inputState: null,
+      poseIndex: 0
+    };
+
+    let pose = this.poseFromMatrix(poseMatrix);
+    for (let field in pose) {
+      if (this.pose_.hasOwnProperty(field)) {
+        this.pose_[field] = pose[field];
+      }
+    }
+  }
+
+  poseFromMatrix(m) {
+    let orientation = [];
+
+    let m00 = m[0];
+    let m11 = m[5];
+    let m22 = m[10];
+    // The max( 0, ... ) is just a safeguard against rounding error.
+    orientation[3] = Math.sqrt(Math.max(0, 1 + m00 + m11 + m22)) / 2;
+    orientation[0] = Math.sqrt(Math.max(0, 1 + m00 - m11 - m22)) / 2;
+    orientation[1] = Math.sqrt(Math.max(0, 1 - m00 + m11 - m22)) / 2;
+    orientation[2] = Math.sqrt(Math.max(0, 1 - m00 - m11 + m22)) / 2;
+
+    let position = [];
+    position[0] = m[12];
+    position[1] = m[13];
+    position[2] = m[14];
+
+    return {
+      orientation, position
+    }
+  }
+
+  // VRPresentationProvider mojo implementation
+  submitFrameMissing(frameId, mailboxHolder, timeWaited) {
+    this.missing_frame_count_++;
+  }
+
+  submitFrame(frameId, mailboxHolder, timeWaited) {
+    this.submit_frame_count_++;
+
+    // Trigger the submit completion callbacks here. WARNING: The
+    // Javascript-based mojo mocks are *not* re-entrant. It's OK to
+    // wait for these notifications on the next frame, but waiting
+    // within the current frame would never finish since the incoming
+    // calls would be queued until the current execution context finishes.
+    this.submitFrameClient_.onSubmitFrameTransferred(true);
+    this.submitFrameClient_.onSubmitFrameRendered();
+  }
+
+  getFrameData() {
+    if (this.pose_) {
+      this.pose_.poseIndex++;
+    }
+
+    // Convert current document time to monotonic time.
+    let now = window.performance.now() / 1000.0;
+    let diff = now - internals.monotonicTimeToZeroBasedDocumentTime(now);
+    now += diff;
+    now *= 1000000;
+
+    return Promise.resolve({
+      frameData: {
+        pose: this.pose_,
+        timeDelta: {
+          microseconds: now,
+        },
+        frameId: this.next_frame_id_++,
+        projectionMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
+        bufferHolder: null,
+        bufferSize: {}
+      }
+    });
+  }
+}
+
+let XRTest = new ChromeXRTest();
\ No newline at end of file
diff --git a/resources/chromium/webxr-test.js.headers b/resources/chromium/webxr-test.js.headers
new file mode 100644
index 0000000..6805c32
--- /dev/null
+++ b/resources/chromium/webxr-test.js.headers
@@ -0,0 +1 @@
+Content-Type: text/javascript; charset=utf-8
diff --git a/webxr/resources/webxr_util.js b/webxr/resources/webxr_util.js
index 5adf0a2..eaa7887 100644
--- a/webxr/resources/webxr_util.js
+++ b/webxr/resources/webxr_util.js
@@ -1,3 +1,25 @@
+// These tests rely on the User Agent providing an implementation of the
+// WebXR Testing API (https://github.com/immersive-web/webxr-test-api).
+//
+// In Chromium-based browsers, this implementation is provided by a JavaScript
+// shim in order to reduce the amount of test-only code shipped to users. To
+// enable these tests the browser must be run with these options:
+//
+//   --enable-blink-features=MojoJS,MojoJSTest
+
+function xr_promise_test(func, name, properties) {
+  promise_test(async (t) => {
+    // Perform any required test setup:
+
+    if (window.XRTest === undefined) {
+      // Chrome setup
+      await loadChromiumResources;
+    }
+
+    return func(t);
+  }, name, properties);
+}
+
 // This functions calls a callback with each API object as specified
 // by https://immersive-web.github.io/webxr/spec/latest/, allowing
 // checks to be made on all ojects.
@@ -25,4 +47,32 @@
   callback(window.XRStageBoundsPoint, 'XRStageBoundsPoint');
   callback(window.XRSessionEvent, 'XRSessionEvent');
   callback(window.XRCoordinateSystemEvent, 'XRCoordinateSystemEvent');
-}
\ No newline at end of file
+}
+
+// Code for loading test api in chromium.
+let loadChromiumResources = Promise.resolve().then(() => {
+  if (!MojoInterfaceInterceptor) {
+    // Do nothing on non-Chromium-based browsers or when the Mojo bindings are
+    // not present in the global namespace.
+    return;
+  }
+
+  let chain = Promise.resolve();
+  ['/gen/layout_test_data/mojo/public/js/mojo_bindings.js',
+   '/gen/ui/gfx/geometry/mojo/geometry.mojom.js',
+   '/gen/mojo/public/mojom/base/time.mojom.js',
+   '/gen/device/vr/public/mojom/vr_service.mojom.js',
+   '/resources/chromium/webxr-test.js', '/resources/testdriver.js',
+   '/resources/testdriver-vendor.js',
+  ].forEach(path => {
+    let script = document.createElement('script');
+    script.src = path;
+    script.async = false;
+    chain = chain.then(() => new Promise(resolve => {
+                         script.onload = () => resolve();
+                       }));
+    document.head.appendChild(script);
+  });
+
+  return chain;
+});
\ No newline at end of file
diff --git a/webxr/xrSession_exclusive_requestAnimationFrame.https.html b/webxr/xrSession_exclusive_requestAnimationFrame.https.html
new file mode 100644
index 0000000..010ab0b
--- /dev/null
+++ b/webxr/xrSession_exclusive_requestAnimationFrame.https.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<body>
+  <script src=/resources/testharness.js></script>
+  <script src=/resources/testharnessreport.js></script>
+  <script src="resources/webxr_util.js"></script>
+  <canvas id="webgl-canvas"></canvas>
+
+  <script>
+
+  const identityMatrix = new Float32Array([
+    1, 0, 0, 0,
+    0, 1, 0, 0,
+    0, 0, 1, 0,
+    0, 0, 0, 1
+  ]);
+
+  const rightFakeXRViewInit =
+    {eye:"right", projectionMatrix: identityMatrix, viewMatrix: identityMatrix};
+
+  const leftFakeXRViewInit =
+    {eye:"left", projectionMatrix: identityMatrix, viewMatrix: identityMatrix};
+
+  const immersiveFakeXRDeviceInit = { supportsImmersive:true };
+
+  const webglCanvas = document.getElementById('webgl-canvas');
+  let gl = webglCanvas.getContext('webgl', { alpha: false, antialias: false });
+
+  let testDevice;
+  let testDeviceController;
+  let testSession;
+
+  xr_promise_test(
+    (t) => XRTest.simulateDeviceConnection(immersiveFakeXRDeviceInit)
+      .then((controller) => {
+        testDeviceController = controller;
+        return navigator.xr.requestDevice();
+      })
+      .then((device) => {
+        testDevice = device;
+        return gl.setCompatibleXRDevice(device);
+      })
+      .then(() => new Promise((resolve, reject) => {
+          // Perform the session request in a user gesture.
+          XRTest.simulateUserActivation(() => {
+            testDevice.requestSession({ immersive: true })
+              .then((session) => {
+                testSession = session;
+                return session.requestFrameOfReference('eye-level');
+              })
+              .then((frameOfRef) => {
+                // Session must have a baseLayer or frame requests will be ignored.
+                testSession.baseLayer = new XRWebGLLayer(testSession, gl);
+
+                function onFrame(time, xrFrame) {
+                  assert_true(xrFrame instanceof XRFrame);
+
+                  assert_not_equals(xrFrame.views, null);
+                  assert_equals(xrFrame.views.length, 2);
+
+                  let devicePose = xrFrame.getDevicePose(frameOfRef);
+
+                  assert_not_equals(devicePose, null);
+                  for(let i = 0; i < identityMatrix.length; i++) {
+                    assert_equals(devicePose.poseModelMatrix[i], identityMatrix[i]);
+                  }
+
+                  assert_not_equals(devicePose.getViewMatrix(xrFrame.views[0]), null);
+                  assert_equals(devicePose.getViewMatrix(xrFrame.views[0]).length, 16);
+                  assert_not_equals(devicePose.getViewMatrix(xrFrame.views[1]), null);
+                  assert_equals(devicePose.getViewMatrix(xrFrame.views[1]).length, 16);
+
+                  // Test does not complete until the returned promise resolves.
+                  resolve();
+                }
+
+                testDeviceController.setXRPresentationFrameData(
+                  identityMatrix,
+                  [rightFakeXRViewInit, leftFakeXRViewInit]
+                );
+
+                testSession.requestAnimationFrame(onFrame);
+              }).catch((err) => {
+                reject("Session was rejected with error: "+err);
+              });
+          });
+        })
+      ),
+    "RequestAnimationFrame resolves with good data"
+  );
+  </script>
+</body>