Hit test API WPTs along with required mock implementation in JS

Change-Id: I89e6820f1920698f7fc44b625f7f3428c27d20fc
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2003292
Commit-Queue: Piotr Bialecki <bialpio@chromium.org>
Reviewed-by: Klaus Weidner <klausw@chromium.org>
Cr-Commit-Position: refs/heads/master@{#732565}
diff --git a/resources/chromium/webxr-test-math-helper.js b/resources/chromium/webxr-test-math-helper.js
new file mode 100644
index 0000000..0147f50
--- /dev/null
+++ b/resources/chromium/webxr-test-math-helper.js
@@ -0,0 +1,222 @@
+'use strict';
+
+// Math helper - used mainly in hit test implementation done by webxr-test.js
+class XRMathHelper {
+  static toString(p) {
+    return "[" + p.x + "," + p.y + "," + p.z + "," + p.w + "]";
+  }
+
+  static transform_by_matrix(matrix, point) {
+    return {
+      x : matrix[0] * point.x + matrix[4] * point.y + matrix[8] * point.z + matrix[12] * point.w,
+      y : matrix[1] * point.x + matrix[5] * point.y + matrix[9] * point.z + matrix[13] * point.w,
+      z : matrix[2] * point.x + matrix[6] * point.y + matrix[10] * point.z + matrix[14] * point.w,
+      w : matrix[3] * point.x + matrix[7] * point.y + matrix[11] * point.z + matrix[15] * point.w,
+    };
+  }
+
+  static neg(p) {
+    return {x : -p.x, y : -p.y, z : -p.z, w : p.w};
+  }
+
+  static sub(lhs, rhs) {
+    // .w is treated here like an entity type, 1 signifies points, 0 signifies vectors.
+    // point - point, point - vector, vector - vector are ok, vector - point is not.
+    if (lhs.w != rhs.w && lhs.w == 0.0) {
+      console.warn("vector - point not allowed: " + toString(lhs) + "-" + toString(rhs));
+    }
+
+    return {x : lhs.x - rhs.x, y : lhs.y - rhs.y, z : lhs.z - rhs.z, w : lhs.w - rhs.w};
+  }
+
+  static add(lhs, rhs) {
+    if (lhs.w == rhs.w && lhs.w == 1.0) {
+      console.warn("point + point not allowed", p1, p2);
+    }
+
+    return {x : lhs.x + rhs.x, y : lhs.y + rhs.y, z : lhs.z + rhs.z, w : lhs.w + rhs.w};
+  }
+
+  static cross(lhs, rhs) {
+    if (lhs.w != 0.0 || rhs.w != 0.0) {
+      console.warn("cross product not allowed: " + toString(lhs) + "x" + toString(rhs));
+    }
+
+    return {
+      x : lhs.y * rhs.z - lhs.z * rhs.y,
+      y : lhs.z * rhs.x - lhs.x * rhs.z,
+      z : lhs.x * rhs.y - lhs.y * rhs.x,
+      w : 0
+    };
+  }
+
+  static dot(lhs, rhs) {
+    if (lhs.w != 0 || rhs.w != 0) {
+      console.warn("dot product not allowed: " + toString(lhs) + "x" + toString(rhs));
+    }
+
+    return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z;
+  }
+
+  static mul(scalar, vector) {
+    if (vector.w != 0) {
+      console.warn("scalar * vector not allowed", scalar, vector);
+    }
+
+    return {x : vector.x * scalar, y : vector.y * scalar, z : vector.z * scalar, w : vector.w};
+  }
+
+  static length(vector) {
+    return Math.sqrt(XRMathHelper.dot(vector, vector));
+  }
+
+  static normalize(vector) {
+    const l = XRMathHelper.length(vector);
+    return XRMathHelper.mul(1.0/l, vector);
+  }
+
+  // All |face|'s points and |point| must be co-planar.
+  static pointInFace(point, face) {
+    const normalize = XRMathHelper.normalize;
+    const sub = XRMathHelper.sub;
+    const length = XRMathHelper.length;
+    const cross = XRMathHelper.cross;
+
+    let onTheRight = null;
+    let previous_point = face[face.length - 1];
+
+    // |point| is in |face| if it's on the same side of all the edges.
+    for (let i = 0; i < face.length; ++i) {
+      const current_point = face[i];
+
+      const edge_direction = normalize(sub(current_point, previous_point));
+      const turn_direction = normalize(sub(point, current_point));
+
+      const sin_turn_angle = length(cross(edge_direction, turn_direction));
+
+      if (onTheRight == null) {
+        onTheRight = sin_turn_angle >= 0;
+      } else {
+        if (onTheRight && sin_turn_angle < 0) return false;
+        if (!onTheRight && sin_turn_angle > 0) return false;
+      }
+
+      previous_point = current_point;
+    }
+
+    return true;
+  }
+
+  static det2x2(m00, m01, m10, m11) {
+    return m00 * m11 - m01 * m10;
+  }
+
+  static det3x3(
+    m00, m01, m02,
+    m10, m11, m12,
+    m20, m21, m22
+  ){
+    const det2x2 = XRMathHelper.det2x2;
+
+    return    m00 * det2x2(m11, m12, m21, m22)
+            - m01 * det2x2(m10, m12, m20, m22)
+            + m02 * det2x2(m10, m11, m20, m21);
+  }
+
+  static det4x4(
+    m00, m01, m02, m03,
+    m10, m11, m12, m13,
+    m20, m21, m22, m23,
+    m30, m31, m32, m33
+  ) {
+    const det3x3 = XRMathHelper.det3x3;
+
+    return  m00 * det3x3(m11, m12, m13,
+                         m21, m22, m23,
+                         m31, m32, m33)
+          - m01 * det3x3(m10, m12, m13,
+                         m20, m22, m23,
+                         m30, m32, m33)
+          + m02 * det3x3(m10, m11, m13,
+                         m20, m21, m23,
+                         m30, m31, m33)
+          - m03 * det3x3(m10, m11, m12,
+                         m20, m21, m22,
+                         m30, m31, m32);
+  }
+
+  static inv2(m) {
+    // mij - i-th column, j-th row
+    const m00 = m[0],  m01 = m[1],  m02 = m[2],  m03 = m[3];
+    const m10 = m[4],  m11 = m[5],  m12 = m[6],  m13 = m[7];
+    const m20 = m[8],  m21 = m[9],  m22 = m[10], m23 = m[11];
+    const m30 = m[12], m31 = m[13], m32 = m[14], m33 = m[15];
+
+    const det = det4x4(
+      m00, m01, m02, m03,
+      m10, m11, m12, m13,
+      m20, m21, m22, m23,
+      m30, m31, m32, m33
+    );
+  }
+
+  static transpose(m) {
+    const result = Array(16);
+    for (let i = 0; i < 4; i++) {
+      for (let j = 0; j < 4; j++) {
+        result[i * 4 + j] = m[j * 4 + i];
+      }
+    }
+    return result;
+  }
+
+  // Inverts the matrix, ported from transformation_matrix.cc.
+  static inverse(m) {
+    const det3x3 = XRMathHelper.det3x3;
+
+    // mij - i-th column, j-th row
+    const m00 = m[0],  m01 = m[1],  m02 = m[2],  m03 = m[3];
+    const m10 = m[4],  m11 = m[5],  m12 = m[6],  m13 = m[7];
+    const m20 = m[8],  m21 = m[9],  m22 = m[10], m23 = m[11];
+    const m30 = m[12], m31 = m[13], m32 = m[14], m33 = m[15];
+
+    const det = XRMathHelper.det4x4(
+      m00, m01, m02, m03,
+      m10, m11, m12, m13,
+      m20, m21, m22, m23,
+      m30, m31, m32, m33
+    );
+
+    if (Math.abs(det) < 0.0001) {
+      return null;
+    }
+
+    const invDet = 1.0 / det;
+    // Calculate `comatrix * 1/det`:
+    const result2 = [
+      // First column (m0r):
+      invDet * det3x3(m11, m12, m13, m21, m22, m23, m32, m32, m33),
+      invDet * det3x3(m10, m12, m13, m20, m22, m23, m30, m32, m33),
+      invDet * det3x3(m10, m11, m13, m20, m21, m23, m30, m31, m33),
+      invDet * det3x3(m10, m11, m12, m20, m21, m22, m30, m31, m32),
+      // Second column (m1r):
+      invDet * det3x3(m01, m02, m03, m21, m22, m23, m32, m32, m33),
+      invDet * det3x3(m00, m02, m03, m20, m22, m23, m30, m32, m33),
+      invDet * det3x3(m00, m01, m03, m20, m21, m23, m30, m31, m33),
+      invDet * det3x3(m00, m01, m02, m20, m21, m22, m30, m31, m32),
+      // Third column (m2r):
+      invDet * det3x3(m01, m02, m03, m11, m12, m13, m31, m32, m33),
+      invDet * det3x3(m00, m02, m03, m10, m12, m13, m30, m32, m33),
+      invDet * det3x3(m00, m01, m03, m10, m11, m13, m30, m31, m33),
+      invDet * det3x3(m00, m01, m02, m10, m11, m12, m30, m31, m32),
+      // Fourth column (m3r):
+      invDet * det3x3(m01, m02, m03, m11, m12, m13, m21, m22, m23),
+      invDet * det3x3(m00, m02, m03, m10, m12, m13, m20, m22, m23),
+      invDet * det3x3(m00, m01, m03, m10, m11, m13, m20, m21, m23),
+      invDet * det3x3(m00, m01, m02, m10, m11, m12, m20, m21, m22),
+    ];
+
+    // Actual inverse is `1/det * transposed(comatrix)`:
+    return XRMathHelper.transpose(result2);
+  }
+}
diff --git a/resources/chromium/webxr-test-math-helper.js.headers b/resources/chromium/webxr-test-math-helper.js.headers
new file mode 100644
index 0000000..6805c32
--- /dev/null
+++ b/resources/chromium/webxr-test-math-helper.js.headers
@@ -0,0 +1 @@
+Content-Type: text/javascript; charset=utf-8
diff --git a/resources/chromium/webxr-test.js b/resources/chromium/webxr-test.js
index ca62754..1619169 100644
--- a/resources/chromium/webxr-test.js
+++ b/resources/chromium/webxr-test.js
@@ -231,6 +231,11 @@
     this.input_sources_ = new Map();
     this.next_input_source_index_ = 1;
 
+    // Currently active hit test subscriptons.
+    this.hitTestSubscriptions_ = new Map();
+    // ID of the next subscription to be assigned.
+    this.next_hit_test_id_ = 1;
+
     let supportedModes = [];
     if (fakeDeviceInit.supportedModes) {
       supportedModes = fakeDeviceInit.supportedModes.slice();
@@ -591,8 +596,11 @@
       bufferHolder: null,
       bufferSize: {},
     };
+
     this._injectAdditionalFrameData(options, frameData);
 
+    this._calculateHitTestResults(frameData);
+
     return Promise.resolve({
       frameData: frameData,
     });
@@ -620,6 +628,51 @@
     // do not have any use for this data at present.
   }
 
