Created
October 4, 2023 17:46
-
-
Save KernelFolla/2647d7c644dce10913c592b1708f3a1e to your computer and use it in GitHub Desktop.
playwright mock getusermedia
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* see https://github.com/theopenwebjp/get-user-media-mock | |
*/ | |
import { Page } from '@playwright/test'; | |
export const mockUserMedia = async (page: Page) => { | |
// eslint-disable-next-line sonarjs/cognitive-complexity | |
return await page.addInitScript(() => { | |
/* eslint-disable @typescript-eslint/ban-ts-comment */ | |
/** | |
* @typedef {'canvas'|'mediaElement'|(constraints: MediaStreamConstraints) => MediaStream} MockType | |
*/ | |
/** | |
* @typedef {'audio'|'video'|'image'} MediaStreamTrackType | |
*/ | |
/** | |
* @typedef {typeof MockOptions} MockOptions | |
*/ | |
const MockOptions = () => ({ | |
getUserMedia: true, | |
mediaDevices: { | |
getUserMedia: true, | |
getSupportedConstraints: true, | |
enumerateDevices: true, | |
}, | |
}); | |
/** | |
* Class for creating mock of getUserMedia for navigator.getUserMedia and navigator.mediaDevice.getUserMedia. | |
* Usage: const m = new GetUserMediaMock(); m.setup(); | |
*/ | |
class GetUserMediaMock { | |
private DEFAULT_MEDIA: { VIDEO: string; AUDIO: string }; | |
private settings: { | |
mediaUrl: string; | |
mockType: string; | |
constraints: { | |
image: { | |
exposureCompensation: boolean; | |
iso: boolean; | |
zoom: boolean; | |
torch: boolean; | |
focusDistance: boolean; | |
colorTemperature: boolean; | |
saturation: boolean; | |
brightness: boolean; | |
focusMode: boolean; | |
contrast: boolean; | |
pointsOfInterest: boolean; | |
sharpness: boolean; | |
whiteBalanceMode: boolean; | |
exposureMode: boolean; | |
}; | |
video: { | |
frameRate: boolean; | |
facingMode: boolean; | |
width: boolean; | |
aspectRatio: boolean; | |
height: boolean; | |
}; | |
audio: { | |
volume: boolean; | |
channelCount: boolean; | |
echoCancellation: boolean; | |
autoGainControl: boolean; | |
noiseSuppression: boolean; | |
latency: boolean; | |
sampleSize: boolean; | |
sampleRate: boolean; | |
}; | |
}; | |
}; | |
private state: { prepared: boolean }; | |
constructor() { | |
this.DEFAULT_MEDIA = { | |
VIDEO: '/video.mp4', | |
AUDIO: '-.mp3', | |
}; | |
this.settings = { | |
mediaUrl: this.DEFAULT_MEDIA.VIDEO, | |
/** | |
* @type {MockType} | |
*/ | |
mockType: 'canvas', // "canvas", "mediaElement", function | |
constraints: { | |
// Used for supported constraints | |
video: { | |
aspectRatio: false, // Upon testing in Chrome, width and height hold priority over aspectRatio. | |
facingMode: false, | |
frameRate: false, | |
height: false, | |
width: false, | |
}, | |
audio: { | |
autoGainControl: false, | |
channelCount: false, | |
echoCancellation: false, | |
latency: false, | |
noiseSuppression: false, | |
sampleRate: false, | |
sampleSize: false, | |
volume: false, | |
}, | |
image: { | |
whiteBalanceMode: false, | |
exposureMode: false, | |
focusMode: false, | |
pointsOfInterest: false, | |
exposureCompensation: false, | |
colorTemperature: false, | |
iso: false, | |
brightness: false, | |
contrast: false, | |
saturation: false, | |
sharpness: false, | |
focusDistance: false, | |
zoom: false, | |
torch: false, | |
}, | |
}, | |
}; | |
this.state = { | |
prepared: false, | |
}; | |
} | |
// noinspection JSConstantReassignment | |
_storeOldHandles() { | |
// @ts-ignore | |
navigator._getUserMedia = navigator.getUserMedia; | |
if (!navigator.mediaDevices) { | |
// Fallback. May have some issues. | |
(navigator as any).mediaDevices = {}; | |
} | |
const m = navigator.mediaDevices; | |
// @ts-ignore | |
m._enumerateDevices = m.enumerateDevices; | |
// @ts-ignore | |
m._getSupportedConstraints = m.getSupportedConstraints; | |
// @ts-ignore | |
m._getUserMedia = m.getUserMedia; | |
this.state.prepared = true; | |
} | |
/** | |
* Dynamically update constraints. Applied on next call of getUserMedia, etc. | |
* @param {string} type any key in this.settings.constraints | |
* @param {Partial<MediaStreamConstraints>} updates Data to apply to constraints | |
* @param {boolean} overwrite Whether to fully overwrite original. | |
*/ | |
updateConstraints(type = 'video', updates = {}, overwrite = false) { | |
const c = this.settings.constraints; | |
if (!c[type]) { | |
return false; | |
} | |
if (overwrite) { | |
c[type] = {}; | |
} | |
for (const key in updates) { | |
c[type][key] = updates[key]; | |
} | |
return this; | |
} | |
/** | |
* Set media URL for mockType "mediaElement" | |
* @param {string} url | |
*/ | |
setMediaUrl(url) { | |
this.settings.mediaUrl = url; | |
return this; | |
} | |
/** | |
* Set a predefined mock type via string or a custom function. | |
* @param {MockType} mockType | |
*/ | |
setMockType(mockType) { | |
this.settings.mockType = mockType; | |
return this; | |
} | |
/** | |
* Applies mock to environment ONLY IF getUserMedia constraints fail. | |
*/ | |
fallbackMock() { | |
if (!this.state.prepared) { | |
this._storeOldHandles(); | |
} | |
/** | |
* @param {(stream: MediaStream) => void} handle | |
*/ | |
const getSuccessHandle = (handle) => { | |
/** | |
* @param {MediaStream} stream | |
*/ | |
return (stream) => { | |
this._log('log', 'fallback NOT implemented'); | |
handle(stream); | |
}; | |
}; | |
/** | |
* @param {Error} err | |
* @param {MediaStreamConstraints} constraints | |
*/ | |
const handleFallback = (err, constraints) => { | |
return this.getMockStreamFromConstraints(constraints).then( | |
(stream) => { | |
this._log('warn', 'fallbackMock implemented', err); | |
return stream; | |
} | |
); | |
}; | |
/** | |
* navigator.getUserMedia | |
* @param {MediaStreamConstraints} constraints | |
* @param {(stream: MediaStream) => void} onSuccess | |
* @param {(err: Error) => void|any} onError | |
*/ | |
// @ts-ignore | |
navigator.getUserMedia = (constraints, onSuccess, onError) => { | |
// @ts-ignore | |
navigator._getUserMedia( | |
constraints, | |
getSuccessHandle(onSuccess), | |
(err) => { | |
return handleFallback(err, constraints) | |
.then(onSuccess) | |
.catch(onError); | |
} | |
); | |
}; | |
/** | |
* navigator.mediaDevices.getUserMedia | |
* @param {MediaStreamConstraints} constraints | |
*/ | |
navigator.mediaDevices.getUserMedia = (constraints) => { | |
return new Promise((resolve, reject) => { | |
navigator.mediaDevices | |
// @ts-ignore | |
._getUserMedia(constraints) | |
.then(getSuccessHandle(resolve)) | |
.catch((err) => { | |
return handleFallback(err, constraints) | |
.then(resolve) | |
.catch(reject); | |
}); | |
}); | |
}; | |
return this; | |
} | |
/** | |
* Applies mock to environment. | |
* Generally should be applied before other scripts once. | |
* @param {MockOptions} options Way to only mock certain features. Mocks all by default. | |
*/ | |
mock(options) { | |
if (typeof options !== 'object') { | |
options = MockOptions(); | |
} | |
if (!this.state.prepared) { | |
this._storeOldHandles(); | |
} | |
// navigator.getUserMedia | |
if (options.getUserMedia) { | |
// @ts-ignore | |
navigator.getUserMedia = (constraints, onSuccess, onError) => { | |
return this.getMockStreamFromConstraints(constraints) | |
.then(onSuccess) | |
.catch(onError); | |
}; | |
} | |
if (options.mediaDevices) { | |
// navigator.mediaDevices.getUserMedia | |
if (options.mediaDevices.getUserMedia) { | |
navigator.mediaDevices.getUserMedia = (constraints) => { | |
return this.getMockStreamFromConstraints(constraints); | |
}; | |
} | |
// navigator.mediaDevices.getSupportedConstraints | |
if (options.mediaDevices.getSupportedConstraints) { | |
// @ts-ignore | |
navigator.mediaDevices.getSupportedConstraints = () => { | |
return this.settings.constraints; | |
}; | |
} | |
// navigator.mediaDevices.enumerateDevices | |
if (options.mediaDevices.enumerateDevices) { | |
// @ts-ignore | |
navigator.mediaDevices.enumerateDevices = () => { | |
return this.getMockDevices(); | |
}; | |
} | |
} | |
return this; | |
} | |
/** | |
* Restores actually native handles if mock handles already applied. | |
*/ | |
restoreOldHandles() { | |
// @ts-ignore | |
if (navigator._getUserMedia) { | |
// @ts-ignore | |
navigator.getUserMedia = navigator._getUserMedia; | |
// @ts-ignore | |
navigator._getUserMedia = null; | |
} | |
// @ts-ignore | |
if (navigator.mediaDevices && navigator.mediaDevices._getUserMedia) { | |
navigator.mediaDevices.enumerateDevices = | |
// @ts-ignore | |
navigator.mediaDevices._enumerateDevices; | |
navigator.mediaDevices.getSupportedConstraints = | |
// @ts-ignore | |
navigator.mediaDevices._getSupportedConstraints; | |
navigator.mediaDevices.getUserMedia = | |
// @ts-ignore | |
navigator.mediaDevices._getUserMedia; | |
} | |
this.state.prepared = false; | |
} | |
/** | |
* Gets a media stream with motion and color. | |
* @see https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints | |
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/captureStream | |
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/captureStream | |
* @see https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/createMediaStreamDestination | |
* @param {MediaStreamConstraints} constraints | |
*/ | |
getMockStreamFromConstraints(constraints) { | |
let stream = null; | |
const mockType = this.settings.mockType; | |
if (mockType === 'canvas') { | |
stream = this.getMockCanvasStream(constraints); | |
} else if (mockType === 'mediaElement') { | |
stream = this.getMockMediaElementStream(constraints); | |
} else if (typeof mockType === 'function') { | |
// @ts-ignore | |
stream = mockType(constraints); | |
} else { | |
throw new Error('invalid mockType: ' + String(mockType)); | |
} | |
return Promise.resolve(stream); | |
} | |
/** | |
* Returns stream that is internally generated using canvas and random data. | |
* ONCE STREAM IS NO LONGER NEEDED, SHOULD CALL .stop FUNCTION TO STOP DRAW INTERVAL. | |
* @param {MediaStreamConstraints} constraints | |
* @return {MediaStream} | |
*/ | |
getMockCanvasStream(constraints) { | |
const canvas = document.createElement('canvas'); | |
canvas.width = this.getConstraintBestValue( | |
constraints, | |
'video', | |
'width' | |
); | |
canvas.height = this.getConstraintBestValue( | |
constraints, | |
'video', | |
'height' | |
); | |
const meta = this.createStartedRandomCanvasDrawerInterval(canvas); | |
this._log('log', 'mock canvas meta', meta); | |
const stream = canvas.captureStream( | |
this.getConstraintBestValue(constraints, 'video', 'frameRate') | |
); | |
// @ts-ignore | |
stream.stop = this._createStopCanvasStreamFunction(stream, meta); | |
return stream; | |
} | |
/** | |
* Returns stream with media used as source. | |
* @param {MediaStreamConstraints} constraints | |
* @return {MediaStream} | |
*/ | |
getMockMediaElementStream(constraints) { | |
const video = document.createElement('video'); | |
video.autoplay = true; | |
video.loop = true; | |
this._log('log', 'mediaElement source video', video); | |
video.src = this.settings.mediaUrl; | |
video.load(); | |
video.play(); | |
// @ts-ignore | |
return video.captureStream(); | |
} | |
/** | |
* Creates and starts an interval that paints randomly to a canvas. | |
* @param {HTMLCanvasElement} canvas | |
* @returns meta data including interval that can be cleared with window.clearInterval | |
*/ | |
createStartedRandomCanvasDrawerInterval(canvas) { | |
const FPS = 2; | |
const ms = 1000 / FPS; | |
const getRandom = (max) => { | |
return Math.floor(Math.random() * max); | |
}; | |
const handle = () => { | |
const ctx = canvas.getContext('2d'); | |
const x = 0; | |
const y = 0; | |
const width = getRandom(canvas.width); | |
const height = getRandom(canvas.height); | |
const r = getRandom(255); | |
const g = getRandom(255); | |
const b = getRandom(255); | |
ctx.fillStyle = `rgb(${r},${g},${b})`; | |
ctx.fillRect(x, y, width, height); | |
}; | |
const interval = window.setInterval(handle, ms); | |
// Execute once due to Firefox issue where canvas MUST NOT be empty. | |
// Exception... "Component not initialized" nsresult: "0xc1f30001 (NS_ERROR_NOT_INITIALIZED)" | |
handle(); | |
return { | |
canvas: canvas, | |
interval: interval, | |
}; | |
} | |
/** | |
* Gets constraint best value by necessary identifiers. | |
* Returns appropriate defaults where important. | |
* THIS IS FOR VALUES NOT ACTUAL SET CONTRAINTS. | |
* USED FOR settings, etc. in UI. | |
* @param {MediaStreamConstraints} constraints | |
* @param {MediaStreamTrackType} type | |
* @param {keyof MediaStreamTrack} key | |
*/ | |
getConstraintBestValue(constraints, type, key) { | |
const subConstraints = | |
typeof constraints[type] === 'object' ? constraints[type] : {}; | |
const cVal = subConstraints[key]; | |
let value; | |
if (typeof cVal !== 'object') { | |
value = cVal; | |
} else if (cVal) { | |
for (const key in cVal) { | |
if (key === 'ideal') { | |
value = cVal[key]; | |
break; | |
} else { | |
value = cVal[key]; | |
} | |
} | |
} | |
// Defaults | |
if (key === 'width' && !value) { | |
value = 640; | |
} | |
if (key === 'height' && !value) { | |
value = 480; | |
} | |
if (key === 'frameRate' && !value) { | |
value = 15; | |
} | |
return value; | |
} | |
/** | |
* Returns a set of mock devices using similar format. | |
*/ | |
getMockDevices() { | |
const devices = [ | |
{ | |
kind: 'audioinput', | |
label: '(4- BUFFALO BSW32KM03 USB PC Camera)', | |
}, | |
{ | |
kind: 'audiooutput', | |
label: 'Bluetooth Hands-free Audio', | |
}, | |
{ | |
kind: 'videooutput', | |
label: 'BUFFALO BSW32KM03 USB PC Camera', | |
}, | |
]; | |
return new Promise((resolve) => { | |
devices.forEach((device, index) => { | |
// @ts-ignore | |
device.deviceId = String(index); | |
// @ts-ignore | |
device.groupId = String(index); | |
}); | |
return resolve(devices); | |
}); | |
} | |
/** | |
* @param {MediaStream} stream | |
* @param {{ interval: number }} meta | |
* @return {() => void)} | |
*/ | |
_createStopCanvasStreamFunction(stream, meta) { | |
return () => { | |
window.clearInterval(meta.interval); | |
const tracks = stream.getTracks(); | |
tracks.forEach((track) => { | |
track.stop(); | |
}); | |
if (stream.stop) { | |
stream.stop = undefined; | |
} | |
}; | |
} | |
_log(type, ...args) { | |
if (window.console && window.console[type]) { | |
window.console[type](...args); | |
} | |
} | |
} | |
const mock = new GetUserMediaMock(); | |
(window as any).getUserMediaMock = mock; | |
mock.mock(MockOptions()); | |
alert('ok'); | |
}); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment