diff options
Diffstat (limited to 'test/wpt/tests/resources/chromium')
34 files changed, 6713 insertions, 0 deletions
diff --git a/test/wpt/tests/resources/chromium/README.md b/test/wpt/tests/resources/chromium/README.md new file mode 100644 index 0000000..be090b3 --- /dev/null +++ b/test/wpt/tests/resources/chromium/README.md @@ -0,0 +1,7 @@ +This directory contains Chromium-specific test resources, including mocks for +test-only APIs implemented with +[MojoJS](https://chromium.googlesource.com/chromium/src/+/main/mojo/public/js/README.md). + +Please do **not** copy `*.mojom.m.js` into this directory. Follow this doc if you +want to add new MojoJS-backed mocks: +https://chromium.googlesource.com/chromium/src/+/main/docs/testing/web_platform_tests.md#mojojs diff --git a/test/wpt/tests/resources/chromium/contacts_manager_mock.js b/test/wpt/tests/resources/chromium/contacts_manager_mock.js new file mode 100644 index 0000000..0496852 --- /dev/null +++ b/test/wpt/tests/resources/chromium/contacts_manager_mock.js @@ -0,0 +1,90 @@ +// Copyright 2018 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import {ContactsManager, ContactsManagerReceiver} from '/gen/third_party/blink/public/mojom/contacts/contacts_manager.mojom.m.js'; + +self.WebContactsTest = (() => { + class MockContacts { + constructor() { + this.receiver_ = new ContactsManagerReceiver(this); + + this.interceptor_ = + new MojoInterfaceInterceptor(ContactsManager.$interfaceName); + this.interceptor_.oninterfacerequest = + e => this.receiver_.$.bindHandle(e.handle); + this.interceptor_.start(); + + this.selectedContacts_ = []; + } + + formatAddress_(address) { + // These are all required fields in the mojo definition. + return { + country: address.country || '', + addressLine: address.addressLine || [], + region: address.region || '', + city: address.city || '', + dependentLocality: address.dependentLocality || '', + postalCode: address.postCode || '', + sortingCode: address.sortingCode || '', + organization: address.organization || '', + recipient: address.recipient || '', + phone: address.phone || '', + }; + } + + async select(multiple, includeNames, includeEmails, includeTel, includeAddresses, includeIcons) { + if (this.selectedContacts_ === null) + return {contacts: null}; + + const contactInfos = await Promise.all(this.selectedContacts_.map(async contact => { + const contactInfo = {}; + if (includeNames) + contactInfo.name = contact.name || []; + if (includeEmails) + contactInfo.email = contact.email || []; + if (includeTel) + contactInfo.tel = contact.tel || []; + if (includeAddresses) { + contactInfo.address = (contact.address || []).map(address => this.formatAddress_(address)); + } + if (includeIcons) { + contactInfo.icon = await Promise.all( + (contact.icon || []).map(async blob => ({ + mimeType: blob.type, + data: (await blob.text()).split('').map(s => s.charCodeAt(0)), + }))); + } + return contactInfo; + })); + + if (!contactInfos.length) return {contacts: []}; + if (!multiple) return {contacts: [contactInfos[0]]}; + return {contacts: contactInfos}; + } + + setSelectedContacts(contacts) { + this.selectedContacts_ = contacts; + } + + reset() { + this.receiver_.$.close(); + this.interceptor_.stop(); + } + } + + const mockContacts = new MockContacts(); + + class ContactsTestChromium { + constructor() { + Object.freeze(this); // Make it immutable. + } + + setSelectedContacts(contacts) { + mockContacts.setSelectedContacts(contacts); + } + } + + return ContactsTestChromium; +})(); diff --git a/test/wpt/tests/resources/chromium/content-index-helpers.js b/test/wpt/tests/resources/chromium/content-index-helpers.js new file mode 100644 index 0000000..936fe84 --- /dev/null +++ b/test/wpt/tests/resources/chromium/content-index-helpers.js @@ -0,0 +1,9 @@ +import {ContentIndexService} from '/gen/third_party/blink/public/mojom/content_index/content_index.mojom.m.js'; + +// Returns a promise if the chromium based browser fetches icons for +// content-index. +export async function fetchesIcons() { + const remote = ContentIndexService.getRemote(); + const {iconSizes} = await remote.getIconSizes(); + return iconSizes.length > 0; +}; diff --git a/test/wpt/tests/resources/chromium/enable-hyperlink-auditing.js b/test/wpt/tests/resources/chromium/enable-hyperlink-auditing.js new file mode 100644 index 0000000..263f651 --- /dev/null +++ b/test/wpt/tests/resources/chromium/enable-hyperlink-auditing.js @@ -0,0 +1,2 @@ +if (window.testRunner) + testRunner.overridePreference("WebKitHyperlinkAuditingEnabled", 1); diff --git a/test/wpt/tests/resources/chromium/fake-hid.js b/test/wpt/tests/resources/chromium/fake-hid.js new file mode 100644 index 0000000..70a0149 --- /dev/null +++ b/test/wpt/tests/resources/chromium/fake-hid.js @@ -0,0 +1,297 @@ +import {HidConnectionReceiver, HidDeviceInfo} from '/gen/services/device/public/mojom/hid.mojom.m.js'; +import {HidService, HidServiceReceiver} from '/gen/third_party/blink/public/mojom/hid/hid.mojom.m.js'; + +// Fake implementation of device.mojom.HidConnection. HidConnection represents +// an open connection to a HID device and can be used to send and receive +// reports. +class FakeHidConnection { + constructor(client) { + this.client_ = client; + this.receiver_ = new HidConnectionReceiver(this); + this.expectedWrites_ = []; + this.expectedGetFeatureReports_ = []; + this.expectedSendFeatureReports_ = []; + } + + bindNewPipeAndPassRemote() { + return this.receiver_.$.bindNewPipeAndPassRemote(); + } + + // Simulate an input report sent from the device to the host. The connection + // client's onInputReport method will be called with the provided |reportId| + // and |buffer|. + simulateInputReport(reportId, reportData) { + if (this.client_) { + this.client_.onInputReport(reportId, reportData); + } + } + + // Specify the result for an expected call to write. If |success| is true the + // write will be successful, otherwise it will simulate a failure. The + // parameters of the next write call must match |reportId| and |buffer|. + queueExpectedWrite(success, reportId, reportData) { + this.expectedWrites_.push({ + params: {reportId, data: reportData}, + result: {success}, + }); + } + + // Specify the result for an expected call to getFeatureReport. If |success| + // is true the operation is successful, otherwise it will simulate a failure. + // The parameter of the next getFeatureReport call must match |reportId|. + queueExpectedGetFeatureReport(success, reportId, reportData) { + this.expectedGetFeatureReports_.push({ + params: {reportId}, + result: {success, buffer: reportData}, + }); + } + + // Specify the result for an expected call to sendFeatureReport. If |success| + // is true the operation is successful, otherwise it will simulate a failure. + // The parameters of the next sendFeatureReport call must match |reportId| and + // |buffer|. + queueExpectedSendFeatureReport(success, reportId, reportData) { + this.expectedSendFeatureReports_.push({ + params: {reportId, data: reportData}, + result: {success}, + }); + } + + // Asserts that there are no more expected operations. + assertExpectationsMet() { + assert_equals(this.expectedWrites_.length, 0); + assert_equals(this.expectedGetFeatureReports_.length, 0); + assert_equals(this.expectedSendFeatureReports_.length, 0); + } + + read() {} + + // Implementation of HidConnection::Write. Causes an assertion failure if + // there are no expected write operations, or if the parameters do not match + // the expected call. + async write(reportId, buffer) { + let expectedWrite = this.expectedWrites_.shift(); + assert_not_equals(expectedWrite, undefined); + assert_equals(reportId, expectedWrite.params.reportId); + let actual = new Uint8Array(buffer); + compareDataViews( + new DataView(actual.buffer, actual.byteOffset), + new DataView( + expectedWrite.params.data.buffer, + expectedWrite.params.data.byteOffset)); + return expectedWrite.result; + } + + // Implementation of HidConnection::GetFeatureReport. Causes an assertion + // failure if there are no expected write operations, or if the parameters do + // not match the expected call. + async getFeatureReport(reportId) { + let expectedGetFeatureReport = this.expectedGetFeatureReports_.shift(); + assert_not_equals(expectedGetFeatureReport, undefined); + assert_equals(reportId, expectedGetFeatureReport.params.reportId); + return expectedGetFeatureReport.result; + } + + // Implementation of HidConnection::SendFeatureReport. Causes an assertion + // failure if there are no expected write operations, or if the parameters do + // not match the expected call. + async sendFeatureReport(reportId, buffer) { + let expectedSendFeatureReport = this.expectedSendFeatureReports_.shift(); + assert_not_equals(expectedSendFeatureReport, undefined); + assert_equals(reportId, expectedSendFeatureReport.params.reportId); + let actual = new Uint8Array(buffer); + compareDataViews( + new DataView(actual.buffer, actual.byteOffset), + new DataView( + expectedSendFeatureReport.params.data.buffer, + expectedSendFeatureReport.params.data.byteOffset)); + return expectedSendFeatureReport.result; + } +} + + +// A fake implementation of the HidService mojo interface. HidService manages +// HID device access for clients in the render process. Typically, when a client +// requests access to a HID device a chooser dialog is shown with a list of +// available HID devices. Selecting a device from the chooser also grants +// permission for the client to access that device. +// +// The fake implementation allows tests to simulate connected devices. It also +// skips the chooser dialog and instead allows tests to specify which device +// should be selected. All devices are treated as if the user had already +// granted permission. It is possible to revoke permission with forget() later. +class FakeHidService { + constructor() { + this.interceptor_ = new MojoInterfaceInterceptor(HidService.$interfaceName); + this.interceptor_.oninterfacerequest = e => this.bind(e.handle); + this.receiver_ = new HidServiceReceiver(this); + this.nextGuidValue_ = 0; + this.simulateConnectFailure_ = false; + this.reset(); + } + + start() { + this.interceptor_.start(); + } + + stop() { + this.interceptor_.stop(); + } + + reset() { + this.devices_ = new Map(); + this.allowedDevices_ = new Map(); + this.fakeConnections_ = new Map(); + this.selectedDevices_ = []; + } + + // Creates and returns a HidDeviceInfo with the specified device IDs. + makeDevice(vendorId, productId) { + let guidValue = ++this.nextGuidValue_; + let info = new HidDeviceInfo(); + info.guid = 'guid-' + guidValue.toString(); + info.physicalDeviceId = 'physical-device-id-' + guidValue.toString(); + info.vendorId = vendorId; + info.productId = productId; + info.productName = 'product name'; + info.serialNumber = '0'; + info.reportDescriptor = new Uint8Array(); + info.collections = []; + info.deviceNode = 'device node'; + return info; + } + + // Simulates a connected device the client has already been granted permission + // to. Returns the key used to store the device in the map. The key is either + // the physical device ID, or the device GUID if it has no physical device ID. + addDevice(deviceInfo, grantPermission = true) { + let key = deviceInfo.physicalDeviceId; + if (key.length === 0) + key = deviceInfo.guid; + + let devices = this.devices_.get(key) || []; + devices.push(deviceInfo); + this.devices_.set(key, devices); + + if (grantPermission) { + let allowedDevices = this.allowedDevices_.get(key) || []; + allowedDevices.push(deviceInfo); + this.allowedDevices_.set(key, allowedDevices); + } + + if (this.client_) + this.client_.deviceAdded(deviceInfo); + return key; + } + + // Simulates disconnecting a connected device. + removeDevice(key) { + let devices = this.devices_.get(key); + this.devices_.delete(key); + if (this.client_ && devices) { + devices.forEach(deviceInfo => { + this.client_.deviceRemoved(deviceInfo); + }); + } + } + + // Simulates updating the device information for a connected device. + changeDevice(deviceInfo) { + let key = deviceInfo.physicalDeviceId; + if (key.length === 0) + key = deviceInfo.guid; + + let devices = this.devices_.get(key) || []; + let i = devices.length; + while (i--) { + if (devices[i].guid == deviceInfo.guid) + devices.splice(i, 1); + } + devices.push(deviceInfo); + this.devices_.set(key, devices); + + let allowedDevices = this.allowedDevices_.get(key) || []; + let j = allowedDevices.length; + while (j--) { + if (allowedDevices[j].guid == deviceInfo.guid) + allowedDevices.splice(j, 1); + } + allowedDevices.push(deviceInfo); + this.allowedDevices_.set(key, allowedDevices); + + if (this.client_) + this.client_.deviceChanged(deviceInfo); + return key; + } + + // Sets a flag that causes the next call to connect() to fail. + simulateConnectFailure() { + this.simulateConnectFailure_ = true; + } + + // Sets the key of the device that will be returned as the selected item the + // next time requestDevice is called. The device with this key must have been + // previously added with addDevice. + setSelectedDevice(key) { + this.selectedDevices_ = this.devices_.get(key); + } + + // Returns the fake HidConnection object for this device, if there is one. A + // connection is created once the device is opened. + getFakeConnection(guid) { + return this.fakeConnections_.get(guid); + } + + bind(handle) { + this.receiver_.$.bindHandle(handle); + } + + registerClient(client) { + this.client_ = client; + } + + // Returns an array of connected devices the client has already been granted + // permission to access. + async getDevices() { + let devices = []; + this.allowedDevices_.forEach((value) => { + devices = devices.concat(value); + }); + return {devices}; + } + + // Simulates a device chooser prompt, returning |selectedDevices_| as the + // simulated selection. |options| is ignored. + async requestDevice(options) { + return {devices: this.selectedDevices_}; + } + + // Returns a fake connection to the device with the specified GUID. If + // |connectionClient| is not null, its onInputReport method will be called + // when input reports are received. If simulateConnectFailure() was called + // then a null connection is returned instead, indicating failure. + async connect(guid, connectionClient) { + if (this.simulateConnectFailure_) { + this.simulateConnectFailure_ = false; + return {connection: null}; + } + const fakeConnection = new FakeHidConnection(connectionClient); + this.fakeConnections_.set(guid, fakeConnection); + return {connection: fakeConnection.bindNewPipeAndPassRemote()}; + } + + // Removes the allowed device. + async forget(deviceInfo) { + for (const [key, value] of this.allowedDevices_) { + for (const device of value) { + if (device.guid == deviceInfo.guid) { + this.allowedDevices_.delete(key); + break; + } + } + } + return {success: true}; + } +} + +export const fakeHidService = new FakeHidService(); diff --git a/test/wpt/tests/resources/chromium/fake-serial.js b/test/wpt/tests/resources/chromium/fake-serial.js new file mode 100644 index 0000000..e1e4d57 --- /dev/null +++ b/test/wpt/tests/resources/chromium/fake-serial.js @@ -0,0 +1,443 @@ +import {SerialPortFlushMode, SerialPortRemote, SerialReceiveError, SerialPortReceiver, SerialSendError} from '/gen/services/device/public/mojom/serial.mojom.m.js'; +import {SerialService, SerialServiceReceiver} from '/gen/third_party/blink/public/mojom/serial/serial.mojom.m.js'; + +// Implementation of an UnderlyingSource to create a ReadableStream from a Mojo +// data pipe consumer handle. +class DataPipeSource { + constructor(consumer) { + this.consumer_ = consumer; + } + + async pull(controller) { + let chunk = new ArrayBuffer(64); + let {result, numBytes} = this.consumer_.readData(chunk); + if (result == Mojo.RESULT_OK) { + controller.enqueue(new Uint8Array(chunk, 0, numBytes)); + return; + } else if (result == Mojo.RESULT_FAILED_PRECONDITION) { + controller.close(); + return; + } else if (result == Mojo.RESULT_SHOULD_WAIT) { + await this.readable(); + return this.pull(controller); + } + } + + cancel() { + if (this.watcher_) + this.watcher_.cancel(); + this.consumer_.close(); + } + + readable() { + return new Promise((resolve) => { + this.watcher_ = + this.consumer_.watch({ readable: true, peerClosed: true }, () => { + this.watcher_.cancel(); + this.watcher_ = undefined; + resolve(); + }); + }); + } +} + +// Implementation of an UnderlyingSink to create a WritableStream from a Mojo +// data pipe producer handle. +class DataPipeSink { + constructor(producer) { + this._producer = producer; + } + + async write(chunk, controller) { + while (true) { + let {result, numBytes} = this._producer.writeData(chunk); + if (result == Mojo.RESULT_OK) { + if (numBytes == chunk.byteLength) { + return; + } + chunk = chunk.slice(numBytes); + } else if (result == Mojo.RESULT_FAILED_PRECONDITION) { + throw new DOMException('The pipe is closed.', 'InvalidStateError'); + } else if (result == Mojo.RESULT_SHOULD_WAIT) { + await this.writable(); + } + } + } + + close() { + assert_equals(undefined, this._watcher); + this._producer.close(); + } + + abort(reason) { + if (this._watcher) + this._watcher.cancel(); + this._producer.close(); + } + + writable() { + return new Promise((resolve) => { + this._watcher = + this._producer.watch({ writable: true, peerClosed: true }, () => { + this._watcher.cancel(); + this._watcher = undefined; + resolve(); + }); + }); + } +} + +// Implementation of device.mojom.SerialPort. +class FakeSerialPort { + constructor() { + this.inputSignals_ = { + dataCarrierDetect: false, + clearToSend: false, + ringIndicator: false, + dataSetReady: false + }; + this.inputSignalFailure_ = false; + this.outputSignals_ = { + dataTerminalReady: false, + requestToSend: false, + break: false + }; + this.outputSignalFailure_ = false; + } + + open(options, client) { + if (this.receiver_ !== undefined) { + // Port already open. + return null; + } + + let port = new SerialPortRemote(); + this.receiver_ = new SerialPortReceiver(this); + this.receiver_.$.bindHandle(port.$.bindNewPipeAndPassReceiver().handle); + + this.options_ = options; + this.client_ = client; + // OS typically sets DTR on open. + this.outputSignals_.dataTerminalReady = true; + + return port; + } + + write(data) { + return this.writer_.write(data); + } + + read() { + return this.reader_.read(); + } + + // Reads from the port until at least |targetLength| is read or the stream is + // closed. The data is returned as a combined Uint8Array. + readWithLength(targetLength) { + return readWithLength(this.reader_, targetLength); + } + + simulateReadError(error) { + this.writer_.close(); + this.writer_.releaseLock(); + this.writer_ = undefined; + this.writable_ = undefined; + this.client_.onReadError(error); + } + + simulateParityError() { + this.simulateReadError(SerialReceiveError.PARITY_ERROR); + } + + simulateDisconnectOnRead() { + this.simulateReadError(SerialReceiveError.DISCONNECTED); + } + + simulateWriteError(error) { + this.reader_.cancel(); + this.reader_ = undefined; + this.readable_ = undefined; + this.client_.onSendError(error); + } + + simulateSystemErrorOnWrite() { + this.simulateWriteError(SerialSendError.SYSTEM_ERROR); + } + + simulateDisconnectOnWrite() { + this.simulateWriteError(SerialSendError.DISCONNECTED); + } + + simulateInputSignals(signals) { + this.inputSignals_ = signals; + } + + simulateInputSignalFailure(fail) { + this.inputSignalFailure_ = fail; + } + + get outputSignals() { + return this.outputSignals_; + } + + simulateOutputSignalFailure(fail) { + this.outputSignalFailure_ = fail; + } + + writable() { + if (this.writable_) + return Promise.resolve(); + + if (!this.writablePromise_) { + this.writablePromise_ = new Promise((resolve) => { + this.writableResolver_ = resolve; + }); + } + + return this.writablePromise_; + } + + readable() { + if (this.readable_) + return Promise.resolve(); + + if (!this.readablePromise_) { + this.readablePromise_ = new Promise((resolve) => { + this.readableResolver_ = resolve; + }); + } + + return this.readablePromise_; + } + + async startWriting(in_stream) { + this.readable_ = new ReadableStream(new DataPipeSource(in_stream)); + this.reader_ = this.readable_.getReader(); + if (this.readableResolver_) { + this.readableResolver_(); + this.readableResolver_ = undefined; + this.readablePromise_ = undefined; + } + } + + async startReading(out_stream) { + this.writable_ = new WritableStream(new DataPipeSink(out_stream)); + this.writer_ = this.writable_.getWriter(); + if (this.writableResolver_) { + this.writableResolver_(); + this.writableResolver_ = undefined; + this.writablePromise_ = undefined; + } + } + + async flush(mode) { + switch (mode) { + case SerialPortFlushMode.kReceive: + this.writer_.abort(); + this.writer_.releaseLock(); + this.writer_ = undefined; + this.writable_ = undefined; + break; + case SerialPortFlushMode.kTransmit: + this.reader_.cancel(); + this.reader_ = undefined; + this.readable_ = undefined; + break; + } + } + + async drain() { + await this.reader_.closed; + } + + async getControlSignals() { + if (this.inputSignalFailure_) { + return {signals: null}; + } + + const signals = { + dcd: this.inputSignals_.dataCarrierDetect, + cts: this.inputSignals_.clearToSend, + ri: this.inputSignals_.ringIndicator, + dsr: this.inputSignals_.dataSetReady + }; + return {signals}; + } + + async setControlSignals(signals) { + if (this.outputSignalFailure_) { + return {success: false}; + } + + if (signals.hasDtr) { + this.outputSignals_.dataTerminalReady = signals.dtr; + } + if (signals.hasRts) { + this.outputSignals_.requestToSend = signals.rts; + } + if (signals.hasBrk) { + this.outputSignals_.break = signals.brk; + } + return { success: true }; + } + + async configurePort(options) { + this.options_ = options; + return { success: true }; + } + + async getPortInfo() { + return { + bitrate: this.options_.bitrate, + dataBits: this.options_.datBits, + parityBit: this.options_.parityBit, + stopBits: this.options_.stopBits, + ctsFlowControl: + this.options_.hasCtsFlowControl && this.options_.ctsFlowControl, + }; + } + + async close() { + // OS typically clears DTR on close. + this.outputSignals_.dataTerminalReady = false; + if (this.writer_) { + this.writer_.close(); + this.writer_.releaseLock(); + this.writer_ = undefined; + } + this.writable_ = undefined; + + // Close the receiver asynchronously so the reply to this message can be + // sent first. + const receiver = this.receiver_; + this.receiver_ = undefined; + setTimeout(() => { + receiver.$.close(); + }, 0); + + return {}; + } +} + +// Implementation of blink.mojom.SerialService. +class FakeSerialService { + constructor() { + this.interceptor_ = + new MojoInterfaceInterceptor(SerialService.$interfaceName); + this.interceptor_.oninterfacerequest = e => this.bind(e.handle); + this.receiver_ = new SerialServiceReceiver(this); + this.clients_ = []; + this.nextToken_ = 0; + this.reset(); + } + + start() { + this.interceptor_.start(); + } + + stop() { + this.interceptor_.stop(); + } + + reset() { + this.ports_ = new Map(); + this.selectedPort_ = null; + } + + addPort(info) { + let portInfo = {}; + if (info?.usbVendorId !== undefined) { + portInfo.hasUsbVendorId = true; + portInfo.usbVendorId = info.usbVendorId; + } + if (info?.usbProductId !== undefined) { + portInfo.hasUsbProductId = true; + portInfo.usbProductId = info.usbProductId; + } + + let token = ++this.nextToken_; + portInfo.token = {high: 0n, low: BigInt(token)}; + + let record = { + portInfo: portInfo, + fakePort: new FakeSerialPort(), + }; + this.ports_.set(token, record); + + for (let client of this.clients_) { + client.onPortAdded(portInfo); + } + + return token; + } + + removePort(token) { + let record = this.ports_.get(token); + if (record === undefined) { + return; + } + + this.ports_.delete(token); + + for (let client of this.clients_) { + client.onPortRemoved(record.portInfo); + } + } + + setSelectedPort(token) { + this.selectedPort_ = this.ports_.get(token); + } + + getFakePort(token) { + let record = this.ports_.get(token); + if (record === undefined) + return undefined; + return record.fakePort; + } + + bind(handle) { + this.receiver_.$.bindHandle(handle); + } + + async setClient(client_remote) { + this.clients_.push(client_remote); + } + + async getPorts() { + return { + ports: Array.from(this.ports_, ([token, record]) => record.portInfo) + }; + } + + async requestPort(filters) { + if (this.selectedPort_) + return { port: this.selectedPort_.portInfo }; + else + return { port: null }; + } + + async openPort(token, options, client) { + let record = this.ports_.get(Number(token.low)); + if (record !== undefined) { + return {port: record.fakePort.open(options, client)}; + } else { + return {port: null}; + } + } + + async forgetPort(token) { + let record = this.ports_.get(Number(token.low)); + if (record === undefined) { + return {success: false}; + } + + this.ports_.delete(Number(token.low)); + if (record.fakePort.receiver_) { + record.fakePort.receiver_.$.close(); + record.fakePort.receiver_ = undefined; + } + return {success: true}; + } +} + +export const fakeSerialService = new FakeSerialService(); diff --git a/test/wpt/tests/resources/chromium/generic_sensor_mocks.js b/test/wpt/tests/resources/chromium/generic_sensor_mocks.js new file mode 100644 index 0000000..98a29c2 --- /dev/null +++ b/test/wpt/tests/resources/chromium/generic_sensor_mocks.js @@ -0,0 +1,519 @@ +import {ReportingMode, Sensor, SensorClientRemote, SensorReceiver, SensorRemote, SensorType} from '/gen/services/device/public/mojom/sensor.mojom.m.js'; +import {SensorCreationResult, SensorInitParams_READ_BUFFER_SIZE_FOR_TESTS, SensorProvider, SensorProviderReceiver} from '/gen/services/device/public/mojom/sensor_provider.mojom.m.js'; + +// A "sliding window" that iterates over |data| and returns one item at a +// time, advancing and wrapping around as needed. |data| must be an array of +// arrays. +self.RingBuffer = class { + constructor(data) { + this.bufferPosition_ = 0; + // Validate |data|'s format and deep-copy every element. + this.data_ = Array.from(data, element => { + if (!Array.isArray(element)) { + throw new TypeError('Every |data| element must be an array.'); + } + return Array.from(element); + }) + } + + next() { + const value = this.data_[this.bufferPosition_]; + this.bufferPosition_ = (this.bufferPosition_ + 1) % this.data_.length; + return { done: false, value: value }; + } + + value() { + return this.data_[this.bufferPosition_]; + } + + [Symbol.iterator]() { + return this; + } +}; + +class DefaultSensorTraits { + // https://w3c.github.io/sensors/#threshold-check-algorithm + static isSignificantlyDifferent(reading1, reading2) { + return true; + } + + // https://w3c.github.io/sensors/#reading-quantization-algorithm + static roundToMultiple(reading) { + return reading; + } + + // https://w3c.github.io/ambient-light/#ambient-light-threshold-check-algorithm + static areReadingsEqual(reading1, reading2) { + return false; + } +} + +class AmbientLightSensorTraits extends DefaultSensorTraits { + // https://w3c.github.io/ambient-light/#reduce-sensor-accuracy + static #ROUNDING_MULTIPLE = 50; + static #SIGNIFICANCE_THRESHOLD = 25; + + // https://w3c.github.io/ambient-light/#ambient-light-threshold-check-algorithm + static isSignificantlyDifferent([illuminance1], [illuminance2]) { + return Math.abs(illuminance1 - illuminance2) >= + this.#SIGNIFICANCE_THRESHOLD; + } + + // https://w3c.github.io/ambient-light/#ambient-light-reading-quantization-algorithm + static roundToMultiple(reading) { + const illuminance = reading[0]; + const scaledValue = + illuminance / AmbientLightSensorTraits.#ROUNDING_MULTIPLE; + let roundedReading = reading.splice(); + + if (illuminance < 0.0) { + roundedReading[0] = -AmbientLightSensorTraits.#ROUNDING_MULTIPLE * + Math.floor(-scaledValue + 0.5); + } else { + roundedReading[0] = AmbientLightSensorTraits.#ROUNDING_MULTIPLE * + Math.floor(scaledValue + 0.5); + } + + return roundedReading; + } + + // https://w3c.github.io/ambient-light/#ambient-light-threshold-check-algorithm + static areReadingsEqual([illuminance1], [illuminance2]) { + return illuminance1 === illuminance2; + } +} + +self.GenericSensorTest = (() => { + // Default sensor frequency in default configurations. + const DEFAULT_FREQUENCY = 5; + + // Class that mocks Sensor interface defined in + // https://cs.chromium.org/chromium/src/services/device/public/mojom/sensor.mojom + class MockSensor { + static #BUFFER_OFFSET_TIMESTAMP = 1; + static #BUFFER_OFFSET_READINGS = 2; + + constructor(sensorRequest, buffer, reportingMode, sensorType) { + this.client_ = null; + this.startShouldFail_ = false; + this.notifyOnReadingChange_ = true; + this.reportingMode_ = reportingMode; + this.sensorType_ = sensorType; + this.sensorReadingTimerId_ = null; + this.readingData_ = null; + this.requestedFrequencies_ = []; + // The Blink implementation (third_party/blink/renderer/modules/sensor/sensor.cc) + // sets a timestamp by creating a DOMHighResTimeStamp from a given platform timestamp. + // In this mock implementation we use a starting value + // and an increment step value that resemble a platform timestamp reasonably enough. + this.timestamp_ = window.performance.timeOrigin; + // |buffer| represents a SensorReadingSharedBuffer on the C++ side in + // Chromium. It consists, in this order, of a + // SensorReadingField<OneWriterSeqLock> (an 8-byte union that includes + // 32-bit integer used by the lock class), and a SensorReading consisting + // of an 8-byte timestamp and 4 8-byte reading fields. + // + // |this.buffer_[0]| is zeroed by default, which allows OneWriterSeqLock + // to work with our custom memory buffer that did not actually create a + // OneWriterSeqLock instance. It is never changed manually here. + // + // Use MockSensor.#BUFFER_OFFSET_TIMESTAMP and + // MockSensor.#BUFFER_OFFSET_READINGS to access the other positions in + // |this.buffer_| without having to hardcode magic numbers in the code. + this.buffer_ = buffer; + this.buffer_.fill(0); + this.receiver_ = new SensorReceiver(this); + this.receiver_.$.bindHandle(sensorRequest.handle); + this.lastRawReading_ = null; + this.lastRoundedReading_ = null; + + if (sensorType == SensorType.AMBIENT_LIGHT) { + this.sensorTraits = AmbientLightSensorTraits; + } else { + this.sensorTraits = DefaultSensorTraits; + } + } + + // Returns default configuration. + async getDefaultConfiguration() { + return { frequency: DEFAULT_FREQUENCY }; + } + + // Adds configuration for the sensor and starts reporting fake data + // through setSensorReading function. + async addConfiguration(configuration) { + this.requestedFrequencies_.push(configuration.frequency); + // Sort using descending order. + this.requestedFrequencies_.sort( + (first, second) => { return second - first }); + + if (!this.startShouldFail_ ) + this.startReading(); + + return { success: !this.startShouldFail_ }; + } + + // Removes sensor configuration from the list of active configurations and + // stops notification about sensor reading changes if + // requestedFrequencies_ is empty. + removeConfiguration(configuration) { + const index = this.requestedFrequencies_.indexOf(configuration.frequency); + if (index == -1) + return; + + this.requestedFrequencies_.splice(index, 1); + if (this.requestedFrequencies_.length === 0) + this.stopReading(); + } + + // ConfigureReadingChangeNotifications(bool enabled) + // Configures whether to report a reading change when in ON_CHANGE + // reporting mode. + configureReadingChangeNotifications(notifyOnReadingChange) { + this.notifyOnReadingChange_ = notifyOnReadingChange; + } + + resume() { + this.startReading(); + } + + suspend() { + this.stopReading(); + } + + // Mock functions + + // Resets mock Sensor state. + reset() { + this.stopReading(); + this.startShouldFail_ = false; + this.requestedFrequencies_ = []; + this.notifyOnReadingChange_ = true; + this.readingData_ = null; + this.buffer_.fill(0); + this.receiver_.$.close(); + this.lastRawReading_ = null; + this.lastRoundedReading_ = null; + } + + // Sets fake data that is used to deliver sensor reading updates. + setSensorReading(readingData) { + this.readingData_ = new RingBuffer(readingData); + } + + // This is a workaround to accommodate Blink's Device Orientation + // implementation. In general, all tests should use setSensorReading() + // instead. + setSensorReadingImmediately(readingData) { + this.setSensorReading(readingData); + + const reading = this.readingData_.value(); + this.buffer_.set(reading, MockSensor.#BUFFER_OFFSET_READINGS); + this.buffer_[MockSensor.#BUFFER_OFFSET_TIMESTAMP] = this.timestamp_++; + } + + // Sets flag that forces sensor to fail when addConfiguration is invoked. + setStartShouldFail(shouldFail) { + this.startShouldFail_ = shouldFail; + } + + startReading() { + if (this.readingData_ != null) { + this.stopReading(); + } + let maxFrequencyUsed = this.requestedFrequencies_[0]; + let timeout = (1 / maxFrequencyUsed) * 1000; + this.sensorReadingTimerId_ = window.setInterval(() => { + if (this.readingData_) { + // |buffer_| is a TypedArray, so we need to make sure pass an + // array to set(). + const reading = this.readingData_.next().value; + if (!Array.isArray(reading)) { + throw new TypeError("startReading(): The readings passed to " + + "setSensorReading() must be arrays"); + } + + if (this.reportingMode_ == ReportingMode.ON_CHANGE && + this.lastRawReading_ !== null && + !this.sensorTraits.isSignificantlyDifferent( + this.lastRawReading_, reading)) { + // In case new value is not significantly different compared to + // old value, new value is not sent. + return; + } + + this.lastRawReading_ = reading.slice(); + const roundedReading = this.sensorTraits.roundToMultiple(reading); + + if (this.reportingMode_ == ReportingMode.ON_CHANGE && + this.lastRoundedReading_ !== null && + this.sensorTraits.areReadingsEqual( + roundedReading, this.lastRoundedReading_)) { + // In case new rounded value is not different compared to old + // value, new value is not sent. + return; + } + this.buffer_.set(roundedReading, MockSensor.#BUFFER_OFFSET_READINGS); + this.lastRoundedReading_ = roundedReading; + } + + // For all tests sensor reading should have monotonically + // increasing timestamp. + this.buffer_[MockSensor.#BUFFER_OFFSET_TIMESTAMP] = this.timestamp_++; + + if (this.reportingMode_ === ReportingMode.ON_CHANGE && + this.notifyOnReadingChange_) { + this.client_.sensorReadingChanged(); + } + }, timeout); + } + + stopReading() { + if (this.sensorReadingTimerId_ != null) { + window.clearInterval(this.sensorReadingTimerId_); + this.sensorReadingTimerId_ = null; + } + } + + getSamplingFrequency() { + if (this.requestedFrequencies_.length == 0) { + throw new Error("getSamplingFrequency(): No configured frequency"); + } + return this.requestedFrequencies_[0]; + } + + isReadingData() { + return this.sensorReadingTimerId_ != null; + } + } + + // Class that mocks SensorProvider interface defined in + // https://cs.chromium.org/chromium/src/services/device/public/mojom/sensor_provider.mojom + class MockSensorProvider { + constructor() { + this.readingSizeInBytes_ = + Number(SensorInitParams_READ_BUFFER_SIZE_FOR_TESTS); + this.sharedBufferSizeInBytes_ = + this.readingSizeInBytes_ * (SensorType.MAX_VALUE + 1); + let rv = Mojo.createSharedBuffer(this.sharedBufferSizeInBytes_); + if (rv.result != Mojo.RESULT_OK) { + throw new Error('MockSensorProvider: Failed to create shared buffer'); + } + const handle = rv.handle; + rv = handle.mapBuffer(0, this.sharedBufferSizeInBytes_); + if (rv.result != Mojo.RESULT_OK) { + throw new Error("MockSensorProvider: Failed to map shared buffer"); + } + this.shmemArrayBuffer_ = rv.buffer; + rv = handle.duplicateBufferHandle({readOnly: true}); + if (rv.result != Mojo.RESULT_OK) { + throw new Error( + 'MockSensorProvider: failed to duplicate shared buffer'); + } + this.readOnlySharedBufferHandle_ = rv.handle; + this.activeSensors_ = new Map(); + this.resolveFuncs_ = new Map(); + this.getSensorShouldFail_ = new Map(); + this.permissionsDenied_ = new Map(); + this.maxFrequency_ = 60; + this.minFrequency_ = 1; + this.mojomSensorType_ = new Map([ + ['Accelerometer', SensorType.ACCELEROMETER], + ['LinearAccelerationSensor', SensorType.LINEAR_ACCELERATION], + ['GravitySensor', SensorType.GRAVITY], + ['AmbientLightSensor', SensorType.AMBIENT_LIGHT], + ['Gyroscope', SensorType.GYROSCOPE], + ['Magnetometer', SensorType.MAGNETOMETER], + ['AbsoluteOrientationSensor', + SensorType.ABSOLUTE_ORIENTATION_QUATERNION], + ['AbsoluteOrientationEulerAngles', + SensorType.ABSOLUTE_ORIENTATION_EULER_ANGLES], + ['RelativeOrientationSensor', + SensorType.RELATIVE_ORIENTATION_QUATERNION], + ['RelativeOrientationEulerAngles', + SensorType.RELATIVE_ORIENTATION_EULER_ANGLES], + ['ProximitySensor', SensorType.PROXIMITY] + ]); + this.receiver_ = new SensorProviderReceiver(this); + + this.interceptor_ = + new MojoInterfaceInterceptor(SensorProvider.$interfaceName); + this.interceptor_.oninterfacerequest = e => { + this.bindToPipe(e.handle); + }; + this.interceptor_.start(); + } + + // Returns initialized Sensor proxy to the client. + async getSensor(type) { + if (this.getSensorShouldFail_.get(type)) { + return {result: SensorCreationResult.ERROR_NOT_AVAILABLE, + initParams: null}; + } + if (this.permissionsDenied_.get(type)) { + return {result: SensorCreationResult.ERROR_NOT_ALLOWED, + initParams: null}; + } + + const offset = type * this.readingSizeInBytes_; + const reportingMode = ReportingMode.ON_CHANGE; + + const sensor = new SensorRemote(); + if (!this.activeSensors_.has(type)) { + const shmemView = new Float64Array( + this.shmemArrayBuffer_, offset, + this.readingSizeInBytes_ / Float64Array.BYTES_PER_ELEMENT); + const mockSensor = new MockSensor( + sensor.$.bindNewPipeAndPassReceiver(), shmemView, reportingMode, + type); + this.activeSensors_.set(type, mockSensor); + this.activeSensors_.get(type).client_ = new SensorClientRemote(); + } + + const rv = this.readOnlySharedBufferHandle_.duplicateBufferHandle( + {readOnly: true}); + if (rv.result != Mojo.RESULT_OK) { + throw new Error('getSensor(): failed to duplicate shared buffer'); + } + + const defaultConfig = { frequency: DEFAULT_FREQUENCY }; + // Consider sensor traits to meet assertions in C++ code (see + // services/device/public/cpp/generic_sensor/sensor_traits.h) + if (type == SensorType.AMBIENT_LIGHT || type == SensorType.MAGNETOMETER) { + this.maxFrequency_ = Math.min(10, this.maxFrequency_); + } + + const client = this.activeSensors_.get(type).client_; + const initParams = { + sensor, + clientReceiver: client.$.bindNewPipeAndPassReceiver(), + memory: {buffer: rv.handle}, + bufferOffset: BigInt(offset), + mode: reportingMode, + defaultConfiguration: defaultConfig, + minimumFrequency: this.minFrequency_, + maximumFrequency: this.maxFrequency_ + }; + + if (this.resolveFuncs_.has(type)) { + for (let resolveFunc of this.resolveFuncs_.get(type)) { + resolveFunc(this.activeSensors_.get(type)); + } + this.resolveFuncs_.delete(type); + } + + return {result: SensorCreationResult.SUCCESS, initParams}; + } + + // Binds object to mojo message pipe + bindToPipe(pipe) { + this.receiver_.$.bindHandle(pipe); + } + + // Mock functions + + // Resets state of mock SensorProvider between test runs. + reset() { + for (const sensor of this.activeSensors_.values()) { + sensor.reset(); + } + this.activeSensors_.clear(); + this.resolveFuncs_.clear(); + this.getSensorShouldFail_.clear(); + this.permissionsDenied_.clear(); + this.maxFrequency_ = 60; + this.minFrequency_ = 1; + this.receiver_.$.close(); + this.interceptor_.stop(); + } + + // Sets flag that forces mock SensorProvider to fail when getSensor() is + // invoked. + setGetSensorShouldFail(sensorType, shouldFail) { + this.getSensorShouldFail_.set(this.mojomSensorType_.get(sensorType), + shouldFail); + } + + setPermissionsDenied(sensorType, permissionsDenied) { + this.permissionsDenied_.set(this.mojomSensorType_.get(sensorType), + permissionsDenied); + } + + // Returns mock sensor that was created in getSensor to the layout test. + getCreatedSensor(sensorType) { + const type = this.mojomSensorType_.get(sensorType); + if (typeof type != "number") { + throw new TypeError(`getCreatedSensor(): Invalid sensor type ${sensorType}`); + } + + if (this.activeSensors_.has(type)) { + return Promise.resolve(this.activeSensors_.get(type)); + } + + return new Promise(resolve => { + if (!this.resolveFuncs_.has(type)) { + this.resolveFuncs_.set(type, []); + } + this.resolveFuncs_.get(type).push(resolve); + }); + } + + // Sets the maximum frequency for a concrete sensor. + setMaximumSupportedFrequency(frequency) { + this.maxFrequency_ = frequency; + } + + // Sets the minimum frequency for a concrete sensor. + setMinimumSupportedFrequency(frequency) { + this.minFrequency_ = frequency; + } + } + + let testInternal = { + initialized: false, + sensorProvider: null + } + + class GenericSensorTestChromium { + constructor() { + Object.freeze(this); // Make it immutable. + } + + async initialize() { + if (testInternal.initialized) + throw new Error('Call reset() before initialize().'); + + // Grant sensor permissions for Chromium testdriver. + // testdriver.js only works in the top-level browsing context, so do + // nothing if we're in e.g. an iframe. + if (window.parent === window) { + for (const entry + of ['accelerometer', 'gyroscope', 'magnetometer', + 'ambient-light-sensor']) { + await test_driver.set_permission({name: entry}, 'granted'); + } + } + + testInternal.sensorProvider = new MockSensorProvider; + testInternal.initialized = true; + } + // Resets state of sensor mocks between test runs. + async reset() { + if (!testInternal.initialized) + throw new Error('Call initialize() before reset().'); + testInternal.sensorProvider.reset(); + testInternal.sensorProvider = null; + testInternal.initialized = false; + + // Wait for an event loop iteration to let any pending mojo commands in + // the sensor provider finish. + await new Promise(resolve => setTimeout(resolve, 0)); + } + + getSensorProvider() { + return testInternal.sensorProvider; + } + } + + return GenericSensorTestChromium; +})(); diff --git a/test/wpt/tests/resources/chromium/generic_sensor_mocks.js.headers b/test/wpt/tests/resources/chromium/generic_sensor_mocks.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/resources/chromium/generic_sensor_mocks.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/wpt/tests/resources/chromium/mock-barcodedetection.js b/test/wpt/tests/resources/chromium/mock-barcodedetection.js new file mode 100644 index 0000000..b0d2e0a --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-barcodedetection.js @@ -0,0 +1,136 @@ +import {BarcodeDetectionReceiver, BarcodeFormat} from '/gen/services/shape_detection/public/mojom/barcodedetection.mojom.m.js'; +import {BarcodeDetectionProvider, BarcodeDetectionProviderReceiver} from '/gen/services/shape_detection/public/mojom/barcodedetection_provider.mojom.m.js'; + +self.BarcodeDetectionTest = (() => { + // Class that mocks BarcodeDetectionProvider interface defined in + // https://cs.chromium.org/chromium/src/services/shape_detection/public/mojom/barcodedetection_provider.mojom + class MockBarcodeDetectionProvider { + constructor() { + this.receiver_ = new BarcodeDetectionProviderReceiver(this); + + this.interceptor_ = new MojoInterfaceInterceptor( + BarcodeDetectionProvider.$interfaceName); + this.interceptor_.oninterfacerequest = e => { + if (this.should_close_pipe_on_request_) + e.handle.close(); + else + this.receiver_.$.bindHandle(e.handle); + } + this.interceptor_.start(); + this.should_close_pipe_on_request_ = false; + } + + createBarcodeDetection(request, options) { + this.mockService_ = new MockBarcodeDetection(request, options); + } + + enumerateSupportedFormats() { + return { + supportedFormats: [ + BarcodeFormat.AZTEC, + BarcodeFormat.DATA_MATRIX, + BarcodeFormat.QR_CODE, + ] + }; + } + + getFrameData() { + return this.mockService_.bufferData_; + } + + getFormats() { + return this.mockService_.options_.formats; + } + + reset() { + this.mockService_ = null; + this.should_close_pipe_on_request_ = false; + this.receiver_.$.close(); + this.interceptor_.stop(); + } + + // simulate a 'no implementation available' case + simulateNoImplementation() { + this.should_close_pipe_on_request_ = true; + } + } + + // Class that mocks BarcodeDetection interface defined in + // https://cs.chromium.org/chromium/src/services/shape_detection/public/mojom/barcodedetection.mojom + class MockBarcodeDetection { + constructor(request, options) { + this.options_ = options; + this.receiver_ = new BarcodeDetectionReceiver(this); + this.receiver_.$.bindHandle(request.handle); + } + + detect(bitmapData) { + this.bufferData_ = + new Uint32Array(getArrayBufferFromBigBuffer(bitmapData.pixelData)); + return { + results: [ + { + rawValue : "cats", + boundingBox: { x: 1.0, y: 1.0, width: 100.0, height: 100.0 }, + format: BarcodeFormat.QR_CODE, + cornerPoints: [ + { x: 1.0, y: 1.0 }, + { x: 101.0, y: 1.0 }, + { x: 101.0, y: 101.0 }, + { x: 1.0, y: 101.0 } + ], + }, + { + rawValue : "dogs", + boundingBox: { x: 2.0, y: 2.0, width: 50.0, height: 50.0 }, + format: BarcodeFormat.CODE_128, + cornerPoints: [ + { x: 2.0, y: 2.0 }, + { x: 52.0, y: 2.0 }, + { x: 52.0, y: 52.0 }, + { x: 2.0, y: 52.0 } + ], + }, + ], + }; + } + } + + let testInternal = { + initialized: false, + MockBarcodeDetectionProvider: null + } + + class BarcodeDetectionTestChromium { + constructor() { + Object.freeze(this); // Make it immutable. + } + + initialize() { + if (testInternal.initialized) + throw new Error('Call reset() before initialize().'); + + testInternal.MockBarcodeDetectionProvider = new MockBarcodeDetectionProvider; + testInternal.initialized = true; + } + + // Resets state of barcode detection mocks between test runs. + async reset() { + if (!testInternal.initialized) + throw new Error('Call initialize() before reset().'); + testInternal.MockBarcodeDetectionProvider.reset(); + testInternal.MockBarcodeDetectionProvider = null; + testInternal.initialized = false; + + await new Promise(resolve => setTimeout(resolve, 0)); + } + + MockBarcodeDetectionProvider() { + return testInternal.MockBarcodeDetectionProvider; + } + } + + return BarcodeDetectionTestChromium; +})(); + +self.BarcodeFormat = BarcodeFormat; diff --git a/test/wpt/tests/resources/chromium/mock-barcodedetection.js.headers b/test/wpt/tests/resources/chromium/mock-barcodedetection.js.headers new file mode 100644 index 0000000..6c61a34 --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-barcodedetection.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8
\ No newline at end of file diff --git a/test/wpt/tests/resources/chromium/mock-battery-monitor.headers b/test/wpt/tests/resources/chromium/mock-battery-monitor.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-battery-monitor.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/wpt/tests/resources/chromium/mock-battery-monitor.js b/test/wpt/tests/resources/chromium/mock-battery-monitor.js new file mode 100644 index 0000000..8fa27bc --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-battery-monitor.js @@ -0,0 +1,61 @@ +import {BatteryMonitor, BatteryMonitorReceiver} from '/gen/services/device/public/mojom/battery_monitor.mojom.m.js'; + +class MockBatteryMonitor { + constructor() { + this.receiver_ = new BatteryMonitorReceiver(this); + this.interceptor_ = + new MojoInterfaceInterceptor(BatteryMonitor.$interfaceName); + this.interceptor_.oninterfacerequest = e => + this.receiver_.$.bindHandle(e.handle); + this.reset(); + } + + start() { + this.interceptor_.start(); + } + + stop() { + this.interceptor_.stop(); + } + + reset() { + this.pendingRequests_ = []; + this.status_ = null; + this.lastKnownStatus_ = null; + } + + queryNextStatus() { + const result = new Promise(resolve => this.pendingRequests_.push(resolve)); + this.runCallbacks_(); + return result; + } + + setBatteryStatus(charging, chargingTime, dischargingTime, level) { + this.status_ = {charging, chargingTime, dischargingTime, level}; + this.lastKnownStatus_ = this.status_; + this.runCallbacks_(); + } + + verifyBatteryStatus(manager) { + assert_not_equals(manager, undefined); + assert_not_equals(this.lastKnownStatus_, null); + assert_equals(manager.charging, this.lastKnownStatus_.charging); + assert_equals(manager.chargingTime, this.lastKnownStatus_.chargingTime); + assert_equals( + manager.dischargingTime, this.lastKnownStatus_.dischargingTime); + assert_equals(manager.level, this.lastKnownStatus_.level); + } + + runCallbacks_() { + if (!this.status_ || !this.pendingRequests_.length) + return; + + let result = {status: this.status_}; + while (this.pendingRequests_.length) { + this.pendingRequests_.pop()(result); + } + this.status_ = null; + } +} + +export const mockBatteryMonitor = new MockBatteryMonitor(); diff --git a/test/wpt/tests/resources/chromium/mock-direct-sockets.js b/test/wpt/tests/resources/chromium/mock-direct-sockets.js new file mode 100644 index 0000000..6d557f7 --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-direct-sockets.js @@ -0,0 +1,94 @@ +'use strict'; + +import {DirectSocketsService, DirectSocketsServiceReceiver} from '/gen/third_party/blink/public/mojom/direct_sockets/direct_sockets.mojom.m.js'; + +self.DirectSocketsServiceTest = (() => { + // Class that mocks DirectSocketsService interface defined in + // https://source.chromium.org/chromium/chromium/src/third_party/blink/public/mojom/direct_sockets/direct_sockets.mojom + class MockDirectSocketsService { + constructor() { + this.interceptor_ = new MojoInterfaceInterceptor(DirectSocketsService.$interfaceName); + this.receiver_ = new DirectSocketsServiceReceiver(this); + this.interceptor_.oninterfacerequest = e => + this.receiver_.$.bindHandle(e.handle); + this.interceptor_.start(); + } + + reset() { + this.receiver_.$.close(); + this.interceptor_.stop(); + } + + openTCPSocket( + options, + receiver, + observer) { + return Promise.resolve({ + // return result = net:Error::NOT_IMPLEMENTED (code -11) + result: -11 + }); + } + + openConnectedUDPSocket( + options, + receiver, + listener) { + return Promise.resolve({ + // return result = net:Error::NOT_IMPLEMENTED (code -11) + result: -11 + }); + } + + openBoundUDPSocket( + options, + receiver, + listener) { + return Promise.resolve({ + // return result = net:Error::NOT_IMPLEMENTED (code -11) + result: -11 + }); + } + + openTCPServerSocket( + options, + receiver) { + return Promise.resolve({ + // return result = net:Error::NOT_IMPLEMENTED (code -11) + result: -11 + }); + } + } + + let testInternal = { + initialized: false, + mockDirectSocketsService: null + } + + class DirectSocketsServiceTestChromium { + constructor() { + Object.freeze(this); // Make it immutable. + } + + initialize() { + if (!testInternal.initialized) { + testInternal = { + mockDirectSocketsService: new MockDirectSocketsService(), + initialized: true + }; + } + } + + async reset() { + if (testInternal.initialized) { + testInternal.mockDirectSocketsService.reset(); + testInternal = { + mockDirectSocketsService: null, + initialized: false + }; + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + } + + return DirectSocketsServiceTestChromium; +})(); diff --git a/test/wpt/tests/resources/chromium/mock-facedetection.js b/test/wpt/tests/resources/chromium/mock-facedetection.js new file mode 100644 index 0000000..7ae6586 --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-facedetection.js @@ -0,0 +1,130 @@ +import {FaceDetectionReceiver, LandmarkType} from '/gen/services/shape_detection/public/mojom/facedetection.mojom.m.js'; +import {FaceDetectionProvider, FaceDetectionProviderReceiver} from '/gen/services/shape_detection/public/mojom/facedetection_provider.mojom.m.js'; + +self.FaceDetectionTest = (() => { + // Class that mocks FaceDetectionProvider interface defined in + // https://cs.chromium.org/chromium/src/services/shape_detection/public/mojom/facedetection_provider.mojom + class MockFaceDetectionProvider { + constructor() { + this.receiver_ = new FaceDetectionProviderReceiver(this); + + this.interceptor_ = new MojoInterfaceInterceptor( + FaceDetectionProvider.$interfaceName); + this.interceptor_.oninterfacerequest = + e => this.receiver_.$.bindHandle(e.handle); + this.interceptor_.start(); + } + + createFaceDetection(request, options) { + this.mockService_ = new MockFaceDetection(request, options); + } + + getFrameData() { + return this.mockService_.bufferData_; + } + + getMaxDetectedFaces() { + return this.mockService_.maxDetectedFaces_; + } + + getFastMode () { + return this.mockService_.fastMode_; + } + + reset() { + this.mockService_ = null; + this.receiver_.$.close(); + this.interceptor_.stop(); + } + } + + // Class that mocks FaceDetection interface defined in + // https://cs.chromium.org/chromium/src/services/shape_detection/public/mojom/facedetection.mojom + class MockFaceDetection { + constructor(request, options) { + this.maxDetectedFaces_ = options.maxDetectedFaces; + this.fastMode_ = options.fastMode; + this.receiver_ = new FaceDetectionReceiver(this); + this.receiver_.$.bindHandle(request.handle); + } + + detect(bitmapData) { + this.bufferData_ = + new Uint32Array(getArrayBufferFromBigBuffer(bitmapData.pixelData)); + return Promise.resolve({ + results: [ + { + boundingBox: {x: 1.0, y: 1.0, width: 100.0, height: 100.0}, + landmarks: [{ + type: LandmarkType.EYE, + locations: [{x: 4.0, y: 5.0}] + }, + { + type: LandmarkType.EYE, + locations: [ + {x: 4.0, y: 5.0}, {x: 5.0, y: 4.0}, {x: 6.0, y: 3.0}, + {x: 7.0, y: 4.0}, {x: 8.0, y: 5.0}, {x: 7.0, y: 6.0}, + {x: 6.0, y: 7.0}, {x: 5.0, y: 6.0} + ] + }] + }, + { + boundingBox: {x: 2.0, y: 2.0, width: 200.0, height: 200.0}, + landmarks: [{ + type: LandmarkType.NOSE, + locations: [{x: 100.0, y: 50.0}] + }, + { + type: LandmarkType.NOSE, + locations: [ + {x: 80.0, y: 50.0}, {x: 70.0, y: 60.0}, {x: 60.0, y: 70.0}, + {x: 70.0, y: 60.0}, {x: 80.0, y: 70.0}, {x: 90.0, y: 80.0}, + {x: 100.0, y: 70.0}, {x: 90.0, y: 60.0}, {x: 80.0, y: 50.0} + ] + }] + }, + { + boundingBox: {x: 3.0, y: 3.0, width: 300.0, height: 300.0}, + landmarks: [] + }, + ] + }); + } + } + + let testInternal = { + initialized: false, + MockFaceDetectionProvider: null + } + + class FaceDetectionTestChromium { + constructor() { + Object.freeze(this); // Make it immutable. + } + + initialize() { + if (testInternal.initialized) + throw new Error('Call reset() before initialize().'); + + testInternal.MockFaceDetectionProvider = new MockFaceDetectionProvider; + testInternal.initialized = true; + } + + // Resets state of face detection mocks between test runs. + async reset() { + if (!testInternal.initialized) + throw new Error('Call initialize() before reset().'); + testInternal.MockFaceDetectionProvider.reset(); + testInternal.MockFaceDetectionProvider = null; + testInternal.initialized = false; + + await new Promise(resolve => setTimeout(resolve, 0)); + } + + MockFaceDetectionProvider() { + return testInternal.MockFaceDetectionProvider; + } + } + + return FaceDetectionTestChromium; +})(); diff --git a/test/wpt/tests/resources/chromium/mock-facedetection.js.headers b/test/wpt/tests/resources/chromium/mock-facedetection.js.headers new file mode 100644 index 0000000..6c61a34 --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-facedetection.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8
\ No newline at end of file diff --git a/test/wpt/tests/resources/chromium/mock-idle-detection.js b/test/wpt/tests/resources/chromium/mock-idle-detection.js new file mode 100644 index 0000000..54fe5dd --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-idle-detection.js @@ -0,0 +1,80 @@ +import {IdleManager, IdleManagerError, IdleManagerReceiver} from '/gen/third_party/blink/public/mojom/idle/idle_manager.mojom.m.js'; + +/** + * This is a testing framework that enables us to test the user idle detection + * by intercepting the connection between the renderer and the browser and + * exposing a mocking API for tests. + * + * Usage: + * + * 1) Include <script src="mock.js"></script> in your file. + * 2) Set expectations + * expect(addMonitor).andReturn((threshold, monitorPtr, callback) => { + * // mock behavior + * }) + * 3) Call navigator.idle.query() + * + * The mocking API is blink agnostic and is designed such that other engines + * could implement it too. Here are the symbols that are exposed to tests: + * + * - function addMonitor(): the main/only function that can be mocked. + * - function expect(): the main/only function that enables us to mock it. + * - function close(): disconnects the interceptor. + * - enum UserIdleState {IDLE, ACTIVE}: blink agnostic constants. + * - enum ScreenIdleState {LOCKED, UNLOCKED}: blink agnostic constants. + */ + +class FakeIdleMonitor { + addMonitor(threshold, monitorPtr, callback) { + return this.handler.addMonitor(threshold, monitorPtr); + } + setHandler(handler) { + this.handler = handler; + return this; + } + setBinding(binding) { + this.binding = binding; + return this; + } + close() { + this.binding.$.close(); + } +} + +self.IdleDetectorError = {}; + +self.addMonitor = function addMonitor(threshold, monitorPtr, callback) { + throw new Error("expected to be overriden by tests"); +} + +async function close() { + interceptor.close(); +} + +self.expect = function(call) { + return { + andReturn(callback) { + let handler = {}; + handler[call.name] = callback; + interceptor.setHandler(handler); + } + }; +}; + +function intercept() { + let result = new FakeIdleMonitor(); + + let binding = new IdleManagerReceiver(result); + let interceptor = new MojoInterfaceInterceptor(IdleManager.$interfaceName); + interceptor.oninterfacerequest = e => binding.$.bindHandle(e.handle); + interceptor.start(); + + self.IdleDetectorError.SUCCESS = IdleManagerError.kSuccess; + self.IdleDetectorError.PERMISSION_DISABLED = + IdleManagerError.kPermissionDisabled; + + result.setBinding(binding); + return result; +} + +const interceptor = intercept(); diff --git a/test/wpt/tests/resources/chromium/mock-imagecapture.js b/test/wpt/tests/resources/chromium/mock-imagecapture.js new file mode 100644 index 0000000..8424e1e --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-imagecapture.js @@ -0,0 +1,309 @@ +import {BackgroundBlurMode, FillLightMode, ImageCapture, ImageCaptureReceiver, MeteringMode, RedEyeReduction} from '/gen/media/capture/mojom/image_capture.mojom.m.js'; + +self.ImageCaptureTest = (() => { + // Class that mocks ImageCapture interface defined in + // https://cs.chromium.org/chromium/src/media/capture/mojom/image_capture.mojom + class MockImageCapture { + constructor() { + this.interceptor_ = + new MojoInterfaceInterceptor(ImageCapture.$interfaceName); + this.interceptor_.oninterfacerequest = + e => this.receiver_.$.bindHandle(e.handle); + this.interceptor_.start(); + + this.state_ = { + state: { + supportedWhiteBalanceModes: [ + MeteringMode.SINGLE_SHOT, + MeteringMode.CONTINUOUS + ], + currentWhiteBalanceMode: MeteringMode.CONTINUOUS, + supportedExposureModes: [ + MeteringMode.MANUAL, + MeteringMode.SINGLE_SHOT, + MeteringMode.CONTINUOUS + ], + currentExposureMode: MeteringMode.MANUAL, + supportedFocusModes: [ + MeteringMode.MANUAL, + MeteringMode.SINGLE_SHOT + ], + currentFocusMode: MeteringMode.MANUAL, + pointsOfInterest: [{ + x: 0.4, + y: 0.6 + }], + + exposureCompensation: { + min: -200.0, + max: 200.0, + current: 33.0, + step: 33.0 + }, + exposureTime: { + min: 100.0, + max: 100000.0, + current: 1000.0, + step: 100.0 + }, + colorTemperature: { + min: 2500.0, + max: 6500.0, + current: 6000.0, + step: 1000.0 + }, + iso: { + min: 100.0, + max: 12000.0, + current: 400.0, + step: 1.0 + }, + + brightness: { + min: 1.0, + max: 10.0, + current: 5.0, + step: 1.0 + }, + contrast: { + min: 2.0, + max: 9.0, + current: 5.0, + step: 1.0 + }, + saturation: { + min: 3.0, + max: 8.0, + current: 6.0, + step: 1.0 + }, + sharpness: { + min: 4.0, + max: 7.0, + current: 7.0, + step: 1.0 + }, + + focusDistance: { + min: 1.0, + max: 10.0, + current: 3.0, + step: 1.0 + }, + + pan: { + min: 0.0, + max: 10.0, + current: 5.0, + step: 2.0 + }, + + tilt: { + min: 0.0, + max: 10.0, + current: 5.0, + step: 2.0 + }, + + zoom: { + min: 0.0, + max: 10.0, + current: 5.0, + step: 5.0 + }, + + supportsTorch: true, + torch: false, + + redEyeReduction: RedEyeReduction.CONTROLLABLE, + height: { + min: 240.0, + max: 2448.0, + current: 240.0, + step: 2.0 + }, + width: { + min: 320.0, + max: 3264.0, + current: 320.0, + step: 3.0 + }, + fillLightMode: [FillLightMode.AUTO, FillLightMode.FLASH], + + supportedBackgroundBlurModes: [ + BackgroundBlurMode.OFF, + BackgroundBlurMode.BLUR + ], + backgroundBlurMode: BackgroundBlurMode.OFF, + } + }; + this.panTiltZoomPermissionStatus_ = null; + this.settings_ = null; + this.receiver_ = new ImageCaptureReceiver(this); + } + + reset() { + this.receiver_.$.close(); + this.interceptor_.stop(); + } + + async getPhotoState(source_id) { + const shouldKeepPanTiltZoom = await this.isPanTiltZoomPermissionGranted(); + if (shouldKeepPanTiltZoom) + return Promise.resolve(this.state_); + + const newState = {...this.state_}; + newState.state.pan = {}; + newState.state.tilt = {}; + newState.state.zoom = {}; + return Promise.resolve(newState); + } + + async setPhotoOptions(source_id, settings) { + const isAllowedToControlPanTiltZoom = await this.isPanTiltZoomPermissionGranted(); + if (!isAllowedToControlPanTiltZoom && + (settings.hasPan || settings.hasTilt || settings.hasZoom)) { + return Promise.resolve({ success: false }); + } + this.settings_ = settings; + if (settings.hasIso) + this.state_.state.iso.current = settings.iso; + if (settings.hasHeight) + this.state_.state.height.current = settings.height; + if (settings.hasWidth) + this.state_.state.width.current = settings.width; + if (settings.hasPan) + this.state_.state.pan.current = settings.pan; + if (settings.hasTilt) + this.state_.state.tilt.current = settings.tilt; + if (settings.hasZoom) + this.state_.state.zoom.current = settings.zoom; + if (settings.hasFocusMode) + this.state_.state.currentFocusMode = settings.focusMode; + if (settings.hasFocusDistance) + this.state_.state.focusDistance.current = settings.focusDistance; + + if (settings.pointsOfInterest.length > 0) { + this.state_.state.pointsOfInterest = + settings.pointsOfInterest; + } + + if (settings.hasExposureMode) + this.state_.state.currentExposureMode = settings.exposureMode; + + if (settings.hasExposureCompensation) { + this.state_.state.exposureCompensation.current = + settings.exposureCompensation; + } + if (settings.hasExposureTime) { + this.state_.state.exposureTime.current = + settings.exposureTime; + } + if (settings.hasWhiteBalanceMode) { + this.state_.state.currentWhiteBalanceMode = + settings.whiteBalanceMode; + } + if (settings.hasFillLightMode) + this.state_.state.fillLightMode = [settings.fillLightMode]; + if (settings.hasRedEyeReduction) + this.state_.state.redEyeReduction = settings.redEyeReduction; + if (settings.hasColorTemperature) { + this.state_.state.colorTemperature.current = + settings.colorTemperature; + } + if (settings.hasBrightness) + this.state_.state.brightness.current = settings.brightness; + if (settings.hasContrast) + this.state_.state.contrast.current = settings.contrast; + if (settings.hasSaturation) + this.state_.state.saturation.current = settings.saturation; + if (settings.hasSharpness) + this.state_.state.sharpness.current = settings.sharpness; + + if (settings.hasTorch) + this.state_.state.torch = settings.torch; + + if (settings.hasBackgroundBlurMode) + this.state_.state.backgroundBlurMode = [settings.backgroundBlurMode]; + + return Promise.resolve({ + success: true + }); + } + + takePhoto(source_id) { + return Promise.resolve({ + blob: { + mimeType: 'image/cat', + data: new Array(2) + } + }); + } + + async isPanTiltZoomPermissionGranted() { + if (!this.panTiltZoomPermissionStatus_) { + this.panTiltZoomPermissionStatus_ = await navigator.permissions.query({ + name: "camera", + panTiltZoom: true + }); + } + return this.panTiltZoomPermissionStatus_.state == "granted"; + } + + state() { + return this.state_.state; + } + + turnOffBackgroundBlurMode() { + this.state_.state.backgroundBlurMode = BackgroundBlurMode.OFF; + } + turnOnBackgroundBlurMode() { + this.state_.state.backgroundBlurMode = BackgroundBlurMode.BLUR; + } + turnOffSupportedBackgroundBlurModes() { + this.state_.state.supportedBackgroundBlurModes = [BackgroundBlurMode.OFF]; + } + turnOnSupportedBackgroundBlurModes() { + this.state_.state.supportedBackgroundBlurModes = [BackgroundBlurMode.BLUR]; + } + + options() { + return this.settings_; + } + } + + let testInternal = { + initialized: false, + mockImageCapture: null + } + + class ImageCaptureTestChromium { + + constructor() { + Object.freeze(this); // Make it immutable. + } + + initialize() { + if (testInternal.initialized) + throw new Error('Call reset() before initialize().'); + + testInternal.mockImageCapture = new MockImageCapture; + testInternal.initialized = true; + } + // Resets state of image capture mocks between test runs. + async reset() { + if (!testInternal.initialized) + throw new Error('Call initialize() before reset().'); + testInternal.mockImageCapture.reset(); + testInternal.mockImageCapture = null; + testInternal.initialized = false; + + await new Promise(resolve => setTimeout(resolve, 0)); + } + mockImageCapture() { + return testInternal.mockImageCapture; + } + } + + return ImageCaptureTestChromium; +})(); diff --git a/test/wpt/tests/resources/chromium/mock-managed-config.js b/test/wpt/tests/resources/chromium/mock-managed-config.js new file mode 100644 index 0000000..c9980e1 --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-managed-config.js @@ -0,0 +1,91 @@ +'use strict' + +import{ManagedConfigurationObserverRemote, ManagedConfigurationService, ManagedConfigurationServiceReceiver} from '/gen/third_party/blink/public/mojom/device/device.mojom.m.js'; + + +self.ManagedConfigTest = (() => { + // Class that mocks ManagedConfigurationService interface defined in + // https://source.chromium.org/chromium/chromium/src/third_party/blink/public/mojom/device/device.mojom + class MockManagedConfig { + constructor() { + this.receiver_ = new ManagedConfigurationServiceReceiver(this); + this.interceptor_ = new MojoInterfaceInterceptor( + ManagedConfigurationService.$interfaceName); + this.interceptor_.oninterfacerequest = e => + this.receiver_.$.bindHandle(e.handle); + this.interceptor_.start(); + this.subscription_ = null; + this.reset(); + } + + reset() { + this.configuration_ = null; + this.onObserverAdd_ = null; + } + + async getManagedConfiguration(keys) { + if (this.configuration_ === null) { + return {}; + } + + return { + configurations: Object.keys(this.configuration_) + .filter(key => keys.includes(key)) + .reduce( + (obj, key) => { + obj[key] = + JSON.stringify(this.configuration_[key]); + return obj; + }, + {}) + }; + } + + subscribeToManagedConfiguration(remote) { + this.subscription_ = remote; + if (this.onObserverAdd_ !== null) { + this.onObserverAdd_(); + } + } + + setManagedConfig(value) { + this.configuration_ = value; + if (this.subscription_ !== null) { + this.subscription_.onConfigurationChanged(); + } + } + } + + let testInternal = { + initialized: false, + mockManagedConfig: null + } + + class ManagedConfigTestChromium { + constructor() { + Object.freeze(this); // Make it immutable. + } + + initialize() { + if (testInternal.mockManagedConfig !== null) { + testInternal.mockManagedConfig.reset(); + return; + } + + testInternal.mockManagedConfig = new MockManagedConfig; + testInternal.initialized = true; + } + + setManagedConfig(config) { + testInternal.mockManagedConfig.setManagedConfig(config); + } + + async nextObserverAdded() { + await new Promise(resolve => { + testInternal.mockManagedConfig.onObserverAdd_ = resolve; + }); + } + } + + return ManagedConfigTestChromium; +})(); diff --git a/test/wpt/tests/resources/chromium/mock-pressure-service.js b/test/wpt/tests/resources/chromium/mock-pressure-service.js new file mode 100644 index 0000000..02d10f8 --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-pressure-service.js @@ -0,0 +1,134 @@ +import {PressureManager, PressureManagerReceiver, PressureStatus} from '/gen/services/device/public/mojom/pressure_manager.mojom.m.js' +import {PressureSource, PressureState} from '/gen/services/device/public/mojom/pressure_update.mojom.m.js' + +class MockPressureService { + constructor() { + this.receiver_ = new PressureManagerReceiver(this); + this.interceptor_ = + new MojoInterfaceInterceptor(PressureManager.$interfaceName); + this.interceptor_.oninterfacerequest = e => { + this.receiver_.$.bindHandle(e.handle); + }; + this.reset(); + this.mojomSourceType_ = new Map([['cpu', PressureSource.kCpu]]); + this.mojomStateType_ = new Map([ + ['nominal', PressureState.kNominal], ['fair', PressureState.kFair], + ['serious', PressureState.kSerious], ['critical', PressureState.kCritical] + ]); + this.pressureServiceReadingTimerId_ = null; + } + + start() { + this.interceptor_.start(); + } + + stop() { + this.stopPlatformCollector(); + this.receiver_.$.close(); + this.interceptor_.stop(); + + // Wait for an event loop iteration to let any pending mojo commands in + // the pressure service finish. + return new Promise(resolve => setTimeout(resolve, 0)); + } + + reset() { + this.observers_ = []; + this.pressureUpdate_ = null; + this.pressureServiceReadingTimerId_ = null; + this.pressureStatus_ = PressureStatus.kOk; + this.updatesDelivered_ = 0; + } + + async addClient(observer, source) { + if (this.observers_.indexOf(observer) >= 0) + throw new Error('addClient() has already been called'); + + // TODO(crbug.com/1342184): Consider other sources. + // For now, "cpu" is the only source. + if (source !== PressureSource.kCpu) + throw new Error('Call addClient() with a wrong PressureSource'); + + observer.onConnectionError.addListener(() => { + // Remove this observer from observer array. + this.observers_.splice(this.observers_.indexOf(observer), 1); + }); + this.observers_.push(observer); + + return {status: this.pressureStatus_}; + } + + startPlatformCollector(sampleRate) { + if (sampleRate === 0) + return; + + if (this.pressureServiceReadingTimerId_ != null) + this.stopPlatformCollector(); + + // The following code for calculating the timestamp was taken from + // https://source.chromium.org/chromium/chromium/src/+/main:third_party/ + // blink/web_tests/http/tests/resources/ + // geolocation-mock.js;l=131;drc=37a9b6c03b9bda9fcd62fc0e5e8016c278abd31f + + // The new Date().getTime() returns the number of milliseconds since the + // UNIX epoch (1970-01-01 00::00:00 UTC), while |internalValue| of the + // device.mojom.PressureUpdate represents the value of microseconds since + // the Windows FILETIME epoch (1601-01-01 00:00:00 UTC). So add the delta + // when sets the |internalValue|. See more info in //base/time/time.h. + const windowsEpoch = Date.UTC(1601, 0, 1, 0, 0, 0, 0); + const unixEpoch = Date.UTC(1970, 0, 1, 0, 0, 0, 0); + // |epochDeltaInMs| equals to base::Time::kTimeTToMicrosecondsOffset. + const epochDeltaInMs = unixEpoch - windowsEpoch; + + const timeout = (1 / sampleRate) * 1000; + this.pressureServiceReadingTimerId_ = self.setInterval(() => { + if (this.pressureUpdate_ === null || this.observers_.length === 0) + return; + this.pressureUpdate_.timestamp = { + internalValue: BigInt((new Date().getTime() + epochDeltaInMs) * 1000) + }; + for (let observer of this.observers_) + observer.onPressureUpdated(this.pressureUpdate_); + this.updatesDelivered_++; + }, timeout); + } + + stopPlatformCollector() { + if (this.pressureServiceReadingTimerId_ != null) { + self.clearInterval(this.pressureServiceReadingTimerId_); + this.pressureServiceReadingTimerId_ = null; + } + this.updatesDelivered_ = 0; + } + + updatesDelivered() { + return this.updatesDelivered_; + } + + setPressureUpdate(source, state) { + if (!this.mojomSourceType_.has(source)) + throw new Error(`PressureSource '${source}' is invalid`); + + if (!this.mojomStateType_.has(state)) + throw new Error(`PressureState '${state}' is invalid`); + + this.pressureUpdate_ = { + source: this.mojomSourceType_.get(source), + state: this.mojomStateType_.get(state), + }; + } + + setExpectedFailure(expectedException) { + assert_true( + expectedException instanceof DOMException, + 'setExpectedFailure() expects a DOMException instance'); + if (expectedException.name === 'NotSupportedError') { + this.pressureStatus_ = PressureStatus.kNotSupported; + } else { + throw new TypeError( + `Unexpected DOMException '${expectedException.name}'`); + } + } +} + +export const mockPressureService = new MockPressureService(); diff --git a/test/wpt/tests/resources/chromium/mock-pressure-service.js.headers b/test/wpt/tests/resources/chromium/mock-pressure-service.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-pressure-service.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/wpt/tests/resources/chromium/mock-subapps.js b/test/wpt/tests/resources/chromium/mock-subapps.js new file mode 100644 index 0000000..b819367 --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-subapps.js @@ -0,0 +1,89 @@ +'use strict'; + +import {SubAppsService, SubAppsServiceReceiver, SubAppsServiceResultCode} from '/gen/third_party/blink/public/mojom/subapps/sub_apps_service.mojom.m.js'; + +self.SubAppsServiceTest = (() => { + // Class that mocks SubAppsService interface defined in /third_party/blink/public/mojom/subapps/sub_apps_service.mojom + + class MockSubAppsService { + constructor() { + this.interceptor_ = + new MojoInterfaceInterceptor(SubAppsService.$interfaceName); + this.receiver_ = new SubAppsServiceReceiver(this); + this.interceptor_.oninterfacerequest = + e => this.receiver_.$.bindHandle(e.handle); + this.interceptor_.start(); + } + + reset() { + this.interceptor_.stop(); + this.receiver_.$.close(); + } + + add(sub_apps) { + return Promise.resolve({ + result: testInternal.addCallReturnValue, + }); + } + + list() { + return Promise.resolve({ + result: { + resultCode: testInternal.serviceResultCode, + subAppsList: testInternal.listCallReturnValue, + } + }); + } + + remove() { + return Promise.resolve({ + result: testInternal.removeCallReturnValue, + }); + } + } + + let testInternal = { + initialized: false, + mockSubAppsService: null, + serviceResultCode: 0, + addCallReturnValue: [], + listCallReturnValue: [], + removeCallReturnValue: [], + } + + class SubAppsServiceTestChromium { + constructor() { + Object.freeze(this); // Make it immutable. + } + + initialize(service_result_code, add_call_return_value, list_call_return_value, remove_call_return_value) { + if (!testInternal.initialized) { + testInternal = { + mockSubAppsService: new MockSubAppsService(), + initialized: true, + serviceResultCode: service_result_code, + addCallReturnValue: add_call_return_value, + listCallReturnValue: list_call_return_value, + removeCallReturnValue: remove_call_return_value, + }; + }; + } + + async reset() { + if (testInternal.initialized) { + testInternal.mockSubAppsService.reset(); + testInternal = { + mockSubAppsService: null, + initialized: false, + serviceResultCode: 0, + addCallReturnValue: [], + listCallReturnValue: [], + removeCallReturnValue: [], + }; + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + } + + return SubAppsServiceTestChromium; +})(); diff --git a/test/wpt/tests/resources/chromium/mock-textdetection.js b/test/wpt/tests/resources/chromium/mock-textdetection.js new file mode 100644 index 0000000..52ca987 --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-textdetection.js @@ -0,0 +1,92 @@ +import {TextDetection, TextDetectionReceiver} from '/gen/services/shape_detection/public/mojom/textdetection.mojom.m.js'; + +self.TextDetectionTest = (() => { + // Class that mocks TextDetection interface defined in + // https://cs.chromium.org/chromium/src/services/shape_detection/public/mojom/textdetection.mojom + class MockTextDetection { + constructor() { + this.receiver_ = new TextDetectionReceiver(this); + this.interceptor_ = + new MojoInterfaceInterceptor(TextDetection.$interfaceName); + this.interceptor_.oninterfacerequest = + e => this.receiver_.$.bindHandle(e.handle); + this.interceptor_.start(); + } + + detect(bitmapData) { + this.bufferData_ = + new Uint32Array(getArrayBufferFromBigBuffer(bitmapData.pixelData)); + return Promise.resolve({ + results: [ + { + rawValue : "cats", + boundingBox: { x: 1.0, y: 1.0, width: 100.0, height: 100.0 }, + cornerPoints: [ + { x: 1.0, y: 1.0 }, + { x: 101.0, y: 1.0 }, + { x: 101.0, y: 101.0 }, + { x: 1.0, y: 101.0 } + ] + }, + { + rawValue : "dogs", + boundingBox: { x: 2.0, y: 2.0, width: 50.0, height: 50.0 }, + cornerPoints: [ + { x: 2.0, y: 2.0 }, + { x: 52.0, y: 2.0 }, + { x: 52.0, y: 52.0 }, + { x: 2.0, y: 52.0 } + ] + }, + ], + }); + } + + getFrameData() { + return this.bufferData_; + } + + reset() { + this.receiver_.$.close(); + this.interceptor_.stop(); + } + + } + + let testInternal = { + initialized: false, + MockTextDetection: null + } + + class TextDetectionTestChromium { + constructor() { + Object.freeze(this); // Make it immutable. + } + + initialize() { + if (testInternal.initialized) + throw new Error('Call reset() before initialize().'); + + testInternal.MockTextDetection = new MockTextDetection; + testInternal.initialized = true; + } + + // Resets state of text detection mocks between test runs. + async reset() { + if (!testInternal.initialized) + throw new Error('Call initialize() before reset().'); + testInternal.MockTextDetection.reset(); + testInternal.MockTextDetection = null; + testInternal.initialized = false; + + await new Promise(resolve => setTimeout(resolve, 0)); + } + + MockTextDetection() { + return testInternal.MockTextDetection; + } + } + + return TextDetectionTestChromium; + +})(); diff --git a/test/wpt/tests/resources/chromium/mock-textdetection.js.headers b/test/wpt/tests/resources/chromium/mock-textdetection.js.headers new file mode 100644 index 0000000..6c61a34 --- /dev/null +++ b/test/wpt/tests/resources/chromium/mock-textdetection.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8
\ No newline at end of file diff --git a/test/wpt/tests/resources/chromium/nfc-mock.js b/test/wpt/tests/resources/chromium/nfc-mock.js new file mode 100644 index 0000000..31a71b9 --- /dev/null +++ b/test/wpt/tests/resources/chromium/nfc-mock.js @@ -0,0 +1,437 @@ +import {NDEFErrorType, NDEFRecordTypeCategory, NFC, NFCReceiver} from '/gen/services/device/public/mojom/nfc.mojom.m.js'; + +// Converts between NDEFMessageInit https://w3c.github.io/web-nfc/#dom-ndefmessage +// and mojom.NDEFMessage structure, so that watch function can be tested. +function toMojoNDEFMessage(message) { + let ndefMessage = {data: []}; + for (let record of message.records) { + ndefMessage.data.push(toMojoNDEFRecord(record)); + } + return ndefMessage; +} + +function toMojoNDEFRecord(record) { + let nfcRecord = {}; + // Simply checks the existence of ':' to decide whether it's an external + // type or a local type. As a mock, no need to really implement the validation + // algorithms for them. + if (record.recordType.startsWith(':')) { + nfcRecord.category = NDEFRecordTypeCategory.kLocal; + } else if (record.recordType.search(':') != -1) { + nfcRecord.category = NDEFRecordTypeCategory.kExternal; + } else { + nfcRecord.category = NDEFRecordTypeCategory.kStandardized; + } + nfcRecord.recordType = record.recordType; + nfcRecord.mediaType = record.mediaType; + nfcRecord.id = record.id; + if (record.recordType == 'text') { + nfcRecord.encoding = record.encoding == null? 'utf-8': record.encoding; + nfcRecord.lang = record.lang == null? 'en': record.lang; + } + nfcRecord.data = toByteArray(record.data); + if (record.data != null && record.data.records !== undefined) { + // |record.data| may be an NDEFMessageInit, i.e. the payload is a message. + nfcRecord.payloadMessage = toMojoNDEFMessage(record.data); + } + return nfcRecord; +} + +// Converts JS objects to byte array. +function toByteArray(data) { + if (data instanceof ArrayBuffer) + return new Uint8Array(data); + else if (ArrayBuffer.isView(data)) + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + + let byteArray = new Uint8Array(0); + let tmpData = data; + if (typeof tmpData === 'object' || typeof tmpData === 'number') + tmpData = JSON.stringify(tmpData); + + if (typeof tmpData === 'string') + byteArray = new TextEncoder().encode(tmpData); + + return byteArray; +} + +// Compares NDEFRecords that were provided / received by the mock service. +// TODO: Use different getters to get received record data, +// see spec changes at https://github.com/w3c/web-nfc/pull/243. +self.compareNDEFRecords = function(providedRecord, receivedRecord) { + assert_equals(providedRecord.recordType, receivedRecord.recordType); + + if (providedRecord.id === undefined) { + assert_equals(null, receivedRecord.id); + } else { + assert_equals(providedRecord.id, receivedRecord.id); + } + + if (providedRecord.mediaType === undefined) { + assert_equals(null, receivedRecord.mediaType); + } else { + assert_equals(providedRecord.mediaType, receivedRecord.mediaType); + } + + assert_not_equals(providedRecord.recordType, 'empty'); + + if (providedRecord.recordType == 'text') { + assert_equals( + providedRecord.encoding == null? 'utf-8': providedRecord.encoding, + receivedRecord.encoding); + assert_equals(providedRecord.lang == null? 'en': providedRecord.lang, + receivedRecord.lang); + } else { + assert_equals(null, receivedRecord.encoding); + assert_equals(null, receivedRecord.lang); + } + + assert_array_equals(toByteArray(providedRecord.data), + new Uint8Array(receivedRecord.data)); +} + +// Compares NDEFWriteOptions structures that were provided to API and +// received by the mock mojo service. +self.assertNDEFWriteOptionsEqual = function(provided, received) { + if (provided.overwrite !== undefined) + assert_equals(provided.overwrite, !!received.overwrite); + else + assert_equals(!!received.overwrite, true); +} + +// Compares NDEFReaderOptions structures that were provided to API and +// received by the mock mojo service. +self.assertNDEFReaderOptionsEqual = function(provided, received) { + if (provided.url !== undefined) + assert_equals(provided.url, received.url); + else + assert_equals(received.url, ''); + + if (provided.mediaType !== undefined) + assert_equals(provided.mediaType, received.mediaType); + else + assert_equals(received.mediaType, ''); + + if (provided.recordType !== undefined) { + assert_equals(provided.recordType, received.recordType); + } +} + +function createNDEFError(type) { + return {error: (type != null ? {errorType: type, errorMessage: ''} : null)}; +} + +self.WebNFCTest = (() => { + class MockNFC { + constructor() { + this.receiver_ = new NFCReceiver(this); + + this.interceptor_ = new MojoInterfaceInterceptor(NFC.$interfaceName); + this.interceptor_.oninterfacerequest = e => { + if (this.should_close_pipe_on_request_) + e.handle.close(); + else + this.receiver_.$.bindHandle(e.handle); + } + + this.interceptor_.start(); + + this.hw_status_ = NFCHWStatus.ENABLED; + this.pushed_message_ = null; + this.pending_write_options_ = null; + this.pending_push_promise_func_ = null; + this.push_completed_ = true; + this.pending_make_read_only_promise_func_ = null; + this.make_read_only_completed_ = true; + this.client_ = null; + this.watchers_ = []; + this.reading_messages_ = []; + this.operations_suspended_ = false; + this.is_formatted_tag_ = false; + this.data_transfer_failed_ = false; + this.should_close_pipe_on_request_ = false; + } + + // NFC delegate functions. + async push(message, options) { + const error = this.getHWError(); + if (error) + return error; + // Cancels previous pending push operation. + if (this.pending_push_promise_func_) { + this.cancelPendingPushOperation(); + } + + this.pushed_message_ = message; + this.pending_write_options_ = options; + return new Promise(resolve => { + if (this.operations_suspended_ || !this.push_completed_) { + // Leaves the push pending. + this.pending_push_promise_func_ = resolve; + } else if (this.is_formatted_tag_ && !options.overwrite) { + // Resolves with NotAllowedError if there are NDEF records on the device + // and overwrite is false. + resolve(createNDEFError(NDEFErrorType.NOT_ALLOWED)); + } else if (this.data_transfer_failed_) { + // Resolves with NetworkError if data transfer fails. + resolve(createNDEFError(NDEFErrorType.IO_ERROR)); + } else { + resolve(createNDEFError(null)); + } + }); + } + + async cancelPush() { + this.cancelPendingPushOperation(); + return createNDEFError(null); + } + + async makeReadOnly(options) { + const error = this.getHWError(); + if (error) + return error; + // Cancels previous pending makeReadOnly operation. + if (this.pending_make_read_only_promise_func_) { + this.cancelPendingMakeReadOnlyOperation(); + } + + if (this.operations_suspended_ || !this.make_read_only_completed_) { + // Leaves the makeReadOnly pending. + return new Promise(resolve => { + this.pending_make_read_only_promise_func_ = resolve; + }); + } else if (this.data_transfer_failed_) { + // Resolves with NetworkError if data transfer fails. + return createNDEFError(NDEFErrorType.IO_ERROR); + } else { + return createNDEFError(null); + } + } + + async cancelMakeReadOnly() { + this.cancelPendingMakeReadOnlyOperation(); + return createNDEFError(null); + } + + setClient(client) { + this.client_ = client; + } + + async watch(id) { + assert_true(id > 0); + const error = this.getHWError(); + if (error) { + return error; + } + + this.watchers_.push({id: id}); + // Ignores reading if NFC operation is suspended + // or the NFC tag does not expose NDEF technology. + if (!this.operations_suspended_) { + // Triggers onWatch if the new watcher matches existing messages. + for (let message of this.reading_messages_) { + this.client_.onWatch( + [id], fake_tag_serial_number, toMojoNDEFMessage(message)); + } + } + + return createNDEFError(null); + } + + cancelWatch(id) { + let index = this.watchers_.findIndex(value => value.id === id); + if (index !== -1) { + this.watchers_.splice(index, 1); + } + } + + getHWError() { + if (this.hw_status_ === NFCHWStatus.DISABLED) + return createNDEFError(NDEFErrorType.NOT_READABLE); + if (this.hw_status_ === NFCHWStatus.NOT_SUPPORTED) + return createNDEFError(NDEFErrorType.NOT_SUPPORTED); + return null; + } + + setHWStatus(status) { + this.hw_status_ = status; + } + + pushedMessage() { + return this.pushed_message_; + } + + writeOptions() { + return this.pending_write_options_; + } + + watchOptions() { + assert_not_equals(this.watchers_.length, 0); + return this.watchers_[this.watchers_.length - 1].options; + } + + setPendingPushCompleted(result) { + this.push_completed_ = result; + } + + setPendingMakeReadOnlyCompleted(result) { + this.make_read_only_completed_ = result; + } + + reset() { + this.hw_status_ = NFCHWStatus.ENABLED; + this.watchers_ = []; + this.reading_messages_ = []; + this.operations_suspended_ = false; + this.cancelPendingPushOperation(); + this.cancelPendingMakeReadOnlyOperation(); + this.is_formatted_tag_ = false; + this.data_transfer_failed_ = false; + this.should_close_pipe_on_request_ = false; + } + + cancelPendingPushOperation() { + if (this.pending_push_promise_func_) { + this.pending_push_promise_func_( + createNDEFError(NDEFErrorType.OPERATION_CANCELLED)); + this.pending_push_promise_func_ = null; + } + + this.pushed_message_ = null; + this.pending_write_options_ = null; + this.push_completed_ = true; + } + + cancelPendingMakeReadOnlyOperation() { + if (this.pending_make_read_only_promise_func_) { + this.pending_make_read_only_promise_func_( + createNDEFError(NDEFErrorType.OPERATION_CANCELLED)); + this.pending_make_read_only_promise_func_ = null; + } + + this.make_read_only_completed_ = true; + } + + // Sets message that is used to deliver NFC reading updates. + setReadingMessage(message) { + this.reading_messages_.push(message); + // Ignores reading if NFC operation is suspended. + if(this.operations_suspended_) return; + // when overwrite is false, the write algorithm will read the NFC tag + // to determine if it has NDEF records on it. + if (this.pending_write_options_ && this.pending_write_options_.overwrite) + return; + // Triggers onWatch if the new message matches existing watchers. + for (let watcher of this.watchers_) { + this.client_.onWatch( + [watcher.id], fake_tag_serial_number, + toMojoNDEFMessage(message)); + } + } + + // Suspends all pending NFC operations. Could be used when web page + // visibility is lost. + suspendNFCOperations() { + this.operations_suspended_ = true; + } + + // Resumes all suspended NFC operations. + resumeNFCOperations() { + this.operations_suspended_ = false; + // Resumes pending NFC reading. + for (let watcher of this.watchers_) { + for (let message of this.reading_messages_) { + this.client_.onWatch( + [watcher.id], fake_tag_serial_number, + toMojoNDEFMessage(message)); + } + } + // Resumes pending push operation. + if (this.pending_push_promise_func_ && this.push_completed_) { + this.pending_push_promise_func_(createNDEFError(null)); + this.pending_push_promise_func_ = null; + } + // Resumes pending makeReadOnly operation. + if (this.pending_make_read_only_promise_func_ && + this.make_read_only_completed_) { + this.pending_make_read_only_promise_func_(createNDEFError(null)); + this.pending_make_read_only_promise_func_ = null; + } + } + + // Simulates the device coming in proximity does not expose NDEF technology. + simulateNonNDEFTagDiscovered() { + // Notify NotSupportedError to all active readers. + if (this.watchers_.length != 0) { + this.client_.onError({ + errorType: NDEFErrorType.NOT_SUPPORTED, + errorMessage: '' + }); + } + // Reject the pending push with NotSupportedError. + if (this.pending_push_promise_func_) { + this.pending_push_promise_func_( + createNDEFError(NDEFErrorType.NOT_SUPPORTED)); + this.pending_push_promise_func_ = null; + } + // Reject the pending makeReadOnly with NotSupportedError. + if (this.pending_make_read_only_promise_func_) { + this.pending_make_read_only_promise_func_( + createNDEFError(NDEFErrorType.NOT_SUPPORTED)); + this.pending_make_read_only_promise_func_ = null; + } + } + + setIsFormattedTag(isFormatted) { + this.is_formatted_tag_ = isFormatted; + } + + simulateDataTransferFails() { + this.data_transfer_failed_ = true; + } + + simulateClosedPipe() { + this.should_close_pipe_on_request_ = true; + } + } + + let testInternal = { + initialized: false, + mockNFC: null + } + + class NFCTestChromium { + constructor() { + Object.freeze(this); // Makes it immutable. + } + + async initialize() { + if (testInternal.initialized) + throw new Error('Call reset() before initialize().'); + + // Grant nfc permissions for Chromium testdriver. + await test_driver.set_permission({ name: 'nfc' }, 'granted'); + + if (testInternal.mockNFC == null) { + testInternal.mockNFC = new MockNFC(); + } + testInternal.initialized = true; + } + + // Reuses the nfc mock but resets its state between test runs. + async reset() { + if (!testInternal.initialized) + throw new Error('Call initialize() before reset().'); + testInternal.mockNFC.reset(); + testInternal.initialized = false; + + await new Promise(resolve => setTimeout(resolve, 0)); + } + + getMockNFC() { + return testInternal.mockNFC; + } + } + + return NFCTestChromium; +})(); diff --git a/test/wpt/tests/resources/chromium/web-bluetooth-test.js b/test/wpt/tests/resources/chromium/web-bluetooth-test.js new file mode 100644 index 0000000..ecea5e7 --- /dev/null +++ b/test/wpt/tests/resources/chromium/web-bluetooth-test.js @@ -0,0 +1,629 @@ +'use strict'; + +const content = {}; +const bluetooth = {}; +const MOJO_CHOOSER_EVENT_TYPE_MAP = {}; + +function toMojoCentralState(state) { + switch (state) { + case 'absent': + return bluetooth.mojom.CentralState.ABSENT; + case 'powered-off': + return bluetooth.mojom.CentralState.POWERED_OFF; + case 'powered-on': + return bluetooth.mojom.CentralState.POWERED_ON; + default: + throw `Unsupported value ${state} for state.`; + } +} + +// Converts bluetooth.mojom.WriteType to a string. If |writeType| is +// invalid, this method will throw. +function writeTypeToString(writeType) { + switch (writeType) { + case bluetooth.mojom.WriteType.kNone: + return 'none'; + case bluetooth.mojom.WriteType.kWriteDefaultDeprecated: + return 'default-deprecated'; + case bluetooth.mojom.WriteType.kWriteWithResponse: + return 'with-response'; + case bluetooth.mojom.WriteType.kWriteWithoutResponse: + return 'without-response'; + default: + throw `Unknown bluetooth.mojom.WriteType: ${writeType}`; + } +} + +// Canonicalizes UUIDs and converts them to Mojo UUIDs. +function canonicalizeAndConvertToMojoUUID(uuids) { + let canonicalUUIDs = uuids.map(val => ({uuid: BluetoothUUID.getService(val)})); + return canonicalUUIDs; +} + +// Converts WebIDL a record<DOMString, BufferSource> to a map<K, array<uint8>> to +// use for Mojo, where the value for K is calculated using keyFn. +function convertToMojoMap(record, keyFn, isNumberKey = false) { + let map = new Map(); + for (const [key, value] of Object.entries(record)) { + let buffer = ArrayBuffer.isView(value) ? value.buffer : value; + if (isNumberKey) { + let numberKey = parseInt(key); + if (Number.isNaN(numberKey)) + throw `Map key ${key} is not a number`; + map.set(keyFn(numberKey), Array.from(new Uint8Array(buffer))); + continue; + } + map.set(keyFn(key), Array.from(new Uint8Array(buffer))); + } + return map; +} + +function ArrayToMojoCharacteristicProperties(arr) { + const struct = {}; + arr.forEach(property => { struct[property] = true; }); + return struct; +} + +class FakeBluetooth { + constructor() { + this.fake_bluetooth_ptr_ = new bluetooth.mojom.FakeBluetoothRemote(); + this.fake_bluetooth_ptr_.$.bindNewPipeAndPassReceiver().bindInBrowser('process'); + this.fake_central_ = null; + } + + // Set it to indicate whether the platform supports BLE. For example, + // Windows 7 is a platform that doesn't support Low Energy. On the other + // hand Windows 10 is a platform that does support LE, even if there is no + // Bluetooth radio present. + async setLESupported(supported) { + if (typeof supported !== 'boolean') throw 'Type Not Supported'; + await this.fake_bluetooth_ptr_.setLESupported(supported); + } + + // Returns a promise that resolves with a FakeCentral that clients can use + // to simulate events that a device in the Central/Observer role would + // receive as well as monitor the operations performed by the device in the + // Central/Observer role. + // Calls sets LE as supported. + // + // A "Central" object would allow its clients to receive advertising events + // and initiate connections to peripherals i.e. operations of two roles + // defined by the Bluetooth Spec: Observer and Central. + // See Bluetooth 4.2 Vol 3 Part C 2.2.2 "Roles when Operating over an + // LE Physical Transport". + async simulateCentral({state}) { + if (this.fake_central_) + throw 'simulateCentral() should only be called once'; + + await this.setLESupported(true); + + let {fakeCentral: fake_central_ptr} = + await this.fake_bluetooth_ptr_.simulateCentral( + toMojoCentralState(state)); + this.fake_central_ = new FakeCentral(fake_central_ptr); + return this.fake_central_; + } + + // Returns true if there are no pending responses. + async allResponsesConsumed() { + let {consumed} = await this.fake_bluetooth_ptr_.allResponsesConsumed(); + return consumed; + } + + // Returns a promise that resolves with a FakeChooser that clients can use to + // simulate chooser events. + async getManualChooser() { + if (typeof this.fake_chooser_ === 'undefined') { + this.fake_chooser_ = new FakeChooser(); + } + return this.fake_chooser_; + } +} + +// FakeCentral allows clients to simulate events that a device in the +// Central/Observer role would receive as well as monitor the operations +// performed by the device in the Central/Observer role. +class FakeCentral { + constructor(fake_central_ptr) { + this.fake_central_ptr_ = fake_central_ptr; + this.peripherals_ = new Map(); + } + + // Simulates a peripheral with |address|, |name|, |manufacturerData| and + // |known_service_uuids| that has already been connected to the system. If the + // peripheral existed already it updates its name, manufacturer data, and + // known UUIDs. |known_service_uuids| should be an array of + // BluetoothServiceUUIDs + // https://webbluetoothcg.github.io/web-bluetooth/#typedefdef-bluetoothserviceuuid + // + // Platforms offer methods to retrieve devices that have already been + // connected to the system or weren't connected through the UA e.g. a user + // connected a peripheral through the system's settings. This method is + // intended to simulate peripherals that those methods would return. + async simulatePreconnectedPeripheral( + {address, name, manufacturerData = {}, knownServiceUUIDs = []}) { + await this.fake_central_ptr_.simulatePreconnectedPeripheral( + address, name, + convertToMojoMap(manufacturerData, Number, true /* isNumberKey */), + canonicalizeAndConvertToMojoUUID(knownServiceUUIDs)); + + return this.fetchOrCreatePeripheral_(address); + } + + // Simulates an advertisement packet described by |scanResult| being received + // from a device. If central is currently scanning, the device will appear on + // the list of discovered devices. + async simulateAdvertisementReceived(scanResult) { + // Create a deep-copy to prevent the original |scanResult| from being + // modified when the UUIDs, manufacturer, and service data are converted. + let clonedScanResult = JSON.parse(JSON.stringify(scanResult)); + + if ('uuids' in scanResult.scanRecord) { + clonedScanResult.scanRecord.uuids = + canonicalizeAndConvertToMojoUUID(scanResult.scanRecord.uuids); + } + + // Convert the optional appearance and txPower fields to the corresponding + // Mojo structures, since Mojo does not support optional interger values. If + // the fields are undefined, set the hasValue field as false and value as 0. + // Otherwise, set the hasValue field as true and value with the field value. + const has_appearance = 'appearance' in scanResult.scanRecord; + clonedScanResult.scanRecord.appearance = { + hasValue: has_appearance, + value: (has_appearance ? scanResult.scanRecord.appearance : 0) + } + + const has_tx_power = 'txPower' in scanResult.scanRecord; + clonedScanResult.scanRecord.txPower = { + hasValue: has_tx_power, + value: (has_tx_power ? scanResult.scanRecord.txPower : 0) + } + + // Convert manufacturerData from a record<DOMString, BufferSource> into a + // map<uint8, array<uint8>> for Mojo. + if ('manufacturerData' in scanResult.scanRecord) { + clonedScanResult.scanRecord.manufacturerData = convertToMojoMap( + scanResult.scanRecord.manufacturerData, Number, + true /* isNumberKey */); + } + + // Convert serviceData from a record<DOMString, BufferSource> into a + // map<string, array<uint8>> for Mojo. + if ('serviceData' in scanResult.scanRecord) { + clonedScanResult.scanRecord.serviceData.serviceData = convertToMojoMap( + scanResult.scanRecord.serviceData, BluetoothUUID.getService, + false /* isNumberKey */); + } + + await this.fake_central_ptr_.simulateAdvertisementReceived( + clonedScanResult); + + return this.fetchOrCreatePeripheral_(clonedScanResult.deviceAddress); + } + + // Simulates a change in the central device described by |state|. For example, + // setState('powered-off') can be used to simulate the central device powering + // off. + // + // This method should be used for any central state changes after + // simulateCentral() has been called to create a FakeCentral object. + async setState(state) { + await this.fake_central_ptr_.setState(toMojoCentralState(state)); + } + + // Create a fake_peripheral object from the given address. + fetchOrCreatePeripheral_(address) { + let peripheral = this.peripherals_.get(address); + if (peripheral === undefined) { + peripheral = new FakePeripheral(address, this.fake_central_ptr_); + this.peripherals_.set(address, peripheral); + } + return peripheral; + } +} + +class FakePeripheral { + constructor(address, fake_central_ptr) { + this.address = address; + this.fake_central_ptr_ = fake_central_ptr; + } + + // Adds a fake GATT Service with |uuid| to be discovered when discovering + // the peripheral's GATT Attributes. Returns a FakeRemoteGATTService + // corresponding to this service. |uuid| should be a BluetoothServiceUUIDs + // https://webbluetoothcg.github.io/web-bluetooth/#typedefdef-bluetoothserviceuuid + async addFakeService({uuid}) { + let {serviceId: service_id} = await this.fake_central_ptr_.addFakeService( + this.address, {uuid: BluetoothUUID.getService(uuid)}); + + if (service_id === null) throw 'addFakeService failed'; + + return new FakeRemoteGATTService( + service_id, this.address, this.fake_central_ptr_); + } + + // Sets the next GATT Connection request response to |code|. |code| could be + // an HCI Error Code from BT 4.2 Vol 2 Part D 1.3 List Of Error Codes or a + // number outside that range returned by specific platforms e.g. Android + // returns 0x101 to signal a GATT failure + // https://developer.android.com/reference/android/bluetooth/BluetoothGatt.html#GATT_FAILURE + async setNextGATTConnectionResponse({code}) { + let {success} = + await this.fake_central_ptr_.setNextGATTConnectionResponse( + this.address, code); + + if (success !== true) throw 'setNextGATTConnectionResponse failed.'; + } + + // Sets the next GATT Discovery request response for peripheral with + // |address| to |code|. |code| could be an HCI Error Code from + // BT 4.2 Vol 2 Part D 1.3 List Of Error Codes or a number outside that + // range returned by specific platforms e.g. Android returns 0x101 to signal + // a GATT failure + // https://developer.android.com/reference/android/bluetooth/BluetoothGatt.html#GATT_FAILURE + // + // The following procedures defined at BT 4.2 Vol 3 Part G Section 4. + // "GATT Feature Requirements" are used to discover attributes of the + // GATT Server: + // - Primary Service Discovery + // - Relationship Discovery + // - Characteristic Discovery + // - Characteristic Descriptor Discovery + // This method aims to simulate the response once all of these procedures + // have completed or if there was an error during any of them. + async setNextGATTDiscoveryResponse({code}) { + let {success} = + await this.fake_central_ptr_.setNextGATTDiscoveryResponse( + this.address, code); + + if (success !== true) throw 'setNextGATTDiscoveryResponse failed.'; + } + + // Simulates a GATT disconnection from the peripheral with |address|. + async simulateGATTDisconnection() { + let {success} = + await this.fake_central_ptr_.simulateGATTDisconnection(this.address); + + if (success !== true) throw 'simulateGATTDisconnection failed.'; + } + + // Simulates an Indication from the peripheral's GATT `Service Changed` + // Characteristic from BT 4.2 Vol 3 Part G 7.1. This Indication is signaled + // when services, characteristics, or descriptors are changed, added, or + // removed. + // + // The value for `Service Changed` is a range of attribute handles that have + // changed. However, this testing specification works at an abstracted + // level and does not expose setting attribute handles when adding + // attributes. Consequently, this simulate method should include the full + // range of all the peripheral's attribute handle values. + async simulateGATTServicesChanged() { + let {success} = + await this.fake_central_ptr_.simulateGATTServicesChanged(this.address); + + if (success !== true) throw 'simulateGATTServicesChanged failed.'; + } +} + +class FakeRemoteGATTService { + constructor(service_id, peripheral_address, fake_central_ptr) { + this.service_id_ = service_id; + this.peripheral_address_ = peripheral_address; + this.fake_central_ptr_ = fake_central_ptr; + } + + // Adds a fake GATT Characteristic with |uuid| and |properties| + // to this fake service. The characteristic will be found when discovering + // the peripheral's GATT Attributes. Returns a FakeRemoteGATTCharacteristic + // corresponding to the added characteristic. + async addFakeCharacteristic({uuid, properties}) { + let {characteristicId: characteristic_id} = + await this.fake_central_ptr_.addFakeCharacteristic( + {uuid: BluetoothUUID.getCharacteristic(uuid)}, + ArrayToMojoCharacteristicProperties(properties), + this.service_id_, + this.peripheral_address_); + + if (characteristic_id === null) throw 'addFakeCharacteristic failed'; + + return new FakeRemoteGATTCharacteristic( + characteristic_id, this.service_id_, + this.peripheral_address_, this.fake_central_ptr_); + } + + // Removes the fake GATT service from its fake peripheral. + async remove() { + let {success} = + await this.fake_central_ptr_.removeFakeService( + this.service_id_, + this.peripheral_address_); + + if (!success) throw 'remove failed'; + } +} + +class FakeRemoteGATTCharacteristic { + constructor(characteristic_id, service_id, peripheral_address, + fake_central_ptr) { + this.ids_ = [characteristic_id, service_id, peripheral_address]; + this.descriptors_ = []; + this.fake_central_ptr_ = fake_central_ptr; + } + + // Adds a fake GATT Descriptor with |uuid| to be discovered when + // discovering the peripheral's GATT Attributes. Returns a + // FakeRemoteGATTDescriptor corresponding to this descriptor. |uuid| should + // be a BluetoothDescriptorUUID + // https://webbluetoothcg.github.io/web-bluetooth/#typedefdef-bluetoothdescriptoruuid + async addFakeDescriptor({uuid}) { + let {descriptorId: descriptor_id} = + await this.fake_central_ptr_.addFakeDescriptor( + {uuid: BluetoothUUID.getDescriptor(uuid)}, ...this.ids_); + + if (descriptor_id === null) throw 'addFakeDescriptor failed'; + + let fake_descriptor = new FakeRemoteGATTDescriptor( + descriptor_id, ...this.ids_, this.fake_central_ptr_); + this.descriptors_.push(fake_descriptor); + + return fake_descriptor; + } + + // Sets the next read response for characteristic to |code| and |value|. + // |code| could be a GATT Error Response from + // BT 4.2 Vol 3 Part F 3.4.1.1 Error Response or a number outside that range + // returned by specific platforms e.g. Android returns 0x101 to signal a GATT + // failure. + // https://developer.android.com/reference/android/bluetooth/BluetoothGatt.html#GATT_FAILURE + async setNextReadResponse(gatt_code, value=null) { + if (gatt_code === 0 && value === null) { + throw '|value| can\'t be null if read should success.'; + } + if (gatt_code !== 0 && value !== null) { + throw '|value| must be null if read should fail.'; + } + + let {success} = + await this.fake_central_ptr_.setNextReadCharacteristicResponse( + gatt_code, value, ...this.ids_); + + if (!success) throw 'setNextReadCharacteristicResponse failed'; + } + + // Sets the next write response for this characteristic to |code|. If + // writing to a characteristic that only supports 'write_without_response' + // the set response will be ignored. + // |code| could be a GATT Error Response from + // BT 4.2 Vol 3 Part F 3.4.1.1 Error Response or a number outside that range + // returned by specific platforms e.g. Android returns 0x101 to signal a GATT + // failure. + async setNextWriteResponse(gatt_code) { + let {success} = + await this.fake_central_ptr_.setNextWriteCharacteristicResponse( + gatt_code, ...this.ids_); + + if (!success) throw 'setNextWriteCharacteristicResponse failed'; + } + + // Sets the next subscribe to notifications response for characteristic with + // |characteristic_id| in |service_id| and in |peripheral_address| to + // |code|. |code| could be a GATT Error Response from BT 4.2 Vol 3 Part F + // 3.4.1.1 Error Response or a number outside that range returned by + // specific platforms e.g. Android returns 0x101 to signal a GATT failure. + async setNextSubscribeToNotificationsResponse(gatt_code) { + let {success} = + await this.fake_central_ptr_.setNextSubscribeToNotificationsResponse( + gatt_code, ...this.ids_); + + if (!success) throw 'setNextSubscribeToNotificationsResponse failed'; + } + + // Sets the next unsubscribe to notifications response for characteristic with + // |characteristic_id| in |service_id| and in |peripheral_address| to + // |code|. |code| could be a GATT Error Response from BT 4.2 Vol 3 Part F + // 3.4.1.1 Error Response or a number outside that range returned by + // specific platforms e.g. Android returns 0x101 to signal a GATT failure. + async setNextUnsubscribeFromNotificationsResponse(gatt_code) { + let {success} = + await this.fake_central_ptr_.setNextUnsubscribeFromNotificationsResponse( + gatt_code, ...this.ids_); + + if (!success) throw 'setNextUnsubscribeToNotificationsResponse failed'; + } + + // Returns true if notifications from the characteristic have been subscribed + // to. + async isNotifying() { + let {success, isNotifying} = + await this.fake_central_ptr_.isNotifying(...this.ids_); + + if (!success) throw 'isNotifying failed'; + + return isNotifying; + } + + // Gets the last successfully written value to the characteristic and its + // write type. Write type is one of 'none', 'default-deprecated', + // 'with-response', 'without-response'. Returns {lastValue: null, + // lastWriteType: 'none'} if no value has yet been written to the + // characteristic. + async getLastWrittenValue() { + let {success, value, writeType} = + await this.fake_central_ptr_.getLastWrittenCharacteristicValue( + ...this.ids_); + + if (!success) throw 'getLastWrittenCharacteristicValue failed'; + + return {lastValue: value, lastWriteType: writeTypeToString(writeType)}; + } + + // Removes the fake GATT Characteristic from its fake service. + async remove() { + let {success} = + await this.fake_central_ptr_.removeFakeCharacteristic(...this.ids_); + + if (!success) throw 'remove failed'; + } +} + +class FakeRemoteGATTDescriptor { + constructor(descriptor_id, + characteristic_id, + service_id, + peripheral_address, + fake_central_ptr) { + this.ids_ = [ + descriptor_id, characteristic_id, service_id, peripheral_address]; + this.fake_central_ptr_ = fake_central_ptr; + } + + // Sets the next read response for descriptor to |code| and |value|. + // |code| could be a GATT Error Response from + // BT 4.2 Vol 3 Part F 3.4.1.1 Error Response or a number outside that range + // returned by specific platforms e.g. Android returns 0x101 to signal a GATT + // failure. + // https://developer.android.com/reference/android/bluetooth/BluetoothGatt.html#GATT_FAILURE + async setNextReadResponse(gatt_code, value=null) { + if (gatt_code === 0 && value === null) { + throw '|value| cannot be null if read should succeed.'; + } + if (gatt_code !== 0 && value !== null) { + throw '|value| must be null if read should fail.'; + } + + let {success} = + await this.fake_central_ptr_.setNextReadDescriptorResponse( + gatt_code, value, ...this.ids_); + + if (!success) throw 'setNextReadDescriptorResponse failed'; + } + + // Sets the next write response for this descriptor to |code|. + // |code| could be a GATT Error Response from + // BT 4.2 Vol 3 Part F 3.4.1.1 Error Response or a number outside that range + // returned by specific platforms e.g. Android returns 0x101 to signal a GATT + // failure. + async setNextWriteResponse(gatt_code) { + let {success} = + await this.fake_central_ptr_.setNextWriteDescriptorResponse( + gatt_code, ...this.ids_); + + if (!success) throw 'setNextWriteDescriptorResponse failed'; + } + + // Gets the last successfully written value to the descriptor. + // Returns null if no value has yet been written to the descriptor. + async getLastWrittenValue() { + let {success, value} = + await this.fake_central_ptr_.getLastWrittenDescriptorValue( + ...this.ids_); + + if (!success) throw 'getLastWrittenDescriptorValue failed'; + + return value; + } + + // Removes the fake GATT Descriptor from its fake characteristic. + async remove() { + let {success} = + await this.fake_central_ptr_.removeFakeDescriptor(...this.ids_); + + if (!success) throw 'remove failed'; + } +} + +// FakeChooser allows clients to simulate user actions on a Bluetooth chooser, +// and records the events produced by the Bluetooth chooser. +class FakeChooser { + constructor() { + let fakeBluetoothChooserFactoryRemote = + new content.mojom.FakeBluetoothChooserFactoryRemote(); + fakeBluetoothChooserFactoryRemote.$.bindNewPipeAndPassReceiver().bindInBrowser('process'); + + this.fake_bluetooth_chooser_ptr_ = + new content.mojom.FakeBluetoothChooserRemote(); + this.fake_bluetooth_chooser_client_receiver_ = + new content.mojom.FakeBluetoothChooserClientReceiver(this); + fakeBluetoothChooserFactoryRemote.createFakeBluetoothChooser( + this.fake_bluetooth_chooser_ptr_.$.bindNewPipeAndPassReceiver(), + this.fake_bluetooth_chooser_client_receiver_.$.associateAndPassRemote()); + + this.events_ = new Array(); + this.event_listener_ = null; + } + + // If the chooser has received more events than |numOfEvents| this function + // will reject the promise, else it will wait until |numOfEvents| events are + // received before resolving with an array of |FakeBluetoothChooserEvent| + // objects. + async waitForEvents(numOfEvents) { + return new Promise(resolve => { + if (this.events_.length > numOfEvents) { + throw `Asked for ${numOfEvents} event(s), but received ` + + `${this.events_.length}.`; + } + + this.event_listener_ = () => { + if (this.events_.length === numOfEvents) { + let result = Array.from(this.events_); + this.event_listener_ = null; + this.events_ = []; + resolve(result); + } + }; + this.event_listener_(); + }); + } + + async selectPeripheral(peripheral) { + if (!(peripheral instanceof FakePeripheral)) { + throw '|peripheral| must be an instance of FakePeripheral'; + } + await this.fake_bluetooth_chooser_ptr_.selectPeripheral(peripheral.address); + } + + async cancel() { + await this.fake_bluetooth_chooser_ptr_.cancel(); + } + + async rescan() { + await this.fake_bluetooth_chooser_ptr_.rescan(); + } + + onEvent(chooserEvent) { + chooserEvent.type = MOJO_CHOOSER_EVENT_TYPE_MAP[chooserEvent.type]; + this.events_.push(chooserEvent); + if (this.event_listener_ !== null) { + this.event_listener_(); + } + } +} + +async function initializeChromiumResources() { + content.mojom = await import( + '/gen/content/web_test/common/fake_bluetooth_chooser.mojom.m.js'); + bluetooth.mojom = await import( + '/gen/device/bluetooth/public/mojom/test/fake_bluetooth.mojom.m.js'); + + const map = MOJO_CHOOSER_EVENT_TYPE_MAP; + const types = content.mojom.ChooserEventType; + map[types.CHOOSER_OPENED] = 'chooser-opened'; + map[types.CHOOSER_CLOSED] = 'chooser-closed'; + map[types.ADAPTER_REMOVED] = 'adapter-removed'; + map[types.ADAPTER_DISABLED] = 'adapter-disabled'; + map[types.ADAPTER_ENABLED] = 'adapter-enabled'; + map[types.DISCOVERY_FAILED_TO_START] = 'discovery-failed-to-start'; + map[types.DISCOVERING] = 'discovering'; + map[types.DISCOVERY_IDLE] = 'discovery-idle'; + map[types.ADD_OR_UPDATE_DEVICE] = 'add-or-update-device'; + + // If this line fails, it means that current environment does not support the + // Web Bluetooth Test API. + try { + navigator.bluetooth.test = new FakeBluetooth(); + } catch { + throw 'Web Bluetooth Test API is not implemented on this ' + + 'environment. See the bluetooth README at ' + + 'https://github.com/web-platform-tests/wpt/blob/master/bluetooth/README.md#web-bluetooth-testing'; + } +} diff --git a/test/wpt/tests/resources/chromium/web-bluetooth-test.js.headers b/test/wpt/tests/resources/chromium/web-bluetooth-test.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/resources/chromium/web-bluetooth-test.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/wpt/tests/resources/chromium/webusb-child-test.js b/test/wpt/tests/resources/chromium/webusb-child-test.js new file mode 100644 index 0000000..21412f6 --- /dev/null +++ b/test/wpt/tests/resources/chromium/webusb-child-test.js @@ -0,0 +1,47 @@ +'use strict'; + +// This polyfill prepares a child context to be attached to a parent context. +// The parent must call navigator.usb.test.attachToContext() to attach to the +// child context. +(() => { + if (this.constructor.name === 'DedicatedWorkerGlobalScope' || + this !== window.top) { + + // Run Chromium specific set up code. + if (typeof MojoInterfaceInterceptor !== 'undefined') { + let messageChannel = new MessageChannel(); + messageChannel.port1.onmessage = async (messageEvent) => { + if (messageEvent.data.type === 'Attach') { + messageEvent.data.interfaces.forEach(interfaceName => { + let interfaceInterceptor = + new MojoInterfaceInterceptor(interfaceName); + interfaceInterceptor.oninterfacerequest = + e => messageChannel.port1.postMessage({ + type: interfaceName, + handle: e.handle + }, [e.handle]); + interfaceInterceptor.start(); + }); + + // Wait for a call to GetDevices() to ensure that the interface + // handles are forwarded to the parent context. + try { + await navigator.usb.getDevices(); + } catch (e) { + // This can happen in case of, for example, testing usb disallowed + // iframe. + console.error(`getDevices() throws error: ${e.name}: ${e.message}`); + } + + messageChannel.port1.postMessage({ type: 'Complete' }); + } + }; + + let message = { type: 'ReadyForAttachment', port: messageChannel.port2 }; + if (typeof Window !== 'undefined') + parent.postMessage(message, '*', [messageChannel.port2]); + else + postMessage(message, [messageChannel.port2]); + } + } +})(); diff --git a/test/wpt/tests/resources/chromium/webusb-child-test.js.headers b/test/wpt/tests/resources/chromium/webusb-child-test.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/resources/chromium/webusb-child-test.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/wpt/tests/resources/chromium/webusb-test.js b/test/wpt/tests/resources/chromium/webusb-test.js new file mode 100644 index 0000000..7cca63d --- /dev/null +++ b/test/wpt/tests/resources/chromium/webusb-test.js @@ -0,0 +1,583 @@ +'use strict'; + +// This polyfill library implements the WebUSB Test API as specified here: +// https://wicg.github.io/webusb/test/ + +(() => { + +// These variables are logically members of the USBTest class but are defined +// here to hide them from being visible as fields of navigator.usb.test. +let internal = { + intialized: false, + + webUsbService: null, + webUsbServiceInterceptor: null, + + messagePort: null, +}; + +let mojom = {}; + +async function loadMojomDefinitions() { + const deviceMojom = + await import('/gen/services/device/public/mojom/usb_device.mojom.m.js'); + const serviceMojom = await import( + '/gen/third_party/blink/public/mojom/usb/web_usb_service.mojom.m.js'); + return { + ...deviceMojom, + ...serviceMojom, + }; +} + +function getMessagePort(target) { + return new Promise(resolve => { + target.addEventListener('message', messageEvent => { + if (messageEvent.data.type === 'ReadyForAttachment') { + if (internal.messagePort === null) { + internal.messagePort = messageEvent.data.port; + } + resolve(); + } + }, {once: true}); + }); +} + +// Converts an ECMAScript String object to an instance of +// mojo_base.mojom.String16. +function mojoString16ToString(string16) { + return String.fromCharCode.apply(null, string16.data); +} + +// Converts an instance of mojo_base.mojom.String16 to an ECMAScript String. +function stringToMojoString16(string) { + let array = new Array(string.length); + for (var i = 0; i < string.length; ++i) { + array[i] = string.charCodeAt(i); + } + return { data: array } +} + +function fakeDeviceInitToDeviceInfo(guid, init) { + let deviceInfo = { + guid: guid + "", + usbVersionMajor: init.usbVersionMajor, + usbVersionMinor: init.usbVersionMinor, + usbVersionSubminor: init.usbVersionSubminor, + classCode: init.deviceClass, + subclassCode: init.deviceSubclass, + protocolCode: init.deviceProtocol, + vendorId: init.vendorId, + productId: init.productId, + deviceVersionMajor: init.deviceVersionMajor, + deviceVersionMinor: init.deviceVersionMinor, + deviceVersionSubminor: init.deviceVersionSubminor, + manufacturerName: stringToMojoString16(init.manufacturerName), + productName: stringToMojoString16(init.productName), + serialNumber: stringToMojoString16(init.serialNumber), + activeConfiguration: init.activeConfigurationValue, + configurations: [] + }; + init.configurations.forEach(config => { + var configInfo = { + configurationValue: config.configurationValue, + configurationName: stringToMojoString16(config.configurationName), + selfPowered: false, + remoteWakeup: false, + maximumPower: 0, + interfaces: [], + extraData: new Uint8Array() + }; + config.interfaces.forEach(iface => { + var interfaceInfo = { + interfaceNumber: iface.interfaceNumber, + alternates: [] + }; + iface.alternates.forEach(alternate => { + var alternateInfo = { + alternateSetting: alternate.alternateSetting, + classCode: alternate.interfaceClass, + subclassCode: alternate.interfaceSubclass, + protocolCode: alternate.interfaceProtocol, + interfaceName: stringToMojoString16(alternate.interfaceName), + endpoints: [], + extraData: new Uint8Array() + }; + alternate.endpoints.forEach(endpoint => { + var endpointInfo = { + endpointNumber: endpoint.endpointNumber, + packetSize: endpoint.packetSize, + synchronizationType: mojom.UsbSynchronizationType.NONE, + usageType: mojom.UsbUsageType.DATA, + pollingInterval: 0, + extraData: new Uint8Array() + }; + switch (endpoint.direction) { + case "in": + endpointInfo.direction = mojom.UsbTransferDirection.INBOUND; + break; + case "out": + endpointInfo.direction = mojom.UsbTransferDirection.OUTBOUND; + break; + } + switch (endpoint.type) { + case "bulk": + endpointInfo.type = mojom.UsbTransferType.BULK; + break; + case "interrupt": + endpointInfo.type = mojom.UsbTransferType.INTERRUPT; + break; + case "isochronous": + endpointInfo.type = mojom.UsbTransferType.ISOCHRONOUS; + break; + } + alternateInfo.endpoints.push(endpointInfo); + }); + interfaceInfo.alternates.push(alternateInfo); + }); + configInfo.interfaces.push(interfaceInfo); + }); + deviceInfo.configurations.push(configInfo); + }); + return deviceInfo; +} + +function convertMojoDeviceFilters(input) { + let output = []; + input.forEach(filter => { + output.push(convertMojoDeviceFilter(filter)); + }); + return output; +} + +function convertMojoDeviceFilter(input) { + let output = {}; + if (input.hasVendorId) + output.vendorId = input.vendorId; + if (input.hasProductId) + output.productId = input.productId; + if (input.hasClassCode) + output.classCode = input.classCode; + if (input.hasSubclassCode) + output.subclassCode = input.subclassCode; + if (input.hasProtocolCode) + output.protocolCode = input.protocolCode; + if (input.serialNumber) + output.serialNumber = mojoString16ToString(input.serialNumber); + return output; +} + +class FakeDevice { + constructor(deviceInit) { + this.info_ = deviceInit; + this.opened_ = false; + this.currentConfiguration_ = null; + this.claimedInterfaces_ = new Map(); + } + + getConfiguration() { + if (this.currentConfiguration_) { + return Promise.resolve({ + value: this.currentConfiguration_.configurationValue }); + } else { + return Promise.resolve({ value: 0 }); + } + } + + open() { + assert_false(this.opened_); + this.opened_ = true; + return Promise.resolve({result: {success: mojom.UsbOpenDeviceSuccess.OK}}); + } + + close() { + assert_true(this.opened_); + this.opened_ = false; + return Promise.resolve(); + } + + setConfiguration(value) { + assert_true(this.opened_); + + let selectedConfiguration = this.info_.configurations.find( + configuration => configuration.configurationValue == value); + // Blink should never request an invalid configuration. + assert_not_equals(selectedConfiguration, undefined); + this.currentConfiguration_ = selectedConfiguration; + return Promise.resolve({ success: true }); + } + + async claimInterface(interfaceNumber) { + assert_true(this.opened_); + assert_false(this.currentConfiguration_ == null, 'device configured'); + assert_false(this.claimedInterfaces_.has(interfaceNumber), + 'interface already claimed'); + + const protectedInterfaces = new Set([ + mojom.USB_AUDIO_CLASS, + mojom.USB_HID_CLASS, + mojom.USB_MASS_STORAGE_CLASS, + mojom.USB_SMART_CARD_CLASS, + mojom.USB_VIDEO_CLASS, + mojom.USB_AUDIO_VIDEO_CLASS, + mojom.USB_WIRELESS_CLASS, + ]); + + let iface = this.currentConfiguration_.interfaces.find( + iface => iface.interfaceNumber == interfaceNumber); + // Blink should never request an invalid interface or alternate. + assert_false(iface == undefined); + if (iface.alternates.some( + alt => protectedInterfaces.has(alt.interfaceClass))) { + return {result: mojom.UsbClaimInterfaceResult.kProtectedClass}; + } + + this.claimedInterfaces_.set(interfaceNumber, 0); + return {result: mojom.UsbClaimInterfaceResult.kSuccess}; + } + + releaseInterface(interfaceNumber) { + assert_true(this.opened_); + assert_false(this.currentConfiguration_ == null, 'device configured'); + assert_true(this.claimedInterfaces_.has(interfaceNumber)); + this.claimedInterfaces_.delete(interfaceNumber); + return Promise.resolve({ success: true }); + } + + setInterfaceAlternateSetting(interfaceNumber, alternateSetting) { + assert_true(this.opened_); + assert_false(this.currentConfiguration_ == null, 'device configured'); + assert_true(this.claimedInterfaces_.has(interfaceNumber)); + + let iface = this.currentConfiguration_.interfaces.find( + iface => iface.interfaceNumber == interfaceNumber); + // Blink should never request an invalid interface or alternate. + assert_false(iface == undefined); + assert_true(iface.alternates.some( + x => x.alternateSetting == alternateSetting)); + this.claimedInterfaces_.set(interfaceNumber, alternateSetting); + return Promise.resolve({ success: true }); + } + + reset() { + assert_true(this.opened_); + return Promise.resolve({ success: true }); + } + + clearHalt(endpoint) { + assert_true(this.opened_); + assert_false(this.currentConfiguration_ == null, 'device configured'); + // TODO(reillyg): Assert that endpoint is valid. + return Promise.resolve({ success: true }); + } + + async controlTransferIn(params, length, timeout) { + assert_true(this.opened_); + + if ((params.recipient == mojom.UsbControlTransferRecipient.INTERFACE || + params.recipient == mojom.UsbControlTransferRecipient.ENDPOINT) && + this.currentConfiguration_ == null) { + return { + status: mojom.UsbTransferStatus.PERMISSION_DENIED, + }; + } + + return { + status: mojom.UsbTransferStatus.OK, + data: { + buffer: [ + length >> 8, length & 0xff, params.request, params.value >> 8, + params.value & 0xff, params.index >> 8, params.index & 0xff + ] + } + }; + } + + async controlTransferOut(params, data, timeout) { + assert_true(this.opened_); + + if ((params.recipient == mojom.UsbControlTransferRecipient.INTERFACE || + params.recipient == mojom.UsbControlTransferRecipient.ENDPOINT) && + this.currentConfiguration_ == null) { + return { + status: mojom.UsbTransferStatus.PERMISSION_DENIED, + }; + } + + return {status: mojom.UsbTransferStatus.OK, bytesWritten: data.byteLength}; + } + + genericTransferIn(endpointNumber, length, timeout) { + assert_true(this.opened_); + assert_false(this.currentConfiguration_ == null, 'device configured'); + // TODO(reillyg): Assert that endpoint is valid. + let data = new Array(length); + for (let i = 0; i < length; ++i) + data[i] = i & 0xff; + return Promise.resolve( + {status: mojom.UsbTransferStatus.OK, data: {buffer: data}}); + } + + genericTransferOut(endpointNumber, data, timeout) { + assert_true(this.opened_); + assert_false(this.currentConfiguration_ == null, 'device configured'); + // TODO(reillyg): Assert that endpoint is valid. + return Promise.resolve( + {status: mojom.UsbTransferStatus.OK, bytesWritten: data.byteLength}); + } + + isochronousTransferIn(endpointNumber, packetLengths, timeout) { + assert_true(this.opened_); + assert_false(this.currentConfiguration_ == null, 'device configured'); + // TODO(reillyg): Assert that endpoint is valid. + let data = new Array(packetLengths.reduce((a, b) => a + b, 0)); + let dataOffset = 0; + let packets = new Array(packetLengths.length); + for (let i = 0; i < packetLengths.length; ++i) { + for (let j = 0; j < packetLengths[i]; ++j) + data[dataOffset++] = j & 0xff; + packets[i] = { + length: packetLengths[i], + transferredLength: packetLengths[i], + status: mojom.UsbTransferStatus.OK + }; + } + return Promise.resolve({data: {buffer: data}, packets: packets}); + } + + isochronousTransferOut(endpointNumber, data, packetLengths, timeout) { + assert_true(this.opened_); + assert_false(this.currentConfiguration_ == null, 'device configured'); + // TODO(reillyg): Assert that endpoint is valid. + let packets = new Array(packetLengths.length); + for (let i = 0; i < packetLengths.length; ++i) { + packets[i] = { + length: packetLengths[i], + transferredLength: packetLengths[i], + status: mojom.UsbTransferStatus.OK + }; + } + return Promise.resolve({ packets: packets }); + } +} + +class FakeWebUsbService { + constructor() { + this.receiver_ = new mojom.WebUsbServiceReceiver(this); + this.devices_ = new Map(); + this.devicesByGuid_ = new Map(); + this.client_ = null; + this.nextGuid_ = 0; + } + + addBinding(handle) { + this.receiver_.$.bindHandle(handle); + } + + addDevice(fakeDevice, info) { + let device = { + fakeDevice: fakeDevice, + guid: (this.nextGuid_++).toString(), + info: info, + receivers: [], + }; + this.devices_.set(fakeDevice, device); + this.devicesByGuid_.set(device.guid, device); + if (this.client_) + this.client_.onDeviceAdded(fakeDeviceInitToDeviceInfo(device.guid, info)); + } + + async forgetDevice(guid) { + // Permissions are currently untestable through WPT. + } + + removeDevice(fakeDevice) { + let device = this.devices_.get(fakeDevice); + if (!device) + throw new Error('Cannot remove unknown device.'); + + for (const receiver of device.receivers) + receiver.$.close(); + this.devices_.delete(device.fakeDevice); + this.devicesByGuid_.delete(device.guid); + if (this.client_) { + this.client_.onDeviceRemoved( + fakeDeviceInitToDeviceInfo(device.guid, device.info)); + } + } + + removeAllDevices() { + this.devices_.forEach(device => { + for (const receiver of device.receivers) + receiver.$.close(); + this.client_.onDeviceRemoved( + fakeDeviceInitToDeviceInfo(device.guid, device.info)); + }); + this.devices_.clear(); + this.devicesByGuid_.clear(); + } + + getDevices() { + let devices = []; + this.devices_.forEach(device => { + devices.push(fakeDeviceInitToDeviceInfo(device.guid, device.info)); + }); + return Promise.resolve({ results: devices }); + } + + getDevice(guid, request) { + let retrievedDevice = this.devicesByGuid_.get(guid); + if (retrievedDevice) { + const receiver = + new mojom.UsbDeviceReceiver(new FakeDevice(retrievedDevice.info)); + receiver.$.bindHandle(request.handle); + receiver.onConnectionError.addListener(() => { + if (retrievedDevice.fakeDevice.onclose) + retrievedDevice.fakeDevice.onclose(); + }); + retrievedDevice.receivers.push(receiver); + } else { + request.handle.close(); + } + } + + getPermission(options) { + return new Promise(resolve => { + if (navigator.usb.test.onrequestdevice) { + navigator.usb.test.onrequestdevice( + new USBDeviceRequestEvent(options, resolve)); + } else { + resolve({ result: null }); + } + }); + } + + setClient(client) { + this.client_ = client; + } +} + +class USBDeviceRequestEvent { + constructor(options, resolve) { + this.filters = convertMojoDeviceFilters(options.filters); + this.exclusionFilters = convertMojoDeviceFilters(options.exclusionFilters); + this.resolveFunc_ = resolve; + } + + respondWith(value) { + // Wait until |value| resolves (if it is a Promise). This function returns + // no value. + Promise.resolve(value).then(fakeDevice => { + let device = internal.webUsbService.devices_.get(fakeDevice); + let result = null; + if (device) { + result = fakeDeviceInitToDeviceInfo(device.guid, device.info); + } + this.resolveFunc_({ result: result }); + }, () => { + this.resolveFunc_({ result: null }); + }); + } +} + +// Unlike FakeDevice this class is exported to callers of USBTest.addFakeDevice. +class FakeUSBDevice { + constructor() { + this.onclose = null; + } + + disconnect() { + setTimeout(() => internal.webUsbService.removeDevice(this), 0); + } +} + +class USBTest { + constructor() { + this.onrequestdevice = undefined; + } + + async initialize() { + if (internal.initialized) + return; + + // Be ready to handle 'ReadyForAttachment' message from child iframes. + if ('window' in self) { + getMessagePort(window); + } + + mojom = await loadMojomDefinitions(); + internal.webUsbService = new FakeWebUsbService(); + internal.webUsbServiceInterceptor = + new MojoInterfaceInterceptor(mojom.WebUsbService.$interfaceName); + internal.webUsbServiceInterceptor.oninterfacerequest = + e => internal.webUsbService.addBinding(e.handle); + internal.webUsbServiceInterceptor.start(); + + // Wait for a call to GetDevices() to pass between the renderer and the + // mock in order to establish that everything is set up. + await navigator.usb.getDevices(); + internal.initialized = true; + } + + // Returns a promise that is resolved when the implementation of |usb| in the + // global scope for |context| is controlled by the current context. + attachToContext(context) { + if (!internal.initialized) + throw new Error('Call initialize() before attachToContext()'); + + let target = context.constructor.name === 'Worker' ? context : window; + return getMessagePort(target).then(() => { + return new Promise(resolve => { + internal.messagePort.onmessage = channelEvent => { + switch (channelEvent.data.type) { + case mojom.WebUsbService.$interfaceName: + internal.webUsbService.addBinding(channelEvent.data.handle); + break; + case 'Complete': + resolve(); + break; + } + }; + internal.messagePort.postMessage({ + type: 'Attach', + interfaces: [ + mojom.WebUsbService.$interfaceName, + ] + }); + }); + }); + } + + addFakeDevice(deviceInit) { + if (!internal.initialized) + throw new Error('Call initialize() before addFakeDevice().'); + + // |addDevice| and |removeDevice| are called in a setTimeout callback so + // that tests do not rely on the device being immediately available which + // may not be true for all implementations of this test API. + let fakeDevice = new FakeUSBDevice(); + setTimeout( + () => internal.webUsbService.addDevice(fakeDevice, deviceInit), 0); + return fakeDevice; + } + + reset() { + if (!internal.initialized) + throw new Error('Call initialize() before reset().'); + + // Reset the mocks in a setTimeout callback so that tests do not rely on + // the fact that this polyfill can do this synchronously. + return new Promise(resolve => { + setTimeout(() => { + if (internal.messagePort !== null) + internal.messagePort.close(); + internal.messagePort = null; + internal.webUsbService.removeAllDevices(); + resolve(); + }, 0); + }); + } +} + +navigator.usb.test = new USBTest(); + +})(); diff --git a/test/wpt/tests/resources/chromium/webusb-test.js.headers b/test/wpt/tests/resources/chromium/webusb-test.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/resources/chromium/webusb-test.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/wpt/tests/resources/chromium/webxr-test-math-helper.js b/test/wpt/tests/resources/chromium/webxr-test-math-helper.js new file mode 100644 index 0000000..22c6c12 --- /dev/null +++ b/test/wpt/tests/resources/chromium/webxr-test-math-helper.js @@ -0,0 +1,298 @@ +'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) { + throw new Error("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) { + throw new Error("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) { + throw new Error("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) { + throw new Error("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) { + throw new Error("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); + } + + static mul4x4(m1, m2) { + if (m1 == null || m2 == null) { + return null; + } + + const result = Array(16); + + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 4; col++) { + result[4 * col + row] = 0; + for(let i = 0; i < 4; i++) { + result[4 * col + row] += m1[4 * i + row] * m2[4 * col + i]; + } + } + } + + return result; + } + + // Decomposes a matrix, with the assumption that the passed in matrix is + // a rigid transformation (i.e. position and rotation *only*!). + // The result is an object with `position` and `orientation` keys, which should + // be compatible with FakeXRRigidTransformInit. + // The implementation should match the behavior of gfx::Transform, but assumes + // that scale, skew & perspective are not present in the matrix so it could be + // simplified. + static decomposeRigidTransform(m) { + 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 position = [m30, m31, m32]; + const orientation = [0, 0, 0, 0]; + + const trace = m00 + m11 + m22; + if (trace > 0) { + const S = Math.sqrt(trace + 1) * 2; + orientation[3] = 0.25 * S; + orientation[0] = (m12 - m21) / S; + orientation[1] = (m20 - m02) / S; + orientation[2] = (m01 - m10) / S; + } else if (m00 > m11 && m00 > m22) { + const S = Math.sqrt(1.0 + m00 - m11 - m22) * 2; + orientation[3] = (m12 - m21) / S; + orientation[0] = 0.25 * S; + orientation[1] = (m01 + m10) / S; + orientation[2] = (m20 + m02) / S; + } else if (m11 > m22) { + const S = Math.sqrt(1.0 + m11 - m00 - m22) * 2; + orientation[3] = (m20 - m02) / S; + orientation[0] = (m01 + m10) / S; + orientation[1] = 0.25 * S; + orientation[2] = (m12 + m21) / S; + } else { + const S = Math.sqrt(1.0 + m22 - m00 - m11) * 2; + orientation[3] = (m01 - m10) / S; + orientation[0] = (m20 + m02) / S; + orientation[1] = (m12 + m21) / S; + orientation[2] = 0.25 * S; + } + + return { position, orientation }; + } + + static identity() { + return [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1 + ]; + }; +} + +XRMathHelper.EPSILON = 0.001; diff --git a/test/wpt/tests/resources/chromium/webxr-test-math-helper.js.headers b/test/wpt/tests/resources/chromium/webxr-test-math-helper.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/resources/chromium/webxr-test-math-helper.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/wpt/tests/resources/chromium/webxr-test.js b/test/wpt/tests/resources/chromium/webxr-test.js new file mode 100644 index 0000000..c5eb1bd --- /dev/null +++ b/test/wpt/tests/resources/chromium/webxr-test.js @@ -0,0 +1,2125 @@ +import * as vrMojom from '/gen/device/vr/public/mojom/vr_service.mojom.m.js'; +import * as xrSessionMojom from '/gen/device/vr/public/mojom/xr_session.mojom.m.js'; +import {GamepadHand, GamepadMapping} from '/gen/device/gamepad/public/mojom/gamepad.mojom.m.js'; + +// This polyfill library implements the WebXR Test API as specified here: +// https://github.com/immersive-web/webxr-test-api + +const defaultMojoFromFloor = { + matrix: [1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, -1.65, 0, 1] +}; +const default_stage_parameters = { + mojoFromFloor: defaultMojoFromFloor, + bounds: null +}; + +const default_framebuffer_scale = 0.7; + +function getMatrixFromTransform(transform) { + const x = transform.orientation[0]; + const y = transform.orientation[1]; + const z = transform.orientation[2]; + const w = transform.orientation[3]; + + const m11 = 1.0 - 2.0 * (y * y + z * z); + const m21 = 2.0 * (x * y + z * w); + const m31 = 2.0 * (x * z - y * w); + + const m12 = 2.0 * (x * y - z * w); + const m22 = 1.0 - 2.0 * (x * x + z * z); + const m32 = 2.0 * (y * z + x * w); + + const m13 = 2.0 * (x * z + y * w); + const m23 = 2.0 * (y * z - x * w); + const m33 = 1.0 - 2.0 * (x * x + y * y); + + const m14 = transform.position[0]; + const m24 = transform.position[1]; + const m34 = transform.position[2]; + + // Column-major linearized order is expected. + return [m11, m21, m31, 0, + m12, m22, m32, 0, + m13, m23, m33, 0, + m14, m24, m34, 1]; +} + +function getPoseFromTransform(transform) { + const [px, py, pz] = transform.position; + const [ox, oy, oz, ow] = transform.orientation; + return { + position: {x: px, y: py, z: pz}, + orientation: {x: ox, y: oy, z: oz, w: ow}, + }; +} + +function composeGFXTransform(fakeTransformInit) { + return {matrix: getMatrixFromTransform(fakeTransformInit)}; +} + +// Value equality for camera image init objects - they must contain `width` & +// `height` properties and may contain `pixels` property. +function isSameCameraImageInit(rhs, lhs) { + return lhs.width === rhs.width && lhs.height === rhs.height && lhs.pixels === rhs.pixels; +} + +class ChromeXRTest { + constructor() { + this.mockVRService_ = new MockVRService(); + } + + // WebXR Test API + simulateDeviceConnection(init_params) { + return Promise.resolve(this.mockVRService_._addRuntime(init_params)); + } + + disconnectAllDevices() { + this.mockVRService_._removeAllRuntimes(); + return Promise.resolve(); + } + + simulateUserActivation(callback) { + if (window.top !== window) { + // test_driver.click only works for the toplevel frame. This alternate + // Chrome-specific method is sufficient for starting an XR session in an + // iframe, and is used in platform-specific tests. + // + // TODO(https://github.com/web-platform-tests/wpt/issues/20282): use + // a cross-platform method if available. + xr_debug('simulateUserActivation', 'use eventSender'); + document.addEventListener('click', callback); + eventSender.mouseMoveTo(0, 0); + eventSender.mouseDown(); + eventSender.mouseUp(); + document.removeEventListener('click', callback); + return; + } + const button = document.createElement('button'); + button.textContent = 'click to continue test'; + button.style.display = 'block'; + button.style.fontSize = '20px'; + button.style.padding = '10px'; + button.onclick = () => { + callback(); + document.body.removeChild(button); + }; + document.body.appendChild(button); + test_driver.click(button); + } + + // Helper method leveraged by chrome-specific setups. + Debug(name, msg) { + console.log(new Date().toISOString() + ' DEBUG[' + name + '] ' + msg); + } +} + +// Mocking class definitions + +// Mock service implements the VRService mojo interface. +class MockVRService { + constructor() { + this.receiver_ = new vrMojom.VRServiceReceiver(this); + this.runtimes_ = []; + + this.interceptor_ = + new MojoInterfaceInterceptor(vrMojom.VRService.$interfaceName); + this.interceptor_.oninterfacerequest = + e => this.receiver_.$.bindHandle(e.handle); + this.interceptor_.start(); + } + + // WebXR Test API Implementation Helpers + _addRuntime(fakeDeviceInit) { + const runtime = new MockRuntime(fakeDeviceInit, this); + this.runtimes_.push(runtime); + + if (this.client_) { + this.client_.onDeviceChanged(); + } + + return runtime; + } + + _removeAllRuntimes() { + if (this.client_) { + this.client_.onDeviceChanged(); + } + + this.runtimes_ = []; + } + + _removeRuntime(device) { + const index = this.runtimes_.indexOf(device); + if (index >= 0) { + this.runtimes_.splice(index, 1); + if (this.client_) { + this.client_.onDeviceChanged(); + } + } + } + + // VRService overrides + setClient(client) { + if (this.client_) { + throw new Error("setClient should only be called once"); + } + + this.client_ = client; + } + + requestSession(sessionOptions) { + const requests = []; + // Request a session from all the runtimes. + for (let i = 0; i < this.runtimes_.length; i++) { + requests[i] = this.runtimes_[i]._requestRuntimeSession(sessionOptions); + } + + return Promise.all(requests).then((results) => { + // Find and return the first successful result. + for (let i = 0; i < results.length; i++) { + if (results[i].session) { + // Construct a dummy metrics recorder + const metricsRecorderPtr = new vrMojom.XRSessionMetricsRecorderRemote(); + metricsRecorderPtr.$.bindNewPipeAndPassReceiver().handle.close(); + + const success = { + session: results[i].session, + metricsRecorder: metricsRecorderPtr, + }; + + return {result: {success}}; + } + } + + // If there were no successful results, returns a null session. + return { + result: {failureReason: vrMojom.RequestSessionError.NO_RUNTIME_FOUND} + }; + }); + } + + supportsSession(sessionOptions) { + const requests = []; + // Check supports on all the runtimes. + for (let i = 0; i < this.runtimes_.length; i++) { + requests[i] = this.runtimes_[i]._runtimeSupportsSession(sessionOptions); + } + + return Promise.all(requests).then((results) => { + // Find and return the first successful result. + for (let i = 0; i < results.length; i++) { + if (results[i].supportsSession) { + return results[i]; + } + } + + // If there were no successful results, returns false. + return {supportsSession: false}; + }); + } + + exitPresent() { + return Promise.resolve(); + } + + setFramesThrottled(throttled) { + this.setFramesThrottledImpl(throttled); + } + + // We cannot override the mojom interceptors via the prototype; so this method + // and the above indirection exist to allow overrides by internal code. + setFramesThrottledImpl(throttled) {} + + // Only handles asynchronous calls to makeXrCompatible. Synchronous calls are + // not supported in Javascript. + makeXrCompatible() { + if (this.runtimes_.length == 0) { + return { + xrCompatibleResult: vrMojom.XrCompatibleResult.kNoDeviceAvailable + }; + } + return {xrCompatibleResult: vrMojom.XrCompatibleResult.kAlreadyCompatible}; + } +} + +class FakeXRAnchorController { + constructor() { + // Private properties. + this.device_ = null; + this.id_ = null; + this.dirty_ = true; + + // Properties backing up public attributes / methods. + this.deleted_ = false; + this.paused_ = false; + this.anchorOrigin_ = XRMathHelper.identity(); + } + + // WebXR Test API (Anchors Extension) + get deleted() { + return this.deleted_; + } + + pauseTracking() { + if(!this.paused_) { + this.paused_ = true; + this.dirty_ = true; + } + } + + resumeTracking() { + if(this.paused_) { + this.paused_ = false; + this.dirty_ = true; + } + } + + stopTracking() { + if(!this.deleted_) { + this.device_._deleteAnchorController(this.id_); + + this.deleted_ = true; + this.dirty_ = true; + } + } + + setAnchorOrigin(anchorOrigin) { + this.anchorOrigin_ = getMatrixFromTransform(anchorOrigin); + this.dirty_ = true; + } + + // Internal implementation: + set id(value) { + this.id_ = value; + } + + set device(value) { + this.device_ = value; + } + + get dirty() { + return this.dirty_; + } + + get paused() { + return this.paused_; + } + + _markProcessed() { + this.dirty_ = false; + } + + _getAnchorOrigin() { + return this.anchorOrigin_; + } +} + +// Implements XRFrameDataProvider and XRPresentationProvider. Maintains a mock +// for XRPresentationProvider. Implements FakeXRDevice test API. +class MockRuntime { + // Mapping from string feature names to the corresponding mojo types. + // This is exposed as a member for extensibility. + static _featureToMojoMap = { + 'viewer': xrSessionMojom.XRSessionFeature.REF_SPACE_VIEWER, + 'local': xrSessionMojom.XRSessionFeature.REF_SPACE_LOCAL, + 'local-floor': xrSessionMojom.XRSessionFeature.REF_SPACE_LOCAL_FLOOR, + 'bounded-floor': xrSessionMojom.XRSessionFeature.REF_SPACE_BOUNDED_FLOOR, + 'unbounded': xrSessionMojom.XRSessionFeature.REF_SPACE_UNBOUNDED, + 'hit-test': xrSessionMojom.XRSessionFeature.HIT_TEST, + 'dom-overlay': xrSessionMojom.XRSessionFeature.DOM_OVERLAY, + 'light-estimation': xrSessionMojom.XRSessionFeature.LIGHT_ESTIMATION, + 'anchors': xrSessionMojom.XRSessionFeature.ANCHORS, + 'depth-sensing': xrSessionMojom.XRSessionFeature.DEPTH, + 'secondary-views': xrSessionMojom.XRSessionFeature.SECONDARY_VIEWS, + 'camera-access': xrSessionMojom.XRSessionFeature.CAMERA_ACCESS, + 'layers': xrSessionMojom.XRSessionFeature.LAYERS, + }; + + static _sessionModeToMojoMap = { + "inline": xrSessionMojom.XRSessionMode.kInline, + "immersive-vr": xrSessionMojom.XRSessionMode.kImmersiveVr, + "immersive-ar": xrSessionMojom.XRSessionMode.kImmersiveAr, + }; + + static _environmentBlendModeToMojoMap = { + "opaque": vrMojom.XREnvironmentBlendMode.kOpaque, + "alpha-blend": vrMojom.XREnvironmentBlendMode.kAlphaBlend, + "additive": vrMojom.XREnvironmentBlendMode.kAdditive, + }; + + static _interactionModeToMojoMap = { + "screen-space": vrMojom.XRInteractionMode.kScreenSpace, + "world-space": vrMojom.XRInteractionMode.kWorldSpace, + }; + + constructor(fakeDeviceInit, service) { + this.sessionClient_ = null; + this.presentation_provider_ = new MockXRPresentationProvider(); + + this.pose_ = null; + this.next_frame_id_ = 0; + this.bounds_ = null; + this.send_mojo_space_reset_ = false; + this.stageParameters_ = null; + this.stageParametersId_ = 1; + + this.service_ = service; + + this.framesOfReference = {}; + + this.input_sources_ = new Map(); + this.next_input_source_index_ = 1; + + // Currently active hit test subscriptons. + this.hitTestSubscriptions_ = new Map(); + // Currently active transient hit test subscriptions. + this.transientHitTestSubscriptions_ = new Map(); + // ID of the next subscription to be assigned. + this.next_hit_test_id_ = 1n; + + this.anchor_controllers_ = new Map(); + // ID of the next anchor to be assigned. + this.next_anchor_id_ = 1n; + // Anchor creation callback (initially null, can be set by tests). + this.anchor_creation_callback_ = null; + + this.depthSensingData_ = null; + this.depthSensingDataDirty_ = false; + + let supportedModes = []; + if (fakeDeviceInit.supportedModes) { + supportedModes = fakeDeviceInit.supportedModes.slice(); + if (fakeDeviceInit.supportedModes.length === 0) { + supportedModes = ["inline"]; + } + } else { + // Back-compat mode. + console.warn("Please use `supportedModes` to signal which modes are supported by this device."); + if (fakeDeviceInit.supportsImmersive == null) { + throw new TypeError("'supportsImmersive' must be set"); + } + + supportedModes = ["inline"]; + if (fakeDeviceInit.supportsImmersive) { + supportedModes.push("immersive-vr"); + } + } + + this.supportedModes_ = this._convertModesToEnum(supportedModes); + if (this.supportedModes_.length == 0) { + console.error("Device has empty supported modes array!"); + throw new InvalidStateError(); + } + + if (fakeDeviceInit.viewerOrigin != null) { + this.setViewerOrigin(fakeDeviceInit.viewerOrigin); + } + + if (fakeDeviceInit.floorOrigin != null) { + this.setFloorOrigin(fakeDeviceInit.floorOrigin); + } + + if (fakeDeviceInit.world) { + this.setWorld(fakeDeviceInit.world); + } + + if (fakeDeviceInit.depthSensingData) { + this.setDepthSensingData(fakeDeviceInit.depthSensingData); + } + + this.defaultFramebufferScale_ = default_framebuffer_scale; + this.enviromentBlendMode_ = this._convertBlendModeToEnum(fakeDeviceInit.environmentBlendMode); + this.interactionMode_ = this._convertInteractionModeToEnum(fakeDeviceInit.interactionMode); + + // This appropriately handles if the coordinates are null + this.setBoundsGeometry(fakeDeviceInit.boundsCoordinates); + + this.setViews(fakeDeviceInit.views, fakeDeviceInit.secondaryViews); + + // Need to support webVR which doesn't have a notion of features + this._setFeatures(fakeDeviceInit.supportedFeatures || []); + } + + // WebXR Test API + setViews(primaryViews, secondaryViews) { + this.cameraImage_ = null; + this.primaryViews_ = []; + this.secondaryViews_ = []; + let xOffset = 0; + if (primaryViews) { + this.primaryViews_ = []; + xOffset = this._setViews(primaryViews, xOffset, this.primaryViews_); + const cameraImage = this._findCameraImage(primaryViews); + + if (cameraImage) { + this.cameraImage_ = cameraImage; + } + } + + if (secondaryViews) { + this.secondaryViews_ = []; + this._setViews(secondaryViews, xOffset, this.secondaryViews_); + const cameraImage = this._findCameraImage(secondaryViews); + + if (cameraImage) { + if (!isSameCameraImageInit(this.cameraImage_, cameraImage)) { + throw new Error("If present, camera resolutions on each view must match each other!" + + " Secondary views' camera doesn't match primary views."); + } + + this.cameraImage_ = cameraImage; + } + } + } + + disconnect() { + this.service_._removeRuntime(this); + this.presentation_provider_._close(); + if (this.sessionClient_) { + this.sessionClient_.$.close(); + this.sessionClient_ = null; + } + + return Promise.resolve(); + } + + setViewerOrigin(origin, emulatedPosition = false) { + const p = origin.position; + const q = origin.orientation; + this.pose_ = { + orientation: { x: q[0], y: q[1], z: q[2], w: q[3] }, + position: { x: p[0], y: p[1], z: p[2] }, + emulatedPosition: emulatedPosition, + angularVelocity: null, + linearVelocity: null, + angularAcceleration: null, + linearAcceleration: null, + inputState: null, + poseIndex: 0 + }; + } + + clearViewerOrigin() { + this.pose_ = null; + } + + setFloorOrigin(floorOrigin) { + if (!this.stageParameters_) { + this.stageParameters_ = default_stage_parameters; + this.stageParameters_.bounds = this.bounds_; + } + + // floorOrigin is passed in as mojoFromFloor. + this.stageParameters_.mojoFromFloor = + {matrix: getMatrixFromTransform(floorOrigin)}; + + this._onStageParametersUpdated(); + } + + clearFloorOrigin() { + if (this.stageParameters_) { + this.stageParameters_ = null; + this._onStageParametersUpdated(); + } + } + + setBoundsGeometry(bounds) { + if (bounds == null) { + this.bounds_ = null; + } else if (bounds.length < 3) { + throw new Error("Bounds must have a length of at least 3"); + } else { + this.bounds_ = bounds; + } + + // We can only set bounds if we have stageParameters set; otherwise, we + // don't know the transform from local space to bounds space. + // We'll cache the bounds so that they can be set in the future if the + // floorLevel transform is set, but we won't update them just yet. + if (this.stageParameters_) { + this.stageParameters_.bounds = this.bounds_; + this._onStageParametersUpdated(); + } + } + + simulateResetPose() { + this.send_mojo_space_reset_ = true; + } + + simulateVisibilityChange(visibilityState) { + let mojoState = null; + switch (visibilityState) { + case "visible": + mojoState = vrMojom.XRVisibilityState.VISIBLE; + break; + case "visible-blurred": + mojoState = vrMojom.XRVisibilityState.VISIBLE_BLURRED; + break; + case "hidden": + mojoState = vrMojom.XRVisibilityState.HIDDEN; + break; + } + if (mojoState && this.sessionClient_) { + this.sessionClient_.onVisibilityStateChanged(mojoState); + } + } + + simulateInputSourceConnection(fakeInputSourceInit) { + const index = this.next_input_source_index_; + this.next_input_source_index_++; + + const source = new MockXRInputSource(fakeInputSourceInit, index, this); + this.input_sources_.set(index, source); + return source; + } + + // WebXR Test API Hit Test extensions + setWorld(world) { + this.world_ = world; + } + + clearWorld() { + this.world_ = null; + } + + // WebXR Test API Anchor extensions + setAnchorCreationCallback(callback) { + this.anchor_creation_callback_ = callback; + } + + setHitTestSourceCreationCallback(callback) { + this.hit_test_source_creation_callback_ = callback; + } + + // WebXR Test API Lighting estimation extensions + setLightEstimate(fakeXrLightEstimateInit) { + if (!fakeXrLightEstimateInit.sphericalHarmonicsCoefficients) { + throw new TypeError("sphericalHarmonicsCoefficients must be set"); + } + + if (fakeXrLightEstimateInit.sphericalHarmonicsCoefficients.length != 27) { + throw new TypeError("Must supply all 27 sphericalHarmonicsCoefficients"); + } + + if (fakeXrLightEstimateInit.primaryLightDirection && fakeXrLightEstimateInit.primaryLightDirection.w != 0) { + throw new TypeError("W component of primaryLightDirection must be 0"); + } + + if (fakeXrLightEstimateInit.primaryLightIntensity && fakeXrLightEstimateInit.primaryLightIntensity.w != 1) { + throw new TypeError("W component of primaryLightIntensity must be 1"); + } + + // If the primaryLightDirection or primaryLightIntensity aren't set, we need to set them + // to the defaults that the spec expects. ArCore will either give us everything or nothing, + // so these aren't nullable on the mojom. + if (!fakeXrLightEstimateInit.primaryLightDirection) { + fakeXrLightEstimateInit.primaryLightDirection = { x: 0.0, y: 1.0, z: 0.0, w: 0.0 }; + } + + if (!fakeXrLightEstimateInit.primaryLightIntensity) { + fakeXrLightEstimateInit.primaryLightIntensity = { x: 0.0, y: 0.0, z: 0.0, w: 1.0 }; + } + + let c = fakeXrLightEstimateInit.sphericalHarmonicsCoefficients; + + this.light_estimate_ = { + lightProbe: { + // XRSphereicalHarmonics + sphericalHarmonics: { + coefficients: [ + { red: c[0], green: c[1], blue: c[2] }, + { red: c[3], green: c[4], blue: c[5] }, + { red: c[6], green: c[7], blue: c[8] }, + { red: c[9], green: c[10], blue: c[11] }, + { red: c[12], green: c[13], blue: c[14] }, + { red: c[15], green: c[16], blue: c[17] }, + { red: c[18], green: c[19], blue: c[20] }, + { red: c[21], green: c[22], blue: c[23] }, + { red: c[24], green: c[25], blue: c[26] } + ] + }, + // Vector3dF + mainLightDirection: { + x: fakeXrLightEstimateInit.primaryLightDirection.x, + y: fakeXrLightEstimateInit.primaryLightDirection.y, + z: fakeXrLightEstimateInit.primaryLightDirection.z + }, + // RgbTupleF32 + mainLightIntensity: { + red: fakeXrLightEstimateInit.primaryLightIntensity.x, + green: fakeXrLightEstimateInit.primaryLightIntensity.y, + blue: fakeXrLightEstimateInit.primaryLightIntensity.z + } + } + } + } + + // WebXR Test API depth Sensing Extensions + setDepthSensingData(depthSensingData) { + for(const key of ["depthData", "normDepthBufferFromNormView", "rawValueToMeters", "width", "height"]) { + if(!(key in depthSensingData)) { + throw new TypeError("Required key not present. Key: " + key); + } + } + + if(depthSensingData.depthData != null) { + // Create new object w/ properties based on the depthSensingData, but + // convert the FakeXRRigidTransformInit into a transformation matrix object. + this.depthSensingData_ = Object.assign({}, + depthSensingData, { + normDepthBufferFromNormView: composeGFXTransform(depthSensingData.normDepthBufferFromNormView), + }); + } else { + throw new TypeError("`depthData` is not set"); + } + + this.depthSensingDataDirty_ = true; + } + + clearDepthSensingData() { + this.depthSensingData_ = null; + this.depthSensingDataDirty_ = true; + } + + // Internal Implementation/Helper Methods + _convertModeToEnum(sessionMode) { + if (sessionMode in MockRuntime._sessionModeToMojoMap) { + return MockRuntime._sessionModeToMojoMap[sessionMode]; + } + + throw new TypeError("Unrecognized value for XRSessionMode enum: " + sessionMode); + } + + _convertModesToEnum(sessionModes) { + return sessionModes.map(mode => this._convertModeToEnum(mode)); + } + + _convertBlendModeToEnum(blendMode) { + if (blendMode in MockRuntime._environmentBlendModeToMojoMap) { + return MockRuntime._environmentBlendModeToMojoMap[blendMode]; + } else { + if (this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { + return vrMojom.XREnvironmentBlendMode.kAdditive; + } else if (this.supportedModes_.includes( + xrSessionMojom.XRSessionMode.kImmersiveVr)) { + return vrMojom.XREnvironmentBlendMode.kOpaque; + } + } + } + + _convertInteractionModeToEnum(interactionMode) { + if (interactionMode in MockRuntime._interactionModeToMojoMap) { + return MockRuntime._interactionModeToMojoMap[interactionMode]; + } else { + return vrMojom.XRInteractionMode.kWorldSpace; + } + } + + _setViews(deviceViews, xOffset, views) { + for (let i = 0; i < deviceViews.length; i++) { + views[i] = this._getView(deviceViews[i], xOffset); + xOffset += deviceViews[i].resolution.width; + } + + return xOffset; + } + + _findCameraImage(views) { + const viewWithCamera = views.find(view => view.cameraImageInit); + if (viewWithCamera) { + //If we have one view with a camera resolution, all views should have the same camera resolution. + const allViewsHaveSameCamera = views.every( + view => isSameCameraImageInit(view.cameraImageInit, viewWithCamera.cameraImageInit)); + + if (!allViewsHaveSameCamera) { + throw new Error("If present, camera resolutions on each view must match each other!"); + } + + return viewWithCamera.cameraImageInit; + } + + return null; + } + + _onStageParametersUpdated() { + // Indicate for the frame loop that the stage parameters have been updated. + this.stageParametersId_++; + } + + _getDefaultViews() { + if (this.primaryViews_) { + return this.primaryViews_; + } + + const viewport_size = 20; + return [{ + eye: vrMojom.XREye.kLeft, + fieldOfView: { + upDegrees: 48.316, + downDegrees: 50.099, + leftDegrees: 50.899, + rightDegrees: 35.197 + }, + mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform({ + position: [-0.032, 0, 0], + orientation: [0, 0, 0, 1] + })), + viewport: { x: 0, y: 0, width: viewport_size, height: viewport_size } + }, + { + eye: vrMojom.XREye.kRight, + fieldOfView: { + upDegrees: 48.316, + downDegrees: 50.099, + leftDegrees: 50.899, + rightDegrees: 35.197 + }, + mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform({ + position: [0.032, 0, 0], + orientation: [0, 0, 0, 1] + })), + viewport: { x: viewport_size, y: 0, width: viewport_size, height: viewport_size } + }]; + } + + // This function converts between the matrix provided by the WebXR test API + // and the internal data representation. + _getView(fakeXRViewInit, xOffset) { + let fov = null; + + if (fakeXRViewInit.fieldOfView) { + fov = { + upDegrees: fakeXRViewInit.fieldOfView.upDegrees, + downDegrees: fakeXRViewInit.fieldOfView.downDegrees, + leftDegrees: fakeXRViewInit.fieldOfView.leftDegrees, + rightDegrees: fakeXRViewInit.fieldOfView.rightDegrees + }; + } else { + const m = fakeXRViewInit.projectionMatrix; + + function toDegrees(tan) { + return Math.atan(tan) * 180 / Math.PI; + } + + const leftTan = (1 - m[8]) / m[0]; + const rightTan = (1 + m[8]) / m[0]; + const upTan = (1 + m[9]) / m[5]; + const downTan = (1 - m[9]) / m[5]; + + fov = { + upDegrees: toDegrees(upTan), + downDegrees: toDegrees(downTan), + leftDegrees: toDegrees(leftTan), + rightDegrees: toDegrees(rightTan) + }; + } + + let viewEye = vrMojom.XREye.kNone; + // The eye passed in corresponds to the values in the WebXR spec, which are + // the strings "none", "left", and "right". They should be converted to the + // corresponding values of XREye in vr_service.mojom. + switch(fakeXRViewInit.eye) { + case "none": + viewEye = vrMojom.XREye.kNone; + break; + case "left": + viewEye = vrMojom.XREye.kLeft; + break; + case "right": + viewEye = vrMojom.XREye.kRight; + break; + } + + return { + eye: viewEye, + fieldOfView: fov, + mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform(fakeXRViewInit.viewOffset)), + viewport: { + x: xOffset, + y: 0, + width: fakeXRViewInit.resolution.width, + height: fakeXRViewInit.resolution.height + }, + isFirstPersonObserver: fakeXRViewInit.isFirstPersonObserver ? true : false, + viewOffset: composeGFXTransform(fakeXRViewInit.viewOffset) + }; + } + + _setFeatures(supportedFeatures) { + function convertFeatureToMojom(feature) { + if (feature in MockRuntime._featureToMojoMap) { + return MockRuntime._featureToMojoMap[feature]; + } else { + return xrSessionMojom.XRSessionFeature.INVALID; + } + } + + this.supportedFeatures_ = []; + + for (let i = 0; i < supportedFeatures.length; i++) { + const feature = convertFeatureToMojom(supportedFeatures[i]); + if (feature !== xrSessionMojom.XRSessionFeature.INVALID) { + this.supportedFeatures_.push(feature); + } + } + } + + // These methods are intended to be used by MockXRInputSource only. + _addInputSource(source) { + if (!this.input_sources_.has(source.source_id_)) { + this.input_sources_.set(source.source_id_, source); + } + } + + _removeInputSource(source) { + this.input_sources_.delete(source.source_id_); + } + + // These methods are intended to be used by FakeXRAnchorController only. + _deleteAnchorController(controllerId) { + this.anchor_controllers_.delete(controllerId); + } + + // Extension point for non-standard modules. + _injectAdditionalFrameData(options, frameData) { + } + + // Mojo function implementations. + + // XRFrameDataProvider implementation. + getFrameData(options) { + return new Promise((resolve) => { + + const populatePose = () => { + const mojo_space_reset = this.send_mojo_space_reset_; + this.send_mojo_space_reset_ = false; + + if (this.pose_) { + this.pose_.poseIndex++; + } + + // Setting the input_state to null tests a slightly different path than + // the browser tests where if the last input source is removed, the device + // code always sends up an empty array, but it's also valid mojom to send + // up a null array. + let input_state = null; + if (this.input_sources_.size > 0) { + input_state = []; + for (const input_source of this.input_sources_.values()) { + input_state.push(input_source._getInputSourceState()); + } + } + + let frame_views = this.primaryViews_; + for (let i = 0; i < this.primaryViews_.length; i++) { + this.primaryViews_[i].mojoFromView = + this._getMojoFromViewerWithOffset(this.primaryViews_[i].viewOffset); + } + if (this.enabledFeatures_.includes(xrSessionMojom.XRSessionFeature.SECONDARY_VIEWS)) { + for (let i = 0; i < this.secondaryViews_.length; i++) { + this.secondaryViews_[i].mojoFromView = + this._getMojoFromViewerWithOffset(this.secondaryViews_[i].viewOffset); + } + + frame_views = frame_views.concat(this.secondaryViews_); + } + + const frameData = { + mojoFromViewer: this.pose_, + views: frame_views, + mojoSpaceReset: mojo_space_reset, + inputState: input_state, + timeDelta: { + // window.performance.now() is in milliseconds, so convert to microseconds. + microseconds: BigInt(Math.floor(window.performance.now() * 1000)), + }, + frameId: this.next_frame_id_, + bufferHolder: null, + cameraImageSize: this.cameraImage_ ? { + width: this.cameraImage_.width, + height: this.cameraImage_.height + } : null, + renderingTimeRatio: 0, + stageParameters: this.stageParameters_, + stageParametersId: this.stageParametersId_, + lightEstimationData: this.light_estimate_ + }; + + this.next_frame_id_++; + + this._calculateHitTestResults(frameData); + + this._calculateAnchorInformation(frameData); + + this._calculateDepthInformation(frameData); + + this._injectAdditionalFrameData(options, frameData); + + resolve({frameData}); + }; + + if(this.sessionOptions_.mode == xrSessionMojom.XRSessionMode.kInline) { + // Inline sessions should not have a delay introduced since it causes them + // to miss a vsync blink-side and delays propagation of changes that happened + // within a rAFcb by one frame (e.g. setViewerOrigin() calls would take 2 frames + // to propagate). + populatePose(); + } else { + // For immerive sessions, add additional delay to allow for anchor creation + // promises to run. + setTimeout(populatePose, 3); // note: according to MDN, the timeout is not exact + } + }); + } + + getEnvironmentIntegrationProvider(environmentProviderRequest) { + if (this.environmentProviderReceiver_) { + this.environmentProviderReceiver_.$.close(); + } + this.environmentProviderReceiver_ = + new vrMojom.XREnvironmentIntegrationProviderReceiver(this); + this.environmentProviderReceiver_.$.bindHandle( + environmentProviderRequest.handle); + } + + // XREnvironmentIntegrationProvider implementation: + subscribeToHitTest(nativeOriginInformation, entityTypes, ray) { + if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { + // Reject outside of AR. + return Promise.resolve({ + result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, + subscriptionId : 0n + }); + } + + if (!this._nativeOriginKnown(nativeOriginInformation)) { + return Promise.resolve({ + result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, + subscriptionId : 0n + }); + } + + // Reserve the id for hit test source: + const id = this.next_hit_test_id_++; + const hitTestParameters = { isTransient: false, profileName: null }; + const controller = new FakeXRHitTestSourceController(id); + + + return this._shouldHitTestSourceCreationSucceed(hitTestParameters, controller) + .then((succeeded) => { + if(succeeded) { + // Store the subscription information as-is (including controller): + this.hitTestSubscriptions_.set(id, { nativeOriginInformation, entityTypes, ray, controller }); + + return Promise.resolve({ + result : vrMojom.SubscribeToHitTestResult.SUCCESS, + subscriptionId : id + }); + } else { + return Promise.resolve({ + result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, + subscriptionId : 0n + }); + } + }); + } + + subscribeToHitTestForTransientInput(profileName, entityTypes, ray){ + if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { + // Reject outside of AR. + return Promise.resolve({ + result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, + subscriptionId : 0n + }); + } + + const id = this.next_hit_test_id_++; + const hitTestParameters = { isTransient: true, profileName: profileName }; + const controller = new FakeXRHitTestSourceController(id); + + // Check if we have hit test source creation callback. + // If yes, ask it if the hit test source creation should succeed. + // If no, for back-compat, assume the hit test source creation succeeded. + return this._shouldHitTestSourceCreationSucceed(hitTestParameters, controller) + .then((succeeded) => { + if(succeeded) { + // Store the subscription information as-is (including controller): + this.transientHitTestSubscriptions_.set(id, { profileName, entityTypes, ray, controller }); + + return Promise.resolve({ + result : vrMojom.SubscribeToHitTestResult.SUCCESS, + subscriptionId : id + }); + } else { + return Promise.resolve({ + result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, + subscriptionId : 0n + }); + } + }); + } + + unsubscribeFromHitTest(subscriptionId) { + let controller = null; + if(this.transientHitTestSubscriptions_.has(subscriptionId)){ + controller = this.transientHitTestSubscriptions_.get(subscriptionId).controller; + this.transientHitTestSubscriptions_.delete(subscriptionId); + } else if(this.hitTestSubscriptions_.has(subscriptionId)){ + controller = this.hitTestSubscriptions_.get(subscriptionId).controller; + this.hitTestSubscriptions_.delete(subscriptionId); + } + + if(controller) { + controller.deleted = true; + } + } + + createAnchor(nativeOriginInformation, nativeOriginFromAnchor) { + return new Promise((resolve) => { + if(this.anchor_creation_callback_ == null) { + resolve({ + result : vrMojom.CreateAnchorResult.FAILURE, + anchorId : 0n + }); + + return; + } + + const mojoFromNativeOrigin = this._getMojoFromNativeOrigin(nativeOriginInformation); + if(mojoFromNativeOrigin == null) { + resolve({ + result : vrMojom.CreateAnchorResult.FAILURE, + anchorId : 0n + }); + + return; + } + + const mojoFromAnchor = XRMathHelper.mul4x4(mojoFromNativeOrigin, nativeOriginFromAnchor); + + const anchorCreationParameters = { + requestedAnchorOrigin: mojoFromAnchor, + isAttachedToEntity: false, + }; + + const anchorController = new FakeXRAnchorController(); + + this.anchor_creation_callback_(anchorCreationParameters, anchorController) + .then((result) => { + if(result) { + // If the test allowed the anchor creation, + // store the anchor controller & return success. + + const anchor_id = this.next_anchor_id_; + this.next_anchor_id_++; + + this.anchor_controllers_.set(anchor_id, anchorController); + anchorController.device = this; + anchorController.id = anchor_id; + + resolve({ + result : vrMojom.CreateAnchorResult.SUCCESS, + anchorId : anchor_id + }); + } else { + // The test has rejected anchor creation. + resolve({ + result : vrMojom.CreateAnchorResult.FAILURE, + anchorId : 0n + }); + } + }) + .catch(() => { + // The test threw an error, treat anchor creation as failed. + resolve({ + result : vrMojom.CreateAnchorResult.FAILURE, + anchorId : 0n + }); + }); + }); + } + + createPlaneAnchor(planeFromAnchor, planeId) { + return new Promise((resolve) => { + + // Not supported yet. + + resolve({ + result : vrMojom.CreateAnchorResult.FAILURE, + anchorId : 0n, + }); + }); + } + + detachAnchor(anchorId) {} + + // Utility function + _requestRuntimeSession(sessionOptions) { + return this._runtimeSupportsSession(sessionOptions).then((result) => { + // The JavaScript bindings convert c_style_names to camelCase names. + const options = { + transportMethod: + vrMojom.XRPresentationTransportMethod.SUBMIT_AS_MAILBOX_HOLDER, + waitForTransferNotification: true, + waitForRenderNotification: true, + waitForGpuFence: false, + }; + + let submit_frame_sink; + if (result.supportsSession) { + submit_frame_sink = { + clientReceiver: this.presentation_provider_._getClientReceiver(), + provider: this.presentation_provider_._bindProvider(sessionOptions), + transportOptions: options + }; + + const dataProviderPtr = new vrMojom.XRFrameDataProviderRemote(); + this.dataProviderReceiver_ = + new vrMojom.XRFrameDataProviderReceiver(this); + this.dataProviderReceiver_.$.bindHandle( + dataProviderPtr.$.bindNewPipeAndPassReceiver().handle); + this.sessionOptions_ = sessionOptions; + + this.sessionClient_ = new vrMojom.XRSessionClientRemote(); + const clientReceiver = this.sessionClient_.$.bindNewPipeAndPassReceiver(); + + const enabled_features = []; + for (let i = 0; i < sessionOptions.requiredFeatures.length; i++) { + if (this.supportedFeatures_.indexOf(sessionOptions.requiredFeatures[i]) !== -1) { + enabled_features.push(sessionOptions.requiredFeatures[i]); + } else { + return Promise.resolve({session: null}); + } + } + + for (let i =0; i < sessionOptions.optionalFeatures.length; i++) { + if (this.supportedFeatures_.indexOf(sessionOptions.optionalFeatures[i]) !== -1) { + enabled_features.push(sessionOptions.optionalFeatures[i]); + } + } + + this.enabledFeatures_ = enabled_features; + + return Promise.resolve({ + session: { + submitFrameSink: submit_frame_sink, + dataProvider: dataProviderPtr, + clientReceiver: clientReceiver, + enabledFeatures: enabled_features, + deviceConfig: { + defaultFramebufferScale: this.defaultFramebufferScale_, + supportsViewportScaling: true, + depthConfiguration: + enabled_features.includes(xrSessionMojom.XRSessionFeature.DEPTH) ? { + depthUsage: vrMojom.XRDepthUsage.kCPUOptimized, + depthDataFormat: vrMojom.XRDepthDataFormat.kLuminanceAlpha, + } : null, + views: this._getDefaultViews(), + }, + enviromentBlendMode: this.enviromentBlendMode_, + interactionMode: this.interactionMode_ + } + }); + } else { + return Promise.resolve({session: null}); + } + }); + } + + _runtimeSupportsSession(options) { + let result = this.supportedModes_.includes(options.mode); + + if (options.requiredFeatures.includes(xrSessionMojom.XRSessionFeature.DEPTH) + || options.optionalFeatures.includes(xrSessionMojom.XRSessionFeature.DEPTH)) { + result &= options.depthOptions.usagePreferences.includes(vrMojom.XRDepthUsage.kCPUOptimized); + result &= options.depthOptions.dataFormatPreferences.includes(vrMojom.XRDepthDataFormat.kLuminanceAlpha); + } + + return Promise.resolve({ + supportsSession: result, + }); + } + + // Private functions - utilities: + _nativeOriginKnown(nativeOriginInformation){ + + if (nativeOriginInformation.inputSourceSpaceInfo !== undefined) { + if (!this.input_sources_.has(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId)) { + // Unknown input source. + return false; + } + + return true; + } else if (nativeOriginInformation.referenceSpaceType !== undefined) { + // Bounded_floor & unbounded ref spaces are not yet supported for AR: + if (nativeOriginInformation.referenceSpaceType == vrMojom.XRReferenceSpaceType.kUnbounded + || nativeOriginInformation.referenceSpaceType == vrMojom.XRReferenceSpaceType.kBoundedFloor) { + return false; + } + + return true; + } else { + // Planes and anchors are not yet supported by the mock interface. + return false; + } + } + + // Private functions - anchors implementation: + + // Modifies passed in frameData to add anchor information. + _calculateAnchorInformation(frameData) { + if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { + return; + } + + frameData.anchorsData = {allAnchorsIds: [], updatedAnchorsData: []}; + for(const [id, controller] of this.anchor_controllers_) { + frameData.anchorsData.allAnchorsIds.push(id); + + // Send the entire anchor data over if there was a change since last GetFrameData(). + if(controller.dirty) { + const anchorData = {id}; + if(!controller.paused) { + anchorData.mojoFromAnchor = getPoseFromTransform( + XRMathHelper.decomposeRigidTransform( + controller._getAnchorOrigin())); + } + + controller._markProcessed(); + + frameData.anchorsData.updatedAnchorsData.push(anchorData); + } + } + } + + // Private functions - depth sensing implementation: + + // Modifies passed in frameData to add anchor information. + _calculateDepthInformation(frameData) { + if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { + return; + } + + if (!this.enabledFeatures_.includes(xrSessionMojom.XRSessionFeature.DEPTH)) { + return; + } + + // If we don't have a current depth data, we'll return null + // (i.e. no data is not a valid data, so it cannot be "StillValid"). + if (this.depthSensingData_ == null) { + frameData.depthData = null; + return; + } + + if(!this.depthSensingDataDirty_) { + frameData.depthData = { dataStillValid: {}}; + return; + } + + frameData.depthData = { + updatedDepthData: { + timeDelta: frameData.timeDelta, + normTextureFromNormView: this.depthSensingData_.normDepthBufferFromNormView, + rawValueToMeters: this.depthSensingData_.rawValueToMeters, + size: { width: this.depthSensingData_.width, height: this.depthSensingData_.height }, + pixelData: { bytes: this.depthSensingData_.depthData } + } + }; + + this.depthSensingDataDirty_ = false; + } + + // Private functions - hit test implementation: + + // Returns a Promise<bool> that signifies whether hit test source creation should succeed. + // If we have a hit test source creation callback installed, invoke it and return its result. + // If it's not installed, for back-compat just return a promise that resolves to true. + _shouldHitTestSourceCreationSucceed(hitTestParameters, controller) { + if(this.hit_test_source_creation_callback_) { + return this.hit_test_source_creation_callback_(hitTestParameters, controller); + } else { + return Promise.resolve(true); + } + } + + // Modifies passed in frameData to add hit test results. + _calculateHitTestResults(frameData) { + if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { + return; + } + + frameData.hitTestSubscriptionResults = {results: [], + 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 [mojo_ray_origin, mojo_ray_direction] = this._transformRayToMojoSpace( + subscription.ray, + mojo_from_native_origin + ); + + const results = this._hitTestWorld(mojo_ray_origin, mojo_ray_direction, subscription.entityTypes); + frameData.hitTestSubscriptionResults.results.push( + {subscriptionId: id, hitTestResults: results}); + } + + // Transient hit test: + const mojo_from_viewer = this._getMojoFromViewer(); + + for (const [id, subscription] of this.transientHitTestSubscriptions_) { + const result = {subscriptionId: id, + inputSourceIdToHitTestResults: new Map()}; + + // Find all input sources that match the profile name: + const matching_input_sources = Array.from(this.input_sources_.values()) + .filter(input_source => input_source.profiles_.includes(subscription.profileName)); + + for (const input_source of matching_input_sources) { + const mojo_from_native_origin = input_source._getMojoFromInputSource(mojo_from_viewer); + + const [mojo_ray_origin, mojo_ray_direction] = this._transformRayToMojoSpace( + subscription.ray, + mojo_from_native_origin + ); + + const results = this._hitTestWorld(mojo_ray_origin, mojo_ray_direction, subscription.entityTypes); + + result.inputSourceIdToHitTestResults.set(input_source.source_id_, results); + } + + frameData.hitTestSubscriptionResults.transientInputResults.push(result); + } + } + + // Returns 2-element array [origin, direction] of a ray in mojo space. + // |ray| is expressed relative to native origin. + _transformRayToMojoSpace(ray, mojo_from_native_origin) { + const ray_origin = { + x: ray.origin.x, + y: ray.origin.y, + z: ray.origin.z, + w: 1 + }; + const ray_direction = { + x: ray.direction.x, + y: ray.direction.y, + z: 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); + + return [mojo_ray_origin, mojo_ray_direction]; + } + + // 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).map((hitTest) => { + delete hitTest.distance; + return hitTest; + }); + } + + // 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": vrMojom.EntityTypeForHitTest.POINT, + "plane": vrMojom.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.vertices[0]; + const point_B = face.vertices[1]; + const point_C = face.vertices[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) < XRMathHelper.EPSILON) { + // 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) > (1 - XRMathHelper.EPSILON)) { + // 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)) > (1 - XRMathHelper.EPSILON) + ? 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. + } + + z_axis = normalize(z_axis); + 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 = {planeId: 0n}; + hitResult.distance = distance; // Extend the object with additional information used by higher layers. + // It will not be serialized over mojom. + + const matrix = new Array(16); + + matrix[0] = x_axis.x; + matrix[1] = x_axis.y; + matrix[2] = x_axis.z; + matrix[3] = 0; + + matrix[4] = y_axis.x; + matrix[5] = y_axis.y; + matrix[6] = y_axis.z; + matrix[7] = 0; + + matrix[8] = z_axis.x; + matrix[9] = z_axis.y; + matrix[10] = z_axis.z; + matrix[11] = 0; + + matrix[12] = intersection_point.x; + matrix[13] = intersection_point.y; + matrix[14] = intersection_point.z; + matrix[15] = 1; + + hitResult.mojoFromResult = getPoseFromTransform( + XRMathHelper.decomposeRigidTransform(matrix)); + return hitResult; + } + } + } + + _getMojoFromViewer() { + if (!this.pose_) { + return XRMathHelper.identity(); + } + 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); + } + + _getMojoFromViewerWithOffset(viewOffset) { + return { matrix: XRMathHelper.mul4x4(this._getMojoFromViewer(), viewOffset.matrix) }; + } + + _getMojoFromNativeOrigin(nativeOriginInformation) { + const mojo_from_viewer = this._getMojoFromViewer(); + + if (nativeOriginInformation.inputSourceSpaceInfo !== undefined) { + if (!this.input_sources_.has(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId)) { + return null; + } else { + const inputSource = this.input_sources_.get(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId); + return inputSource._getMojoFromInputSource(mojo_from_viewer); + } + } else if (nativeOriginInformation.referenceSpaceType !== undefined) { + switch (nativeOriginInformation.referenceSpaceType) { + case vrMojom.XRReferenceSpaceType.kLocal: + return XRMathHelper.identity(); + case vrMojom.XRReferenceSpaceType.kLocalFloor: + if (this.stageParameters_ == null || this.stageParameters_.mojoFromFloor == null) { + console.warn("Standing transform not available."); + return null; + } + return this.stageParameters_.mojoFromFloor.matrix; + case vrMojom.XRReferenceSpaceType.kViewer: + return mojo_from_viewer; + case vrMojom.XRReferenceSpaceType.kBoundedFloor: + return null; + case vrMojom.XRReferenceSpaceType.kUnbounded: + return null; + default: + throw new TypeError("Unrecognized XRReferenceSpaceType!"); + } + } else { + // Anchors & planes are not yet supported for hit test. + return null; + } + } +} + +class MockXRInputSource { + constructor(fakeInputSourceInit, id, pairedDevice) { + this.source_id_ = id; + this.pairedDevice_ = pairedDevice; + this.handedness_ = fakeInputSourceInit.handedness; + this.target_ray_mode_ = fakeInputSourceInit.targetRayMode; + + if (fakeInputSourceInit.pointerOrigin == null) { + throw new TypeError("FakeXRInputSourceInit.pointerOrigin is required."); + } + + this.setPointerOrigin(fakeInputSourceInit.pointerOrigin); + this.setProfiles(fakeInputSourceInit.profiles); + + this.primary_input_pressed_ = false; + if (fakeInputSourceInit.selectionStarted != null) { + this.primary_input_pressed_ = fakeInputSourceInit.selectionStarted; + } + + this.primary_input_clicked_ = false; + if (fakeInputSourceInit.selectionClicked != null) { + this.primary_input_clicked_ = fakeInputSourceInit.selectionClicked; + } + + this.primary_squeeze_pressed_ = false; + this.primary_squeeze_clicked_ = false; + + this.mojo_from_input_ = null; + if (fakeInputSourceInit.gripOrigin != null) { + this.setGripOrigin(fakeInputSourceInit.gripOrigin); + } + + // This properly handles if supportedButtons were not specified. + this.setSupportedButtons(fakeInputSourceInit.supportedButtons); + + this.emulated_position_ = false; + this.desc_dirty_ = true; + } + + // WebXR Test API + setHandedness(handedness) { + if (this.handedness_ != handedness) { + this.desc_dirty_ = true; + this.handedness_ = handedness; + } + } + + setTargetRayMode(targetRayMode) { + if (this.target_ray_mode_ != targetRayMode) { + this.desc_dirty_ = true; + this.target_ray_mode_ = targetRayMode; + } + } + + setProfiles(profiles) { + this.desc_dirty_ = true; + this.profiles_ = profiles; + } + + setGripOrigin(transform, emulatedPosition = false) { + // grip_origin was renamed to mojo_from_input in mojo + this.mojo_from_input_ = composeGFXTransform(transform); + this.emulated_position_ = emulatedPosition; + + // Technically, setting the grip shouldn't make the description dirty, but + // the webxr-test-api sets our pointer as mojoFromPointer; however, we only + // support it across mojom as inputFromPointer, so we need to recalculate it + // whenever the grip moves. + this.desc_dirty_ = true; + } + + clearGripOrigin() { + // grip_origin was renamed to mojo_from_input in mojo + if (this.mojo_from_input_ != null) { + this.mojo_from_input_ = null; + this.emulated_position_ = false; + this.desc_dirty_ = true; + } + } + + setPointerOrigin(transform, emulatedPosition = false) { + // pointer_origin is mojo_from_pointer. + this.desc_dirty_ = true; + this.mojo_from_pointer_ = composeGFXTransform(transform); + this.emulated_position_ = emulatedPosition; + } + + disconnect() { + this.pairedDevice_._removeInputSource(this); + } + + reconnect() { + this.pairedDevice_._addInputSource(this); + } + + startSelection() { + this.primary_input_pressed_ = true; + if (this.gamepad_) { + this.gamepad_.buttons[0].pressed = true; + this.gamepad_.buttons[0].touched = true; + } + } + + endSelection() { + if (!this.primary_input_pressed_) { + throw new Error("Attempted to end selection which was not started"); + } + + this.primary_input_pressed_ = false; + this.primary_input_clicked_ = true; + + if (this.gamepad_) { + this.gamepad_.buttons[0].pressed = false; + this.gamepad_.buttons[0].touched = false; + } + } + + simulateSelect() { + this.primary_input_clicked_ = true; + } + + setSupportedButtons(supportedButtons) { + this.gamepad_ = null; + this.supported_buttons_ = []; + + // If there are no supported buttons, we can stop now. + if (supportedButtons == null || supportedButtons.length < 1) { + return; + } + + const supported_button_map = {}; + this.gamepad_ = this._getEmptyGamepad(); + for (let i = 0; i < supportedButtons.length; i++) { + const buttonType = supportedButtons[i].buttonType; + this.supported_buttons_.push(buttonType); + supported_button_map[buttonType] = supportedButtons[i]; + } + + // Let's start by building the button state in order of priority: + // Primary button is index 0. + this.gamepad_.buttons.push({ + pressed: this.primary_input_pressed_, + touched: this.primary_input_pressed_, + value: this.primary_input_pressed_ ? 1.0 : 0.0 + }); + + // Now add the rest of our buttons + this._addGamepadButton(supported_button_map['grip']); + this._addGamepadButton(supported_button_map['touchpad']); + this._addGamepadButton(supported_button_map['thumbstick']); + this._addGamepadButton(supported_button_map['optional-button']); + this._addGamepadButton(supported_button_map['optional-thumbstick']); + + // Finally, back-fill placeholder buttons/axes + for (let i = 0; i < this.gamepad_.buttons.length; i++) { + if (this.gamepad_.buttons[i] == null) { + this.gamepad_.buttons[i] = { + pressed: false, + touched: false, + value: 0 + }; + } + } + + for (let i=0; i < this.gamepad_.axes.length; i++) { + if (this.gamepad_.axes[i] == null) { + this.gamepad_.axes[i] = 0; + } + } + } + + updateButtonState(buttonState) { + if (this.supported_buttons_.indexOf(buttonState.buttonType) == -1) { + throw new Error("Tried to update state on an unsupported button"); + } + + const buttonIndex = this._getButtonIndex(buttonState.buttonType); + const axesStartIndex = this._getAxesStartIndex(buttonState.buttonType); + + if (buttonIndex == -1) { + throw new Error("Unknown Button Type!"); + } + + // is this a 'squeeze' button? + if (buttonIndex === this._getButtonIndex('grip')) { + // squeeze + if (buttonState.pressed) { + this.primary_squeeze_pressed_ = true; + } else if (this.gamepad_.buttons[buttonIndex].pressed) { + this.primary_squeeze_clicked_ = true; + this.primary_squeeze_pressed_ = false; + } else { + this.primary_squeeze_clicked_ = false; + this.primary_squeeze_pressed_ = false; + } + } + + this.gamepad_.buttons[buttonIndex].pressed = buttonState.pressed; + this.gamepad_.buttons[buttonIndex].touched = buttonState.touched; + this.gamepad_.buttons[buttonIndex].value = buttonState.pressedValue; + + if (axesStartIndex != -1) { + this.gamepad_.axes[axesStartIndex] = buttonState.xValue == null ? 0.0 : buttonState.xValue; + this.gamepad_.axes[axesStartIndex + 1] = buttonState.yValue == null ? 0.0 : buttonState.yValue; + } + } + + // DOM Overlay Extensions + setOverlayPointerPosition(x, y) { + this.overlay_pointer_position_ = {x: x, y: y}; + } + + // Helpers for Mojom + _getInputSourceState() { + const input_state = {}; + + input_state.sourceId = this.source_id_; + input_state.isAuxiliary = false; + + input_state.primaryInputPressed = this.primary_input_pressed_; + input_state.primaryInputClicked = this.primary_input_clicked_; + + input_state.primarySqueezePressed = this.primary_squeeze_pressed_; + input_state.primarySqueezeClicked = this.primary_squeeze_clicked_; + // Setting the input source's "clicked" state should generate one "select" + // event. Reset the input value to prevent it from continuously generating + // events. + this.primary_input_clicked_ = false; + // Setting the input source's "clicked" state should generate one "squeeze" + // event. Reset the input value to prevent it from continuously generating + // events. + this.primary_squeeze_clicked_ = false; + + input_state.mojoFromInput = this.mojo_from_input_; + + input_state.gamepad = this.gamepad_; + + input_state.emulatedPosition = this.emulated_position_; + + if (this.desc_dirty_) { + const input_desc = {}; + + switch (this.target_ray_mode_) { + case 'gaze': + input_desc.targetRayMode = vrMojom.XRTargetRayMode.GAZING; + break; + case 'tracked-pointer': + input_desc.targetRayMode = vrMojom.XRTargetRayMode.POINTING; + break; + case 'screen': + input_desc.targetRayMode = vrMojom.XRTargetRayMode.TAPPING; + break; + default: + throw new Error('Unhandled target ray mode ' + this.target_ray_mode_); + } + + switch (this.handedness_) { + case 'left': + input_desc.handedness = vrMojom.XRHandedness.LEFT; + break; + case 'right': + input_desc.handedness = vrMojom.XRHandedness.RIGHT; + break; + default: + input_desc.handedness = vrMojom.XRHandedness.NONE; + break; + } + + // Mojo requires us to send the pointerOrigin as relative to the grip + // space. If we don't have a grip space, we'll just assume that there + // is a grip at identity. This allows tests to simulate controllers that + // are really just a pointer with no tracked grip, though we will end up + // exposing that grip space. + let mojo_from_input = XRMathHelper.identity(); + switch (this.target_ray_mode_) { + case 'gaze': + case 'screen': + // For gaze and screen space, we won't have a mojo_from_input; however + // the "input" position is just the viewer, so use mojo_from_viewer. + mojo_from_input = this.pairedDevice_._getMojoFromViewer(); + break; + case 'tracked-pointer': + // If we have a tracked grip position (e.g. mojo_from_input), then use + // that. If we don't, then we'll just set the pointer offset directly, + // using identity as set above. + if (this.mojo_from_input_) { + mojo_from_input = this.mojo_from_input_.matrix; + } + break; + default: + throw new Error('Unhandled target ray mode ' + this.target_ray_mode_); + } + + // To convert mojo_from_pointer to input_from_pointer, we need: + // input_from_pointer = input_from_mojo * mojo_from_pointer + // Since we store mojo_from_input, we need to invert it here before + // multiplying. + let input_from_mojo = XRMathHelper.inverse(mojo_from_input); + input_desc.inputFromPointer = {}; + input_desc.inputFromPointer.matrix = + XRMathHelper.mul4x4(input_from_mojo, this.mojo_from_pointer_.matrix); + + input_desc.profiles = this.profiles_; + + input_state.description = input_desc; + + this.desc_dirty_ = false; + } + + // Pointer data for DOM Overlay, set by setOverlayPointerPosition() + if (this.overlay_pointer_position_) { + input_state.overlayPointerPosition = this.overlay_pointer_position_; + this.overlay_pointer_position_ = null; + } + + return input_state; + } + + _getEmptyGamepad() { + // Mojo complains if some of the properties on Gamepad are null, so set + // everything to reasonable defaults that tests can override. + const gamepad = { + connected: true, + id: [], + timestamp: 0n, + axes: [], + buttons: [], + touchEvents: [], + mapping: GamepadMapping.GamepadMappingStandard, + displayId: 0, + }; + + switch (this.handedness_) { + case 'left': + gamepad.hand = GamepadHand.GamepadHandLeft; + break; + case 'right': + gamepad.hand = GamepadHand.GamepadHandRight; + break; + default: + gamepad.hand = GamepadHand.GamepadHandNone; + break; + } + + return gamepad; + } + + _addGamepadButton(buttonState) { + if (buttonState == null) { + return; + } + + const buttonIndex = this._getButtonIndex(buttonState.buttonType); + const axesStartIndex = this._getAxesStartIndex(buttonState.buttonType); + + if (buttonIndex == -1) { + throw new Error("Unknown Button Type!"); + } + + this.gamepad_.buttons[buttonIndex] = { + pressed: buttonState.pressed, + touched: buttonState.touched, + value: buttonState.pressedValue + }; + + // Add x/y value if supported. + if (axesStartIndex != -1) { + this.gamepad_.axes[axesStartIndex] = (buttonState.xValue == null ? 0.0 : buttonSate.xValue); + this.gamepad_.axes[axesStartIndex + 1] = (buttonState.yValue == null ? 0.0 : buttonSate.yValue); + } + } + + // General Helper methods + _getButtonIndex(buttonType) { + switch (buttonType) { + case 'grip': + return 1; + case 'touchpad': + return 2; + case 'thumbstick': + return 3; + case 'optional-button': + return 4; + case 'optional-thumbstick': + return 5; + default: + return -1; + } + } + + _getAxesStartIndex(buttonType) { + switch (buttonType) { + case 'touchpad': + return 0; + case 'thumbstick': + return 2; + case 'optional-thumbstick': + return 4; + default: + return -1; + } + } + + _getMojoFromInputSource(mojo_from_viewer) { + return this.mojo_from_pointer_.matrix; + } +} + +// Mojo helper classes +class FakeXRHitTestSourceController { + constructor(id) { + this.id_ = id; + this.deleted_ = false; + } + + get deleted() { + return this.deleted_; + } + + // Internal setter: + set deleted(value) { + this.deleted_ = value; + } +} + +class MockXRPresentationProvider { + constructor() { + this.receiver_ = null; + this.submit_frame_count_ = 0; + this.missing_frame_count_ = 0; + } + + _bindProvider() { + const provider = new vrMojom.XRPresentationProviderRemote(); + + if (this.receiver_) { + this.receiver_.$.close(); + } + this.receiver_ = new vrMojom.XRPresentationProviderReceiver(this); + this.receiver_.$.bindHandle(provider.$.bindNewPipeAndPassReceiver().handle); + return provider; + } + + _getClientReceiver() { + this.submitFrameClient_ = new vrMojom.XRPresentationClientRemote(); + return this.submitFrameClient_.$.bindNewPipeAndPassReceiver(); + } + + // XRPresentationProvider mojo implementation + updateLayerBounds(frameId, leftBounds, rightBounds, sourceSize) {} + + 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(); + } + + submitFrameWithTextureHandle(frameId, texture, syncToken) {} + + submitFrameDrawnIntoTexture(frameId, syncToken, timeWaited) {} + + // Utility methods + _close() { + if (this.receiver_) { + this.receiver_.$.close(); + } + } +} + +// Export these into the global object as a side effect of importing this +// module. +self.ChromeXRTest = ChromeXRTest; +self.MockRuntime = MockRuntime; +self.MockVRService = MockVRService; +self.SubscribeToHitTestResult = vrMojom.SubscribeToHitTestResult; + +navigator.xr.test = new ChromeXRTest(); diff --git a/test/wpt/tests/resources/chromium/webxr-test.js.headers b/test/wpt/tests/resources/chromium/webxr-test.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/wpt/tests/resources/chromium/webxr-test.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 |