+  // XREnvironmentIntegrationProvider implementation:
+  subscribeToHitTest(nativeOriginInformation, entityTypes, ray) {
+    if(!this.supportedModes_.includes(device.mojom.XRSessionMode.kImmersiveAr)) {
+      // Reject outside of AR.
+      return Promise.resolve({
+        result : device.mojom.SubscribeToHitTestResult.FAILED,
+        subscriptionId : 0
+      });
+    }
+
+    if(nativeOriginInformation.$tag == device.mojom.XRNativeOriginInformation.Tags.inputSourceId) {
+      if(!this.input_sources_.has(nativeOriginInformation.inputSourceId)) {
+        // Reject - unknown input source ID.
+        return Promise.resolve({
+          result : device.mojom.SubscribeToHitTestResult.FAILED,
+          subscriptionId : 0
+        });
+      }
+    } else if(nativeOriginInformation.$tag == device.mojom.XRNativeOriginInformation.Tags.referenceSpaceCategory) {
+      // Bounded_floor & unbounded ref spaces are not yet supported for AR:
+      if(nativeOriginInformation.referenceSpaceCategory == device.mojom.XRReferenceSpaceCategory.UNBOUNDED
+      || nativeOriginInformation.referenceSpaceCategory == device.mojom.XRReferenceSpaceCategory.BOUNDED_FLOOR) {
+        return Promise.resolve({
+          result : device.mojom.SubscribeToHitTestResult.FAILED,
+          subscriptionId : 0
+        });
+      }
+    } else {
+      // Planes and anchors are not yet supported by the mock interface.
+      return Promise.resolve({
+        result : device.mojom.SubscribeToHitTestResult.FAILED,
+        subscriptionId : 0
+      });
+    }
+
+    // Store the subscription information as-is:
+    const id = this.next_hit_test_id_++;
+    this.hitTestSubscriptions_.set(id, { nativeOriginInformation, entityTypes, ray });
+
+    return Promise.resolve({
+      result : device.mojom.SubscribeToHitTestResult.SUCCESS,
+      subscriptionId : id
+    });
+  }
+
   // Utility function
   requestRuntimeSession(sessionOptions) {
     return this.runtimeSupportsSession(sessionOptions).then((result) => {
@@ -680,6 +733,245 @@
       supportsSession: this.supportedModes_.includes(options.mode)
     });
   }
