├── .dockerignore ├── .gitignore ├── CODEOWNERS ├── Dockerfile ├── README.md ├── lib ├── config.js ├── server.js └── watcher.js ├── package.json ├── players ├── player1.js └── player2.js ├── src └── common.js ├── static └── player.html ├── webpack.config.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/ 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @muxinc/solutions-architecture 2 | /CODEOWNERS @muxinc/platform-engineering 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:9.5.0 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y fonts-liberation \ 5 | gconf-service libappindicator1 libasound2 \ 6 | libatk-bridge2.0-0 libatk1.0-0 libcups2 \ 7 | libdbus-1-3 libgconf-2-4 libgtk-3-0 \ 8 | libnspr4 libnss3 libx11-xcb1 libxss1 \ 9 | lsb-release xdg-utils libappindicator3-1 10 | 11 | RUN wget https://dl.google.com/linux/direct/google-chrome-beta_current_amd64.deb 12 | RUN dpkg -i google-chrome-beta_current_amd64.deb 13 | 14 | RUN apt-get install -y apt-transport-https 15 | RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - 16 | RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list 17 | RUN apt-get update && apt-get install yarn 18 | 19 | RUN update-ca-certificates 20 | 21 | WORKDIR /opt/app 22 | 23 | COPY . . 24 | 25 | RUN yarn install 26 | 27 | ENTRYPOINT ["yarn"] 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Headless Player Tests 2 | 3 | I cobbled these together for a webinar showing the possibility of using Puppeteer and headless Chrome for testing different aspects of players. The example we went with here is trying to stress different adaptive bitrate algorithms, namely VHS and hlsjs. 4 | 5 | This isn't a very scientific test. It's interesting and something that I think has a lot of promise, but don't go run these and think you've found The Truth™. 6 | 7 | ## Usage 8 | 9 | We were using [Hyper cron jobs](https://hyper.sh/) to run the tests, but you can check the specifics out in the `scripts` key under package.json. 10 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | const NETWORK_PRESETS = { 2 | 'Good2G': { 3 | 'offline': false, 4 | 'downloadThroughput': 450 * 1024 / 8, 5 | 'uploadThroughput': 150 * 1024 / 8, 6 | 'latency': 150 7 | }, 8 | 'Regular3G': { 9 | 'offline': false, 10 | 'downloadThroughput': 750 * 1024 / 8, 11 | 'uploadThroughput': 250 * 1024 / 8, 12 | 'latency': 100 13 | }, 14 | 'Good3G': { 15 | 'offline': false, 16 | 'downloadThroughput': 1.5 * 1024 * 1024 / 8, 17 | 'uploadThroughput': 750 * 1024 / 8, 18 | 'latency': 40 19 | }, 20 | 'Regular4G': { 21 | 'offline': false, 22 | 'downloadThroughput': 4 * 1024 * 1024 / 8, 23 | 'uploadThroughput': 3 * 1024 * 1024 / 8, 24 | 'latency': 20 25 | }, 26 | 'DSL': { 27 | 'offline': false, 28 | 'downloadThroughput': 2 * 1024 * 1024 / 8, 29 | 'uploadThroughput': 1 * 1024 * 1024 / 8, 30 | 'latency': 5 31 | }, 32 | 'WiFi': { 33 | 'offline': false, 34 | 'downloadThroughput': 30 * 1024 * 1024 / 8, 35 | 'uploadThroughput': 15 * 1024 * 1024 / 8, 36 | 'latency': 2 37 | }, 38 | }; 39 | 40 | const PORT = 3000; 41 | 42 | module.exports = { NETWORK_PRESETS, PORT }; 43 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | const finalhandler = require('finalhandler'); 2 | const http = require('http'); 3 | const serveStatic = require('serve-static'); 4 | 5 | const serve = serveStatic('dist', {'index': ['index.html', 'index.htm']}); 6 | 7 | const server = http.createServer(function onRequest (req, res) { 8 | serve(req, res, finalhandler(req, res)); 9 | }); 10 | 11 | const start = port => 12 | new Promise(res => { 13 | server.listen(port); 14 | console.log(`Static server started on port ${port}`); 15 | res(); 16 | }); 17 | 18 | const stop = () => 19 | new Promise(res => { 20 | server.close(() => { 21 | console.log('Static server stopped'); 22 | res(); 23 | }); 24 | }); 25 | 26 | module.exports = { start, stop }; 27 | -------------------------------------------------------------------------------- /lib/watcher.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const fs = require('fs'); 3 | const { random, sample } = require('lodash'); 4 | 5 | const server = require('./server'); 6 | const { NETWORK_PRESETS, PORT } = require('./config') 7 | 8 | const players = fs.readdirSync('./dist').filter((file) => file.match(/player-/)); 9 | const randomPlayer = sample(players); 10 | 11 | console.log('Players found: ', players); 12 | console.log('Random player chosen: ', randomPlayer); 13 | 14 | (async () => { 15 | await server.start(PORT); 16 | 17 | const browser = await puppeteer.launch({executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox', "--disable-dev-shm-usage"]}); 18 | const page = await browser.newPage(); 19 | 20 | const client = await page.target().createCDPSession() 21 | 22 | const randomStart = random(0, 100); 23 | if (randomStart < 15) { 24 | console.log('This bot got the short end of the stick...giving it a 3G connection.'); 25 | await client.send('Network.emulateNetworkConditions', NETWORK_PRESETS['Regular3G']); 26 | } else if (randomStart > 75) { 27 | console.log('This bot wins! No throttling.'); 28 | } else { 29 | const presetName = sample(Object.keys(NETWORK_PRESETS)); 30 | console.log(`Assigning a random preset: ${presetName}`); 31 | await client.send('Network.emulateNetworkConditions', NETWORK_PRESETS[presetName]); 32 | } 33 | 34 | page.on('console', msg => { 35 | switch (msg._text) { 36 | case '--network_change--': { 37 | console.log('Network change triggered'); 38 | client.send('Network.emulateNetworkConditions', sample(NETWORK_PRESETS)); 39 | break; 40 | } 41 | case '--finished--': { 42 | console.log('Player example said farewell. Shutting things down.'); 43 | browser.close(); 44 | server.stop(); 45 | break; 46 | } 47 | } 48 | }); 49 | 50 | await page.goto(`http://localhost:${PORT}/${randomPlayer}`); 51 | })(); 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fanbot", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "docker build . -t muxinc/webinar:latest", 8 | "push": "docker push muxinc/webinar:latest && hyper pull muxinc/webinar:latest && hyper --region eu-central-1 pull muxinc/webinar:latest", 9 | "start:dev": "webpack-dev-server", 10 | "start": "webpack && node ./lib/watcher.js", 11 | "cron": "hyper cron create --minute=*/2 --hour=* --name watcher-cron-b muxinc/webinar start && hyper --region eu-central-1 cron create --minute=*/2 --hour=* --name watcher-cron-b muxinc/webinar start", 12 | "cron:rm": "hyper cron rm watcher-cron-b && hyper --region eu-central-1 cron rm watcher-cron-b" 13 | }, 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "@videojs/http-streaming": "^1.0.1", 18 | "hls.js": "^0.9.1", 19 | "lodash": "^4.17.5", 20 | "mux-embed": "^2.4.2", 21 | "npm": "^5.8.0", 22 | "puppeteer": "^1.3.0", 23 | "serve-static": "^1.13.2", 24 | "video.js": "^6.8.0", 25 | "videojs-mux": "^2.2.3" 26 | }, 27 | "devDependencies": { 28 | "html-webpack-plugin": "^3.2.0", 29 | "webpack": "^4.6.0", 30 | "webpack-cli": "^2.0.15", 31 | "webpack-dev-server": "^3.1.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /players/player1.js: -------------------------------------------------------------------------------- 1 | import Hls from 'hls.js'; 2 | import mux from 'mux-embed'; 3 | 4 | const player = window.player = document.getElementById('player'); 5 | 6 | const muxConfig = { 7 | Hls, 8 | data: { 9 | property_key: 'a2chm9ktrjn6v2n6m14k9eadf', 10 | player_name: 'hls.js', 11 | player_init_time: Date.now(), 12 | mux_video_id: 'nHGQIg00LHJ1yKG008cwxBvR49dWdP6olj', 13 | video_series: 'webinar', 14 | }, 15 | } 16 | 17 | if (Hls.isSupported()) { 18 | const hls = muxConfig.hlsjs = new Hls(); 19 | 20 | hls.loadSource('https://stream.mux.com/nHGQIg00LHJ1yKG008cwxBvR49dWdP6olj.m3u8'); 21 | hls.attachMedia(player); 22 | hls.on(Hls.Events.MANIFEST_PARSED, () => player.play()); 23 | 24 | } else if (player.canPlayType('application/vnd.apple.mpegurl')) { 25 | player.src = 'https://stream.mux.com/nHGQIg00LHJ1yKG008cwxBvR49dWdP6olj.m3u8'; 26 | player.addEventListener('canplay', () => player.play()); 27 | } 28 | 29 | mux.monitor(player, muxConfig); 30 | -------------------------------------------------------------------------------- /players/player2.js: -------------------------------------------------------------------------------- 1 | import videojs from 'video.js'; 2 | import vhs from '@videojs/http-streaming'; 3 | import 'videojs-mux'; 4 | 5 | const initTime = Date.now(); 6 | 7 | const player = window.player = document.getElementById('player'); 8 | 9 | const vjsPlayer = videojs(player, { 10 | plugins: { 11 | mux: { 12 | data: { 13 | property_key: 'a2chm9ktrjn6v2n6m14k9eadf', 14 | player_name: 'video.js | vhs', 15 | player_init_time: initTime, 16 | mux_video_id: 'nHGQIg00LHJ1yKG008cwxBvR49dWdP6olj', 17 | video_series: 'webinar', 18 | }, 19 | } 20 | } 21 | }); 22 | 23 | vjsPlayer.src({ 24 | src: 'https://stream.mux.com/nHGQIg00LHJ1yKG008cwxBvR49dWdP6olj.m3u8', 25 | type: 'application/x-mpegURL', 26 | }); 27 | 28 | vjsPlayer.play(); 29 | -------------------------------------------------------------------------------- /src/common.js: -------------------------------------------------------------------------------- 1 | import { random, sample } from 'lodash'; 2 | 3 | const delay = ms => new Promise(res => setTimeout(res, ms)); 4 | const player = window.player; 5 | 6 | const actions = [ 7 | { 8 | name: 'seek', 9 | fun: async () => ( 10 | player.currentTime = random(0, player.duration) 11 | ), 12 | }, 13 | { 14 | name: 'seek', 15 | fun: async () => ( 16 | player.currentTime = random(player.currentTime, player.duration) 17 | ), 18 | }, 19 | { 20 | name: 'pause:play', 21 | fun: async () => { 22 | await player.pause(); 23 | await delay(random(0, 2000)); 24 | return await player.play(); 25 | }, 26 | }, 27 | { 28 | name: 'pause:play', 29 | fun: async () => { 30 | await player.pause(); 31 | await delay(random(0, 1000)); 32 | return await player.play(); 33 | }, 34 | }, 35 | { 36 | name: 'delay', 37 | fun: () => delay(random(5000, 15000)), 38 | }, 39 | { 40 | name: 'delay', 41 | fun: () => delay(random(5000, 15000)), 42 | }, 43 | { 44 | name: 'trigger-network', 45 | fun: () => console.log('--network_change--'), 46 | }, 47 | ]; 48 | 49 | const randomAction = async () => { 50 | const action = sample(actions); 51 | console.log(`${action.name} -- started.`); 52 | await action.fun(); 53 | console.log(`${action.name} -- ended.`); 54 | } 55 | 56 | (async () => { 57 | const interval = await setInterval(randomAction, random(3000, 10000)); 58 | 59 | player.onended = () => { 60 | clearInterval(interval); 61 | console.log('--finished--'); 62 | } 63 | })(); 64 | -------------------------------------------------------------------------------- /static/player.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Super Cool Video Site 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

Player 1

16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | let path = require('path'); 2 | let fs = require('fs'); 3 | let HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | 5 | let players = fs.readdirSync('./players').map(player => `./players/${player}`); 6 | 7 | const entry = Object.assign({ 8 | app: './src/common.js' 9 | }, players); 10 | 11 | let playersStatic = players.map((player, i) => new HtmlWebpackPlugin({ 12 | inject: "body", 13 | title: `Player ${i}`, 14 | chunksSortMode: 'manual', 15 | chunks: ['app', `${i}`], 16 | template: './static/player.html', 17 | filename: `player-${i}.html`, 18 | })); 19 | 20 | module.exports = { 21 | entry: entry, 22 | output: { 23 | filename: '[name].bundle.js', 24 | path: path.resolve(__dirname, 'dist') 25 | }, 26 | mode: process.env.NODE_ENV || 'development', 27 | devServer: { 28 | contentBase: path.join(__dirname, './'), 29 | compress: true, 30 | port: 9000 31 | }, 32 | plugins: [...playersStatic], 33 | module: { 34 | rules: [ 35 | {exclude: [/lib/, /node_modules/]}, 36 | ] 37 | }, 38 | }; 39 | --------------------------------------------------------------------------------