UNPKG

@fakes/media-devices

Version:

A interactive fake implementation of MediaDevices interface in the browser for testing

358 lines (322 loc) 13.3 kB
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') }