@fakes/media-devices
Version:
A interactive fake implementation of MediaDevices interface in the browser for testing
282 lines (252 loc) • 9.79 kB
text/typescript
import { Context } from './context'
import { Deferred } from './Deferred'
import { LocalListenerPropertySync } from './LocalListenerPropertySync'
import { MediaDeviceDescription } from './MediaDeviceDescription'
import { MediaDeviceInfoFake } from './MediaDeviceInfoFake'
import { MediaStreamFake, mediaStreamId } from './MediaStreamFake'
import { initialMediaStreamTrackProperties, MediaStreamTrackFake, TrackKind } from './MediaStreamTrackFake'
import { OpenMediaTracks } from './OpenMediaTracks'
import { UserConsentTracker } from './UserConsentTracker'
type DeviceChangeListener = (this: MediaDevices, ev: Event) => any
const descriptionMatching = (description: MediaDeviceDescription) => (device: MediaDeviceDescription) =>
device.deviceId === description.deviceId && device.groupId === description.groupId && device.kind === description.kind
const fit2 = (actual: string, ideal: string): number => (actual === ideal ? 0 : 1)
const fitExact = (actual: string, ideal: string): number => (actual === ideal ? 0 : Infinity)
type Constraint = (device: MediaDeviceInfoFake) => number
class ConstrainSet {
private readonly _constraints: Constraint[] = []
constructor(private readonly _context: Context, requested: boolean | MediaTrackConstraints) {
if (typeof requested === 'boolean') {
return
}
const deviceId = requested.deviceId
if (deviceId === undefined) {
return
}
if (typeof deviceId === 'string') {
this._constraints.push((device: MediaDeviceInfoFake) => {
return fit2(device.deviceId, deviceId)
})
return
}
if (typeof deviceId === 'object') {
if (Array.isArray(deviceId)) {
this._context.notImplemented.call('An array of deviceIds is not supported right now')
} else {
const exactDeviceId = deviceId.exact
if (exactDeviceId === undefined) {
return
}
if (Array.isArray(exactDeviceId)) {
this._context.notImplemented.call('An array of exact deviceIds is not supported right now')
} else {
this._constraints.push((device: MediaDeviceInfoFake) => {
return fitExact(device.deviceId, exactDeviceId)
})
}
}
}
}
fitnessDistanceFor(device: MediaDeviceInfoFake): number {
return this._constraints.reduce((acc, curr) => acc + curr(device), 0)
}
}
class OverconstrainedError extends DOMException {
readonly constraint: string
constructor(constraint: string, message?: string) {
super(message, 'OverconstrainedError')
this.constraint = constraint
}
}
const selectSettings = (
context: Context,
mediaTrackConstraints: MediaTrackConstraints | boolean,
devices: MediaDeviceInfoFake[],
): MediaDeviceInfoFake | void => {
const constraintSet = new ConstrainSet(context, mediaTrackConstraints)
const viableDevices = devices
.map((device) => {
return {
device,
fitness: constraintSet.fitnessDistanceFor(device),
}
})
.filter((scoredDevice) => scoredDevice.fitness !== Infinity)
viableDevices.sort((a, b) => a.fitness - b.fitness)
if (viableDevices.length === 0) {
return undefined
}
return viableDevices[0].device
}
const trackConstraintsFrom = (
constraints: MediaStreamConstraints,
): {
mediaTrackConstraints: boolean | MediaTrackConstraints
trackKind: TrackKind
deviceKind: MediaDeviceKind
} => {
if (constraints.video) {
const mediaTrackConstraints = constraints.video
const trackKind = 'video'
const deviceKind = 'videoinput'
return {
mediaTrackConstraints,
trackKind,
deviceKind,
}
}
if (constraints.audio) {
const mediaTrackConstraints = constraints.audio
const trackKind = 'audio'
const deviceKind = 'audioinput'
return {
mediaTrackConstraints,
trackKind,
deviceKind,
}
}
throw new Error('with the current assumptions this should not happen')
}
const deepClone = (mediaTrackConstraints: MediaTrackConstraints) => JSON.parse(JSON.stringify(mediaTrackConstraints))
const tryToOpenAStreamFor = (
context: Context,
deferred: Deferred<MediaStream>,
deviceKind: MediaDeviceKind,
trackKind: TrackKind,
mediaTrackConstraints: boolean | MediaTrackConstraints,
allDevices: MediaDeviceInfoFake[],
openMediaTracks: OpenMediaTracks,
): void => {
const devices = allDevices.filter((device) => device.kind === deviceKind)
if (devices.length === 0) {
deferred.reject(new DOMException('Requested device not found', 'NotFoundError'))
return
}
const selectedDevice = selectSettings(context, mediaTrackConstraints, devices)
if (selectedDevice === undefined) {
//constraint name is hardcoded here, because right now we only check for deviceId
// Firefox also has a message and a stack
deferred.reject(new OverconstrainedError('deviceId', ''))
return
}
const constraintObject = typeof mediaTrackConstraints === 'boolean' ? {} : deepClone(mediaTrackConstraints)
const mediaTrack = new MediaStreamTrackFake(
context,
initialMediaStreamTrackProperties(selectedDevice.label, trackKind, constraintObject),
)
openMediaTracks.track(selectedDevice, mediaTrack)
mediaTrack.onTerminated = (track) => openMediaTracks.remove(track)
const mediaTracks = [mediaTrack]
const mediaStream = new MediaStreamFake(context, mediaStreamId(), mediaTracks)
deferred.resolve(mediaStream)
}
// this looks interesting
// https://github.com/fippo/dynamic-getUserMedia/blob/master/content.js
export class MediaDevicesFake extends EventTarget implements MediaDevices {
private readonly _deviceDescriptions: MediaDeviceDescription[] = []
private readonly _onDeviceChangeListener: LocalListenerPropertySync<DeviceChangeListener>
constructor(
private readonly _context: Context,
private readonly _userConsentTracker: UserConsentTracker,
private readonly _openMediaTracks: OpenMediaTracks,
) {
super()
this._onDeviceChangeListener = new LocalListenerPropertySync<DeviceChangeListener>(this, 'devicechange')
}
private get devices(): MediaDeviceInfoFake[] {
return this._deviceDescriptions
.map((description) => {
const { kind } = description
const accessAllowed = this._userConsentTracker.accessAllowedFor(kind)
const deviceId = accessAllowed ? description.deviceId : ''
const label = accessAllowed ? description.label : ''
return {
...description,
deviceId,
label,
}
})
.map((description) => new MediaDeviceInfoFake(this._context, description))
}
get ondevicechange(): DeviceChangeListener | null {
return this._onDeviceChangeListener.get()
}
set ondevicechange(listener: DeviceChangeListener | null) {
this._onDeviceChangeListener.set(listener)
}
enumerateDevices(): Promise<MediaDeviceInfo[]> {
try {
const devices = this.devices
return Promise.resolve(devices)
} catch (e) {
return Promise.reject(e)
}
}
getSupportedConstraints(): MediaTrackSupportedConstraints {
this._context.notImplemented.call('MediaDevicesFake.getSupportedConstraints()')
}
// https://w3c.github.io/mediacapture-main/#methods-5
// https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
// https://blog.addpipe.com/common-getusermedia-errors/
getUserMedia(constraints?: MediaStreamConstraints): Promise<MediaStream> {
this._context.reporter.report(() => ['getUserMedia', 'constraints' + ':' + JSON.stringify(constraints, null, 2)])
if (
constraints === undefined ||
Object.keys(constraints).length === 0 ||
(constraints.video === false && constraints.audio === false)
) {
return Promise.reject(
new TypeError(
`Failed to execute 'getUserMedia' on 'MediaDevices': At least one of audio and video must be requested`,
),
)
}
if (constraints.audio !== undefined && constraints.video !== undefined) {
this._context.notImplemented.call('at the moment there is no support to request audio and video at the same time')
}
const { mediaTrackConstraints, trackKind, deviceKind } = trackConstraintsFrom(constraints)
const deferred = new Deferred<MediaStream>()
this._userConsentTracker.requestPermissionFor({
deviceKind,
granted: () => {
tryToOpenAStreamFor(
this._context,
deferred,
deviceKind,
trackKind,
mediaTrackConstraints,
this.devices,
this._openMediaTracks,
)
},
blocked: () => {
deferred.reject(new DOMException('Permission denied', 'NotAllowedError'))
},
})
return deferred.promise
}
public noDevicesAttached() {
const currentlyAttachedDevices = [...this._deviceDescriptions]
currentlyAttachedDevices.forEach((descriptor) => this.remove(descriptor))
}
public attach(toAdd: MediaDeviceDescription) {
if (this._deviceDescriptions.some(descriptionMatching(toAdd))) {
this._context.notImplemented.call(`device with this description already attached
${JSON.stringify(toAdd, null, 2)}`)
}
// make a defensive copy to stop manipulation after attaching the device
this._deviceDescriptions.push({ ...toAdd })
this.informDeviceChangeListener()
}
public remove(toRemove: MediaDeviceDescription) {
const index = this._deviceDescriptions.findIndex(descriptionMatching(toRemove))
if (index >= 0) {
this._deviceDescriptions.splice(index, 1)
this.informDeviceChangeListener()
}
this._openMediaTracks.allFor(toRemove).forEach((mediaStreamFake) => mediaStreamFake.deviceRemoved())
}
private informDeviceChangeListener() {
this.dispatchEvent(new Event('devicechange'))
}
}