@fakes/media-devices
Version:
A interactive fake implementation of MediaDevices interface in the browser for testing
358 lines (322 loc) • 13.3 kB
text/typescript
import 'jest-extended'
import { defaultContext } from './context'
import { anyCamera, anyDevice, anyMicrophone, anySpeaker } from './DeviceMother'
import './matchers/dom-exception'
import './matchers/to-be-uuid'
import './matchers/to-include-video-track'
import { MediaDevicesFake } from './MediaDevicesFake'
import { OpenMediaTracks } from './OpenMediaTracks'
import {
allConstraintsFalse,
noDeviceWithDeviceId,
passUndefined,
requestedDeviceTypeNotAttached,
Scenario,
} from './Scenarios'
import { UserConsent, UserConsentTracker } from './UserConsentTracker'
describe('attach device', () => {
let fake: MediaDevicesFake
beforeEach(() => {
fake = new MediaDevicesFake(defaultContext(), allPermissionsGranted(), new OpenMediaTracks())
})
describe('attach', () => {
test('inform the listeners', () => {
const ondevicechange = jest.fn()
const eventListener = jest.fn()
fake.ondevicechange = ondevicechange
fake.addEventListener('devicechange', eventListener)
fake.attach(anyDevice())
expect(ondevicechange).toHaveBeenCalled()
expect(eventListener).toHaveBeenCalled()
})
test('no longer inform removed listeners', () => {
const ondevicechange = jest.fn()
const eventListener = jest.fn()
fake.ondevicechange = ondevicechange
fake.addEventListener('devicechange', eventListener)
fake.ondevicechange = null
fake.removeEventListener('devicechange', eventListener)
fake.attach(anyDevice())
expect(ondevicechange).not.toHaveBeenCalled()
expect(eventListener).not.toHaveBeenCalled()
})
test('enumerate devices lists attached device', () => {
const device = anyDevice({
kind: 'audioinput',
label: 'device label',
groupId: 'the group id',
deviceId: 'the device id',
})
fake.attach(device)
return fake.enumerateDevices().then((devices) => {
expect(devices).toHaveLength(1)
const deviceInfo = devices[0]
expect(deviceInfo.kind).toBe('audioinput')
expect(deviceInfo.label).toBe('device label')
expect(deviceInfo.groupId).toBe('the group id')
expect(deviceInfo.deviceId).toBe('the device id')
})
})
test('rejects adding two devices of the same kind with the same groupId:deviceId', () => {
const one = anyDevice({ groupId: 'group id', deviceId: 'device id', kind: 'audioinput' })
const two = anyDevice({ groupId: 'group id', deviceId: 'device id', kind: 'audioinput' })
fake.attach(one)
expect(() => fake.attach(two)).toThrow()
})
test('can attach devices with same groupId:deviceId if they are of a different kind', async () => {
const one = anyDevice({ groupId: 'group id', deviceId: 'default', kind: 'audioinput' })
const two = anyDevice({ groupId: 'group id', deviceId: 'default', kind: 'videoinput' })
fake.attach(one)
fake.attach(two)
expect(await fake.enumerateDevices()).toHaveLength(2)
})
})
describe('remove', () => {
test('inform the listeners', () => {
const ondevicechange = jest.fn()
const eventListener = jest.fn()
const device = anyDevice()
fake.attach(device)
fake.ondevicechange = ondevicechange
fake.addEventListener('devicechange', eventListener)
fake.remove(device)
expect(ondevicechange).toHaveBeenCalled()
expect(eventListener).toHaveBeenCalled()
})
test('enumerate devices no longer lists the device', () => {
const device = anyDevice()
fake.attach(device)
fake.remove(device)
return fake.enumerateDevices().then((devices) => expect(devices).toHaveLength(0))
})
})
describe('getUserMedia', () => {
describe('no constraints', () => {
test('returns type error', () => {
const stream = fake.getUserMedia()
return expect(stream).rejects.toThrow(
new TypeError(
`Failed to execute 'getUserMedia' on 'MediaDevices': At least one of audio and video must be requested`,
),
)
})
test('scenario', async () => {
expect(await runAndReport(fake, passUndefined)).toBe('')
})
})
describe('all passed constraints are false', () => {
test('returns type error', () => {
const stream = fake.getUserMedia(allConstraintsFalse.constraints)
return expect(stream).rejects.toThrow(
new TypeError(
`Failed to execute 'getUserMedia' on 'MediaDevices': At least one of audio and video must be requested`,
),
)
})
test('scenario', async () => {
expect(await runAndReport(fake, allConstraintsFalse)).toBe('')
})
})
describe('not device of this type is attached', () => {
test('return a DOMException', async () => {
const stream = fake.getUserMedia(requestedDeviceTypeNotAttached.constraints)
await expect(stream).rejects.domException(`Requested device not found`, 'NotFoundError')
})
test('scenario', async () => {
expect(await runAndReport(fake, requestedDeviceTypeNotAttached)).toBe('')
})
})
test('not passing video and audio property results in type error with message', () => {
const stream = fake.getUserMedia({})
return expect(stream).rejects.toThrow(
new TypeError(
`Failed to execute 'getUserMedia' on 'MediaDevices': At least one of audio and video must be requested`,
),
)
})
describe('reject promise in case no videoinput device is attached', () => {
test('reject promise in case no videoinput device is attached', () => {
fake.noDevicesAttached()
const stream = fake.getUserMedia(requestedDeviceTypeNotAttached.constraints)
return expect(stream).rejects.toThrow(new DOMException('Requested device not found'))
})
test('scenario', async () => {
fake.noDevicesAttached()
expect(await runAndReport(fake, requestedDeviceTypeNotAttached)).toBe('')
})
})
test('return track for an attached camera', async () => {
fake.attach(anyCamera())
const stream = await fake.getUserMedia({ video: true })
expect(stream).toIncludeVideoTrack()
})
describe('return another device of the same kind in case no device with the given id is attached', () => {
test('scenario', async () => {
fake.attach(anyCamera({ deviceId: 'actually connected' }))
expect(await runAndReport(fake, noDeviceWithDeviceId)).toBe('')
})
})
describe('exact deviceId constraint', () => {
test('return the connected device', async () => {
fake.attach(anyCamera({ deviceId: 'exact device identifier' }))
const stream = await fake.getUserMedia({
video: {
deviceId: {
exact: 'exact device identifier',
},
},
})
expect(stream).toIncludeVideoTrack()
})
test('reject if there is no device with a matching deviceId even if there are other video devices', async () => {
fake.attach(anyCamera({ deviceId: 'not what you are looking for' }))
const response = fake.getUserMedia({
video: {
deviceId: {
exact: 'exact device identifier',
},
},
})
await expect(response).rejects.toThrow()
})
})
test('return videoinput with matching device id', async () => {
fake.attach(anyCamera({ deviceId: 'not this one' }))
fake.attach(anyCamera({ deviceId: 'attached', label: 'match' }))
fake.attach(anyCamera({ deviceId: 'nope' }))
const stream = await fake.getUserMedia({ video: { deviceId: 'attached' } })
expect(stream).toBeDefined()
expect(stream.getTracks()).toHaveLength(1)
const track = stream.getVideoTracks()[0]
expect(track.label).toBe('match')
expect(track.id).toBeUuid()
expect(track.enabled).toBe(true)
expect(track.readyState).toBe('live')
expect(track.kind).toBe('video')
})
test('return audioinput with matching device id', async () => {
fake.attach(anyMicrophone({ deviceId: 'not this one' }))
fake.attach(anyMicrophone({ deviceId: 'attached', label: 'match' }))
fake.attach(anyMicrophone({ deviceId: 'nope' }))
const stream = await fake.getUserMedia({ audio: { deviceId: 'attached' } })
expect(stream).toBeDefined()
expect(stream.getTracks()).toHaveLength(1)
const track = stream.getAudioTracks()[0]
expect(track.label).toBe('match')
expect(track.id).toBeUuid()
expect(track.enabled).toBe(true)
expect(track.readyState).toBe('live')
expect(track.kind).toBe('audio')
})
test('provide access to the passed video constraints', async () => {
fake.attach(anyCamera())
const constraintInput = { facingMode: 'user' }
const stream = await fake.getUserMedia({ video: constraintInput })
const trackConstraints = stream.getTracks()[0].getConstraints()
expect(trackConstraints).toEqual({ facingMode: 'user' })
expect(trackConstraints).not.toBe(constraintInput)
})
test('replace true constraint with an empty object', async () => {
fake.attach(anyCamera())
const stream = await fake.getUserMedia({ video: true })
expect(stream.getTracks()[0].getConstraints()).toEqual({})
})
test('provide access to the passed audio constraints', async () => {
fake.attach(anyMicrophone())
const stream = await fake.getUserMedia({ audio: { noiseSuppression: true } })
expect(stream.getTracks()[0].getConstraints()).toEqual({ noiseSuppression: true })
})
})
describe('dispatch', () => {
test('forward devicechange events to the listeners', () => {
const ondevicechange = jest.fn()
const eventListener = jest.fn()
fake.ondevicechange = ondevicechange
fake.addEventListener('devicechange', eventListener)
fake.dispatchEvent(new Event('devicechange'))
expect(ondevicechange).toHaveBeenCalled()
})
})
})
describe('enumerateDevices', () => {
test('include speakers on chrome', async () => {
const fake = new MediaDevicesFake(defaultContext(), stillHaveToAskForDeviceAccess(), new OpenMediaTracks())
fake.attach(anySpeaker({ label: 'should not be returned' }))
const mediaDeviceInfos = await fake.enumerateDevices()
expect(mediaDeviceInfos).toHaveLength(1)
expect(mediaDeviceInfos[0].label).toBe('')
})
describe('speaker label is connected to microphone permissions', () => {
test('label is returned if microphone permissions are granted', async () => {
const fake = new MediaDevicesFake(
defaultContext(),
anyUserConsent({ microphone: 'granted' }),
new OpenMediaTracks(),
)
fake.attach(anySpeaker({ label: 'the speaker' }))
const mediaDeviceInfos = await fake.enumerateDevices()
expect(mediaDeviceInfos).toHaveLength(1)
expect(mediaDeviceInfos[0].label).toBe('the speaker')
})
test('label is returned if microphone permissions are granted', async () => {
const fake = new MediaDevicesFake(
defaultContext(),
anyUserConsent({ microphone: 'denied' }),
new OpenMediaTracks(),
)
fake.attach(anySpeaker({ label: 'should not be returned' }))
const mediaDeviceInfos = await fake.enumerateDevices()
expect(mediaDeviceInfos).toHaveLength(1)
expect(mediaDeviceInfos[0].label).toBe('')
})
})
describe('still have to ask for device access', () => {
test('label and deviceId in MediaDeviceInfo is set to empty string', async () => {
const fake = new MediaDevicesFake(defaultContext(), stillHaveToAskForDeviceAccess(), new OpenMediaTracks())
fake.attach(anyMicrophone({ label: 'The microphone', deviceId: 'microphone identifier' }))
const devices = await fake.enumerateDevices()
const microphone = devices[0]
expect(microphone.label).toEqual('')
expect(microphone.deviceId).toEqual('')
})
})
})
const allPermissionsGranted = () => {
return anyUserConsent({
camera: 'granted',
microphone: 'granted',
})
}
const stillHaveToAskForDeviceAccess = () => {
return anyUserConsent({
camera: 'prompt',
microphone: 'prompt',
})
}
const anyUserConsent = (override: Partial<UserConsent> = {}) => {
const camera = override.camera ?? 'prompt'
const microphone = override.microphone ?? 'prompt'
return new UserConsentTracker(defaultContext(), { camera, microphone })
}
const runAndReport = async (fake: MediaDevicesFake, scenario: Scenario) => {
const stream = fake.getUserMedia(scenario.constraints)
const checks = scenario.expected.granted?.checks ?? []
const results = await Promise.all(
checks.map(async (check) => {
return {
what: check.what,
details: await check.predicate(stream),
}
}),
)
return results
.filter((check) => !check.details.success)
.map((failed) => {
const lines = []
lines.push('check: ' + failed.what)
const messages = failed.details.messages ?? ['no message']
messages.map((message) => ` - ${message}`).forEach((line) => lines.push(line))
return lines.join('\n')
})
.join('\n')
}