+
+  // Private functions - hit test implementation:
+
+  // Modifies passed in frameData to add hit test results.
+  _calculateHitTestResults(frameData) {
+    if(!this.supportedModes_.includes(device.mojom.XRSessionMode.kImmersiveAr)) {
+      return;
+    }
+
+    frameData.hitTestSubscriptionResults = new device.mojom.XRHitTestSubscriptionResultsData();
+    frameData.hitTestSubscriptionResults.results = [];
+    frameData.hitTestSubscriptionResults.transientInputResults = [];
+
+    if(!this.world_) {
+      return;
+    }
+
+    // Non-transient hit test:
+    for(const [id, subscription] of this.hitTestSubscriptions_) {
+      const mojo_from_native_origin = this._getMojoFromNativeOrigin(subscription.nativeOriginInformation);
+      if(!mojo_from_native_origin) continue;
+
+      const ray_origin = {x: subscription.ray.origin.x, y: subscription.ray.origin.y, z: subscription.ray.origin.z, w: 1};
+      const ray_direction = {x: subscription.ray.direction.x, y: subscription.ray.direction.y, z: subscription.ray.direction.z, w: 0};
+
+      const mojo_ray_origin = XRMathHelper.transform_by_matrix(mojo_from_native_origin, ray_origin);
+      const mojo_ray_direction = XRMathHelper.transform_by_matrix(mojo_from_native_origin, ray_direction);
+
+      const results = this._hitTestWorld(mojo_ray_origin, mojo_ray_direction, subscription.entityTypes);
+
+      const result = new device.mojom.XRHitTestSubscriptionResultData();
+      result.subscriptionId = id;
+      result.hitTestResults = results;
+
+      frameData.hitTestSubscriptionResults.results.push(result);
+    }
+  }
+
+  // Hit tests the passed in ray (expressed as origin and direction) against the mocked world data.
+  _hitTestWorld(origin, direction, entityTypes) {
+    let result = [];
+
+    for(const region of this.world_.hitTestRegions) {
+      const partial_result = this._hitTestRegion(
+        region,
+        origin, direction,
+        entityTypes);
+
+      result = result.concat(partial_result);
+    }
+
+    return result.sort((lhs, rhs) => lhs.distance - rhs.distance);
+  };
+
+  // Hit tests the passed in ray (expressed as origin and direction) against world region.
+  // |entityTypes| is a set of FakeXRRegionTypes.
+  // |region| is FakeXRRegion.
+  // Returns array of XRHitResults, each entry will be decorated with the distance from the ray origin (along the ray).
+  _hitTestRegion(region, origin, direction, entityTypes) {
+    const regionNameToMojoEnum = {
+      "point":device.mojom.EntityTypeForHitTest.POINT,
+      "plane":device.mojom.EntityTypeForHitTest.PLANE,
+      "mesh":null
+    };
+
+    if(!entityTypes.includes(regionNameToMojoEnum[region.type])) {
+      return [];
+    }
+
+    const result = [];
+    for(const face of region.faces) {
+      const maybe_hit = this._hitTestFace(face, origin, direction);
+      if(maybe_hit) {
+        result.push(maybe_hit);
+      }
+    }
+
+    // The results should be sorted by distance and there should be no 2 entries with
+    // the same distance from ray origin - that would mean they are the same point.
+    // This situation is possible when a ray intersects the region through an edge shared
+    // by 2 faces.
+    return result.sort((lhs, rhs) => lhs.distance - rhs.distance)
+                 .filter((val, index, array) => index === 0 || val.distance !== array[index - 1].distance);
+  }
+
+  // Hit tests the passed in ray (expressed as origin and direction) against a single face.
+  // |face|, |origin|, and |direction| are specified in world (aka mojo) coordinates.
+  // |face| is an array of DOMPointInits.
+  // Returns null if the face does not intersect with the ray, otherwise the result is
+  // an XRHitResult with matrix describing the pose of the intersection point.
+  _hitTestFace(face, origin, direction) {
+    const add = XRMathHelper.add;
+    const sub = XRMathHelper.sub;
+    const mul = XRMathHelper.mul;
+    const normalize = XRMathHelper.normalize;
+    const dot = XRMathHelper.dot;
+    const cross = XRMathHelper.cross;
+    const neg = XRMathHelper.neg;
+
+    //1. Calculate plane normal in world coordinates.
+    const point_A = face[0];
+    const point_B = face[1];
+    const point_C = face[2];
+
+    const edge_AB = sub(point_B, point_A);
+    const edge_AC = sub(point_C, point_A);
+
+    const normal = normalize(cross(edge_AB, edge_AC));
+
+    const numerator = dot(sub(point_A, origin), normal);
+    const denominator = dot(direction, normal);
+
+    if(Math.abs(denominator) < 0.0001) {
+      // Planes are nearly parallel - there's either infinitely many intersection points or 0.
+      // Both cases signify a "no hit" for us.
+      return null;
+    } else {
+      // Single intersection point between the infinite plane and the line (*not* ray).
+      // Need to calculate the hit test matrix taking into account the face vertices.
+      const distance = numerator / denominator;
+      if(distance < 0) {
+        // Line - plane intersection exists, but not the half-line - plane does not.
+        return null;
+      } else {
+        const intersection_point = add(origin, mul(distance, direction));
+        // Since we are treating the face as a solid, flip the normal so that its
+        // half-space will contain the ray origin.
+        const y_axis = denominator > 0 ? neg(normal) : normal;
+
+        let z_axis = null;
+        const cos_direction_and_y_axis = dot(direction, y_axis);
+        if(Math.abs(cos_direction_and_y_axis) > 0.9999) {
+          // Ray and the hit test normal are co-linear - try using the 'up' or 'right' vector's projection on the face plane as the Z axis.
+          // Note: this edge case is currently not covered by the spec.
+          const up = {x: 0.0, y: 1.0, z: 0.0, w: 0.0};
+          const right = {x:1.0, y: 0.0, z: 0.0, w: 0.0};
+
+          z_axis = Math.abs(dot(up, y_axis)) > 0.9999
+                        ? sub(up, mul(dot(right, y_axis), y_axis))  // `up is also co-linear with hit test normal, use `right`
+                        : sub(up, mul(dot(up, y_axis), y_axis));    // `up` is not co-linear with hit test normal, use it
+        } else {
+          // Project the ray direction onto the plane, negate it and use as a Z axis.
+          z_axis = neg(sub(direction, mul(cos_direction_and_y_axis, y_axis))); // Z should point towards the ray origin, not away.
+        }
+
+        const x_axis = normalize(cross(y_axis, z_axis));
+
+        // Filter out the points not in polygon.
+        if(!XRMathHelper.pointInFace(intersection_point, face)) {
+          return null;
+        }
+
+        const hitResult = new device.mojom.XRHitResult();
+        hitResult.hitMatrix = new gfx.mojom.Transform();
+
+        hitResult.distance = distance;  // Extend the object with additional information used by higher layers.
+                                        // It will not be serialized over mojom.
+
+        hitResult.hitMatrix.matrix = new Array(16);
+
+        hitResult.hitMatrix.matrix[0] = x_axis.x;
+        hitResult.hitMatrix.matrix[1] = x_axis.y;
+        hitResult.hitMatrix.matrix[2] = x_axis.z;
+        hitResult.hitMatrix.matrix[3] = 0;
+
+        hitResult.hitMatrix.matrix[4] = y_axis.x;
+        hitResult.hitMatrix.matrix[5] = y_axis.y;
+        hitResult.hitMatrix.matrix[6] = y_axis.z;
+        hitResult.hitMatrix.matrix[7] = 0;
+
+        hitResult.hitMatrix.matrix[8] = z_axis.x;
+        hitResult.hitMatrix.matrix[9] = z_axis.y;
+        hitResult.hitMatrix.matrix[10] = z_axis.z;
+        hitResult.hitMatrix.matrix[11] = 0;
+
+        hitResult.hitMatrix.matrix[12] = intersection_point.x;
+        hitResult.hitMatrix.matrix[13] = intersection_point.y;
+        hitResult.hitMatrix.matrix[14] = intersection_point.z;
+        hitResult.hitMatrix.matrix[15] = 1;
+
+        return hitResult;
+      }
+    }
+  }
+
+  _getMojoFromNativeOrigin(nativeOriginInformation) {
+    const identity = function() {
+      return [
+        1, 0, 0, 0,
+        0, 1, 0, 0,
+        0, 0, 1, 0,
+        0, 0, 0, 1
+      ];
+    };
+
+    if(nativeOriginInformation.$tag == device.mojom.XRNativeOriginInformation.Tags.inputSourceId) {
+      if(!this.input_sources_.has(nativeOriginInformation.inputSourceId)) {
+        return null;
+      } else {
+        const inputSource = this.input_sources_.get(nativeOriginInformation.inputSourceId);
+        return inputSource.mojo_from_input_.matrix;
+      }
+    } else if(nativeOriginInformation.$tag == device.mojom.XRNativeOriginInformation.Tags.referenceSpaceCategory) {
+      switch(nativeOriginInformation.referenceSpaceCategory) {
+        case device.mojom.XRReferenceSpaceCategory.LOCAL:
+          return identity();
+        case device.mojom.XRReferenceSpaceCategory.LOCAL_FLOOR:
+          if(this.displayInfo_ == null || this.displayInfo_.stageParameters == null
+          || this.displayInfo_.stageParameters.standingTransform == null) {
+            console.warn("Standing transform not available.");
+            return null;
+          }
+          // this.displayInfo_.stageParameters.standingTransform = floor_from_mojo aka native_origin_from_mojo
+          return XRMathHelper.inverse(this.displayInfo_.stageParameters.standingTransform.matrix);
+        case device.mojom.XRReferenceSpaceCategory.VIEWER:
+          const transform = {
+            position: [
+              this.pose_.position.x,
+              this.pose_.position.y,
+              this.pose_.position.z],
+            orientation: [
+              this.pose_.orientation.x,
+              this.pose_.orientation.y,
+              this.pose_.orientation.z,
+              this.pose_.orientation.w],
+          };
+          return getMatrixFromTransform(transform);  // this.pose_ = mojo_from_viewer
+        case device.mojom.XRReferenceSpaceCategory.BOUNDED_FLOOR:
+          return null;
+        case device.mojom.XRReferenceSpaceCategory.UNBOUNDED:
+          return null;
+        default:
+          throw new TypeError("Unrecognized XRReferenceSpaceCategory!");
+      }
+    } else {
+      // Anchors & planes are not yet supported for hit test.
+      return null;
+    }
+  }
 }
 
 class MockXRSessionMetricsRecorder {
diff --git a/webxr/hit-test/ar_hittest_subscription_refSpaces.https.html b/webxr/hit-test/ar_hittest_subscription_refSpaces.https.html
new file mode 100644
index 0000000..3cbbd6c
--- /dev/null
+++ b/webxr/hit-test/ar_hittest_subscription_refSpaces.https.html
@@ -0,0 +1,176 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/webxr_util.js"></script>
+<script src="../resources/webxr_test_asserts.js"></script>
+<script src="../resources/webxr_test_constants.js"></script>
+<script src="../resources/webxr_test_constants_fake_world.js"></script>
+<canvas />
+
+<script>
+
+// 1m above world origin.
+const VIEWER_ORIGIN_TRANSFORM = {
+  position: [0, 1, 0],
+  orientation: [0, 0, 0, 1],
+};
+
+// 0.25m above world origin.
+const FLOOR_ORIGIN_TRANSFORM = {
+  position: [0, -0.25, 0],
+  orientation: [0, 0, 0, 1],
+};
+
+const fakeDeviceInitParams = {
+  supportedModes: ["immersive-ar"],
+  views: VALID_VIEWS,
+  floorOrigin: FLOOR_ORIGIN_TRANSFORM,    // aka floor_from_mojo
+  viewerOrigin: VIEWER_ORIGIN_TRANSFORM,  // aka mojo_from_viewer
+  supportedFeatures: ALL_FEATURES,
+  world: createFakeWorld(5.0, 2.0, 5.0),  // webxr_test_constants_fake_world.js has detailed description of the fake world
+};
+
+// Generates a test function given the parameters for the hit test.
+// |ray| - ray that will be used to subscribe to hit test.
+// |entityTypes| - entity types that will be used for the hit test subscription.
+// |expectedPoses| - array of expected pose objects. The poses are expected to be expressed in local space.
+//                   Null entries in the array mean that the given entry will not be validated.
+// |refSpaceName| - XRReferenceSpaceType - either 'local', 'local-floor' or 'viewer'.
+let testFunctionGenerator = function(ray, entityTypes, expectedPoses, refSpaceName) {
+  const testFunction = function(session, fakeDeviceController, t) {
+    assert_equals(session.mode, 'immersive-ar');
+    assert_not_equals(session.environmentBlendMode, 'opaque');
+
+    return Promise.all([
+      session.requestReferenceSpace('local'),
+      session.requestReferenceSpace('viewer'),
+      session.requestReferenceSpace('local-floor'),
+    ]).then(([localRefSpace, viewerRefSpace, localFloorRefSpace]) => {
+
+      const refSpaceNameToSpace = {
+        'local' : localRefSpace,
+        'viewer' : viewerRefSpace,
+        'local-floor' : localFloorRefSpace
+      };
+
+      const hitTestOptionsInit = {
+        space: refSpaceNameToSpace[refSpaceName],
+        offsetRay: ray,
+        entityTypes: entityTypes,
+      };
+
+      return session.requestHitTestSource(hitTestOptionsInit).then(
+        (hitTestSource) => new Promise((resolve, reject) => {
+
+        const requestAnimationFrameCallback = function(time, frame) {
+          const hitTestResults = frame.getHitTestResults(hitTestSource);
+
+          t.step(() => {
+            assert_equals(hitTestResults.length, expectedPoses.length, "Results length should match expected results length");
+            for(const [index, expectedPose] of expectedPoses.entries()) {
+              const pose = hitTestResults[index].getPose(localRefSpace);
+              assert_true(pose != null, "Each hit test result should have a pose in viewer space");
+              if(expectedPose != null) {
+                assert_transform_approx_equals(pose.transform, expectedPose);
+              }
+            }
+          });
+
+          resolve();
+        };
+
+        t.step(() => {
+          assert_true(hitTestSource != null, "Hit test source should not be null");
+        });
+
+        session.requestAnimationFrame(requestAnimationFrameCallback);
+      }));
+    });
+  };
+
+  return testFunction;
+};
+
+// Generates a test function that will use local space for hit test subscription.
+// See testFunctionGenerator for explanation of other parameters.
+const localBasedTestFunctionGenerator = function(ray, entityTypes, expectedPoses) {
+  return testFunctionGenerator(ray, entityTypes, expectedPoses, 'local');
+};
+
+// Generates a test function that will use viewer space for hit test subscription.
+// See testFunctionGenerator for explanation of other parameters.
+const viewerBasedTestFunctionGenerator = function(ray, entityTypes, expectedPoses) {
+  return testFunctionGenerator(ray, entityTypes, expectedPoses, 'viewer');
+};
+
+// Generates a test function that will use local-floor space for hit test subscription.
+// See testFunctionGenerator for explanation of other parameters.
+const localFloorBasedTestFunctionGenerator = function(ray, entityTypes, expectedPoses) {
+  return testFunctionGenerator(ray, entityTypes, expectedPoses, 'local-floor');
+};
+
+// Pose of the first expected hit test result - straight ahead of the viewer, viewer-facing.
+const pose_1 = {
+  position: {x: 0.0, y: 1.0, z: -2.5, w: 1.0},
+  orientation: {x: 0.0, y: -0.707, z: -0.707, w: 0.0},
+    // Hit test API will set Y axis to the surface normal at the intersection point,
+    // Z axis towards the ray origin and X axis to cross product of Y axis & Z axis.
+    // If the surface normal and Z axis would be parallel, the hit test API
+    // will attempt to use `up` vector ([0, 1, 0]) as the Z axis, and if it so happens that Z axis
+    // and the surface normal would still be parallel, it will use the `right` vector ([1, 0, 0]) as the Z axis.
+    // In this particular case, `up` vector will work so the resulting pose.orientation
+    // becomes a rotation around [0, 1, 1] vector by 180 degrees.
+};
+
+xr_session_promise_test(
+  "Ensures subscription to hit test works with viewer space - straight ahead - plane",
+  viewerBasedTestFunctionGenerator(new XRRay(), ["plane"], [pose_1]),
+  fakeDeviceInitParams, 'immersive-ar', { 'requiredFeatures': ['local-floor'] });
+
+xr_session_promise_test("Ensures subscription to hit test works with viewer space - straight up - plane - no results",
+  viewerBasedTestFunctionGenerator(new XRRay({}, {x: 0.0, y: 1.0, z : 0.0}), ["plane"], []),
+  fakeDeviceInitParams, 'immersive-ar', { 'requiredFeatures': ['local-floor'] });
+
+const pose_2 = {
+  position: {x: 0.0, y: 2.0, z: 0.0, w: 1.0},
+  orientation: {x: 0.707, y: 0.0, z: 0.707, w: 0.0 },
+    // See comment for pose_1.orientation for details.
+    // In this case, the hit test pose will have Y axis facing downward ([0,-1,0]),
+    // Z axis to the right ([1,0,0]) and X axis towards world's Z axis ([0,0,1]).
+    // This is equivalent to the rotation around [1, 0, 1] vectir by 180 degrees.
+};
+
+xr_session_promise_test("Ensures subscription to hit test works with viewer space - straight up - point",
+  viewerBasedTestFunctionGenerator(new XRRay({}, {x: 0.0, y: 1.0, z : 0.0}), ["point"], [pose_2]),
+  fakeDeviceInitParams,
+  'immersive-ar', { 'requiredFeatures': ['local-floor'] });
+
+const pose_3 = {
+  position: {x: 0.0, y: 0.0, z: -2.5, w: 1.0},
+  orientation: {x: 0.0, y: -0.707, z: -0.707, w: 0.0},
+    // See comment for pose_1.orientation for details.
+    // In this case, the hit test pose will have Y and Z axis towards the ray origin so it won't be used,
+    // but `up` vector will work so the resulting pose.orientation
+    // becomes a rotation around [0, 1, 1] vector by 180 degrees.
+};
+
+xr_session_promise_test("Ensures subscription to hit test works with local space",
+  localBasedTestFunctionGenerator(new XRRay(), ["plane"], [pose_3]),
+  fakeDeviceInitParams,
+  'immersive-ar', { 'requiredFeatures': ['local-floor'] });
+
+const pose_4 = {
+  position: {x: 0.0, y: 0.25, z: -2.5, w: 1.0},
+  orientation: {x: 0.0, y: -0.707, z: -0.707, w: 0.0},
+    // See comment for pose_1.orientation for details.
+    // In this case, the hit test pose will have Y and Z axis towards the ray origin so it won't be used,
+    // but `up` vector will work so the resulting pose.orientation
+    // becomes a rotation around [0, 1, 1] vector by 180 degrees.
+};
+
+xr_session_promise_test("Ensures subscription to hit test works with local-floor space",
+  localFloorBasedTestFunctionGenerator(new XRRay(), ["plane"], [pose_4]),
+  fakeDeviceInitParams,
+  'immersive-ar', { 'requiredFeatures': ['local-floor'] });
+
+</script>
diff --git a/webxr/resources/webxr_util.js b/webxr/resources/webxr_util.js
index 85821d4..5f66dc9 100644
--- a/webxr/resources/webxr_util.js
+++ b/webxr/resources/webxr_util.js
@@ -145,6 +145,7 @@
     '/gen/ui/gfx/mojom/gpu_fence_handle.mojom.js',
     '/gen/ui/gfx/mojom/transform.mojom.js',
     '/gen/device/vr/public/mojom/vr_service.mojom.js',
+    '/resources/chromium/webxr-test-math-helper.js',
     '/resources/chromium/webxr-test.js',
     '/resources/testdriver.js',
     '/resources/testdriver-vendor.js',