├── .gitignore ├── examples ├── scenarios │ ├── .gitkeep │ ├── BasicReceiverCAF-sample.json │ └── example.json └── standalone │ └── example.js ├── .babelrc ├── .travis.yml ├── src ├── receiver-utils │ ├── index.js │ ├── device-polyfill.js │ └── scenario-recorder.js ├── log.js ├── cli │ ├── app.js │ └── index.js ├── index.js └── index.spec.js ├── schemas ├── ipc-message.json └── scenario.json ├── docs ├── cast-reference-player.md └── basic-receiver-caf.md ├── dist ├── cli │ ├── app.js │ └── index.js ├── log.js ├── receiver-utils.min.js ├── index.spec.js └── index.js ├── LICENSE ├── package.json ├── README.md └── diagram.svg /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /examples/scenarios/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["transform-node-env-inline"], 3 | "presets": [["env"]], 4 | "env": { 5 | "production": { 6 | "presets": ["minify"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/standalone/example.js: -------------------------------------------------------------------------------- 1 | const CastDeviceEmulator = require('chromecast-device-emulator'); 2 | const emulator = new CastDeviceEmulator(); 3 | 4 | emulator.loadScenario(require('../scenarios/example.json')); 5 | emulator.start(); 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | env: 3 | - FORCE_COLOR=true 4 | node_js: 5 | - "6" 6 | - "7" 7 | - "8" 8 | - "9" 9 | - "10" 10 | cache: 11 | directories: 12 | - node_modules 13 | notifications: 14 | email: 15 | - im.ajhsu@gmail.com -------------------------------------------------------------------------------- /src/receiver-utils/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * emulator-devtool.js 3 | * - When running receiver out of Google Cast device 4 | * - When you need to record scenario that running on Google Cast device 5 | */ 6 | 7 | console.log('chromecast-device-emulator: receiver-utils loaded.'); 8 | require('./device-polyfill'); 9 | require('./scenario-recorder'); 10 | -------------------------------------------------------------------------------- /schemas/ipc-message.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "properties": { 4 | "data": { 5 | "type": "string" 6 | }, 7 | "namespace": { 8 | "type": "string" 9 | }, 10 | "senderId": { 11 | "type": "string" 12 | } 13 | }, 14 | "required": ["data", "namespace", "senderId"], 15 | "type": "object" 16 | } 17 | -------------------------------------------------------------------------------- /src/log.js: -------------------------------------------------------------------------------- 1 | function log(...params) { 2 | console.log('chromecast-device-emulator:', ...params); 3 | } 4 | 5 | function warn(...params) { 6 | console.warn('chromecast-device-emulator:', ...params); 7 | } 8 | 9 | function error(...params) { 10 | console.error('chromecast-device-emulator:', ...params); 11 | } 12 | 13 | module.exports = { 14 | log, 15 | warn, 16 | error 17 | }; -------------------------------------------------------------------------------- /docs/cast-reference-player.md: -------------------------------------------------------------------------------- 1 | ## Example with [CastVideo-chrome](https://github.com/googlecast/CastVideos-chrome) (sender) and [CastReferencePlayer](https://github.com/googlecast/CastReferencePlayer) (receiver) 2 | If you just started developing your receiver app with Google's [googlecast/CastVideos-chrome](https://github.com/googlecast/CastVideos-chrome) and [googlecast/CastReferencePlayer](https://github.com/googlecast/CastReferencePlayer) repositories; 3 | Here we'll cover how to use the emulator between them. 4 | 5 | > In progress -------------------------------------------------------------------------------- /src/cli/app.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { log, error } = require('../log'); 3 | const CastDeviceEmulator = require('../'); 4 | 5 | function startEmulator(filepath, cmd) { 6 | const fullPath = path.resolve(process.cwd(), filepath); 7 | const emulator = new CastDeviceEmulator(); 8 | try { 9 | emulator.loadScenario(require(fullPath)); 10 | log(`Scenario file <${fullPath}> has been loaded.`); 11 | emulator.start(); 12 | } catch (err) { 13 | error(err); 14 | } 15 | } 16 | 17 | module.exports = { 18 | startEmulator 19 | }; 20 | -------------------------------------------------------------------------------- /dist/cli/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | 5 | var _require = require('../log'), 6 | log = _require.log, 7 | error = _require.error; 8 | 9 | var CastDeviceEmulator = require('../'); 10 | 11 | function startEmulator(filepath, cmd) { 12 | var fullPath = path.resolve(process.cwd(), filepath); 13 | var emulator = new CastDeviceEmulator(); 14 | try { 15 | emulator.loadScenario(require(fullPath)); 16 | log('Scenario file <' + fullPath + '> has been loaded.'); 17 | emulator.start(); 18 | } catch (err) { 19 | error(err); 20 | } 21 | } 22 | 23 | module.exports = { 24 | startEmulator: startEmulator 25 | }; -------------------------------------------------------------------------------- /src/cli/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const program = require('commander'); 4 | const manifest = require('../../package.json'); 5 | const { log, error } = require('../log'); 6 | const { startEmulator } = require('./app'); 7 | 8 | program 9 | .name(manifest.name) 10 | .version(manifest.version, '-v, --version') 11 | .usage('start '); 12 | 13 | /** 14 | * Defaults to help command 15 | */ 16 | if (!process.argv.slice(2).length) { 17 | program.outputHelp(); 18 | } 19 | 20 | /** 21 | * start command 22 | */ 23 | program 24 | .command('start ') 25 | .description( 26 | 'Start a chromecast-device-emulator server that serves with given scenario' 27 | ) 28 | .action(startEmulator); 29 | 30 | program.parse(process.argv); 31 | -------------------------------------------------------------------------------- /schemas/scenario.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "properties": { 4 | "timeline": { 5 | "id": "/properties/timeline", 6 | "items": { 7 | "id": "/properties/timeline/items", 8 | "properties": { 9 | "ipcMessage": { 10 | "id": "/properties/timeline/items/properties/ipcMessage", 11 | "type": "string" 12 | }, 13 | "time": { 14 | "id": "/properties/timeline/items/properties/time", 15 | "type": "integer" 16 | } 17 | }, 18 | "required": ["ipcMessage", "time"], 19 | "type": "object" 20 | }, 21 | "type": "array" 22 | } 23 | }, 24 | "required": ["timeline"], 25 | "type": "object" 26 | } 27 | -------------------------------------------------------------------------------- /dist/cli/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | var program = require('commander'); 5 | var manifest = require('../../package.json'); 6 | 7 | var _require = require('../log'), 8 | log = _require.log, 9 | error = _require.error; 10 | 11 | var _require2 = require('./app'), 12 | startEmulator = _require2.startEmulator; 13 | 14 | program.name(manifest.name).version(manifest.version, '-v, --version').usage('start '); 15 | 16 | /** 17 | * Defaults to help command 18 | */ 19 | if (!process.argv.slice(2).length) { 20 | program.outputHelp(); 21 | } 22 | 23 | /** 24 | * start command 25 | */ 26 | program.command('start ').description('Start a chromecast-device-emulator server that serves with given scenario').action(startEmulator); 27 | 28 | program.parse(process.argv); -------------------------------------------------------------------------------- /src/receiver-utils/device-polyfill.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file will 3 | * 1. Polyfills environment variables when receiver app is NOT running on Google Cast devices. 4 | * 2. Overrides environment variables when receiver app is running on Google Cast devices, 5 | * which will forces receiver app to communicate sender with WebSocket, instead of built-in JavaScript object; 6 | * So that we can sniff IPC messages between sender and receiver app. 7 | * 8 | * Note that you don't need to add this polyfill file in production, 9 | * this polyfill will be only needed in developement. 10 | */ 11 | (function(windowObject) { 12 | if (!windowObject) return; 13 | windowObject.cast = windowObject.cast || {}; 14 | windowObject.cast.__platform__ = {}; 15 | console.log('chromecast-device-emulator: device-polyfill module loaded.'); 16 | })(window); 17 | -------------------------------------------------------------------------------- /dist/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function log() { 4 | var _console; 5 | 6 | for (var _len = arguments.length, params = Array(_len), _key = 0; _key < _len; _key++) { 7 | params[_key] = arguments[_key]; 8 | } 9 | 10 | (_console = console).log.apply(_console, ['chromecast-device-emulator:'].concat(params)); 11 | } 12 | 13 | function warn() { 14 | var _console2; 15 | 16 | for (var _len2 = arguments.length, params = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { 17 | params[_key2] = arguments[_key2]; 18 | } 19 | 20 | (_console2 = console).warn.apply(_console2, ['chromecast-device-emulator:'].concat(params)); 21 | } 22 | 23 | function error() { 24 | var _console3; 25 | 26 | for (var _len3 = arguments.length, params = Array(_len3), _key3 = 0; _key3 < _len3; _key3++) { 27 | params[_key3] = arguments[_key3]; 28 | } 29 | 30 | (_console3 = console).error.apply(_console3, ['chromecast-device-emulator:'].concat(params)); 31 | } 32 | 33 | module.exports = { 34 | log: log, 35 | warn: warn, 36 | error: error 37 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 AJ Hsu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/receiver-utils/scenario-recorder.js: -------------------------------------------------------------------------------- 1 | (function(windowObject) { 2 | if (!windowObject) return; 3 | 4 | /** 5 | * Hijack WebSocket class for IPC message recording 6 | */ 7 | WebSocket.prototype.realSendFunc = WebSocket.prototype.send; 8 | WebSocket.prototype.send = function(data) { 9 | this.realSendFunc(data); 10 | this.addEventListener( 11 | 'message', 12 | function(message) { 13 | if (message && message.data) { 14 | pushMessage(message.data); 15 | } 16 | }, 17 | false 18 | ); 19 | this.send = function(data) { 20 | this.realSendFunc(data); 21 | }; 22 | }; 23 | 24 | const startup = new Date().getTime(); 25 | const messageQueue = []; 26 | 27 | function pushMessage(message) { 28 | const now = new Date().getTime(); 29 | const elapsed = now - startup; 30 | messageQueue.push({ 31 | time: elapsed, 32 | ipcMessage: message 33 | }); 34 | } 35 | 36 | function exportScenario(clearConsole = true) { 37 | if (clearConsole) console.clear(); 38 | console.log( 39 | JSON.stringify({ 40 | timeline: messageQueue 41 | }) 42 | ); 43 | } 44 | 45 | windowObject.CDE = windowObject.CDE || {}; 46 | windowObject.CDE.exportScenario = exportScenario; 47 | console.log('chromecast-device-emulator: scenario-recorder module loaded.'); 48 | })(window); 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chromecast-device-emulator", 3 | "version": "1.2.7", 4 | "description": "Testing your chromecast receiver app, without a real-device needed.", 5 | "homepage": "https://github.com/ajhsu/chromecast-device-emulator#readme", 6 | "bugs": { 7 | "url": "https://github.com/ajhsu/chromecast-device-emulator/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/ajhsu/chromecast-device-emulator.git" 12 | }, 13 | "license": "MIT", 14 | "author": "AJ Hsu ", 15 | "main": "dist/index.js", 16 | "bin": { 17 | "chromecast-device-emulator": "./dist/cli/index.js", 18 | "cde": "./dist/cli/index.js" 19 | }, 20 | "scripts": { 21 | "build": "babel src --ignore receiver-utils --out-dir dist", 22 | "build:receiver-utils": "NODE_ENV=production browserify --transform babelify src/receiver-utils/index.js --outfile dist/receiver-utils.min.js", 23 | "pretest": "npm run build", 24 | "test": "mocha \"dist/**/*.spec.js\"" 25 | }, 26 | "dependencies": { 27 | "ajv": "^4.11.8", 28 | "chalk": "^2.4.1", 29 | "commander": "^2.15.1", 30 | "ws": "^5.2.0" 31 | }, 32 | "devDependencies": { 33 | "babel-cli": "^6.26.0", 34 | "babel-core": "^6.26.3", 35 | "babel-plugin-transform-node-env-inline": "^0.4.3", 36 | "babel-preset-env": "^1.7.0", 37 | "babel-preset-minify": "^0.4.3", 38 | "babelify": "^8.0.0", 39 | "browserify": "^16.2.2", 40 | "chai": "^3.5.0", 41 | "mocha": "^3.5.3" 42 | }, 43 | "engines": { 44 | "node": ">= 6.9.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /dist/receiver-utils.min.js: -------------------------------------------------------------------------------- 1 | (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i 17 | 18 | 19 | 20 | ``` 21 | 22 | ### Deploy and publish your receiver app 23 | Since receiver apps need to be public accessible, you still have to publish it on the internet. 24 | 25 | Or you may use tools like [ngrok](https://ngrok.com/) to expose your local environment public accesssible. 26 | So that your Cast devices are able to access your receiver app direct from your local machine. 27 | 28 | ### Invoke your receiver app with sender app 29 | Once you're ready for casting, try to cast a sample video onto your receiver app. 30 | Note that the `scenario-recorder` was started recording as soon as receiver app was invoked, any actions (play, pause, seek, next, previous .. etc) that you do, will be recorded into a scenario JSON, to be exported later. 31 | 32 | So now you are able to make a series of actions (an user scenario, for example), and we will export the scenario later. 33 | 34 | ### Export scenario JSON file 35 | Assume that you've done an user scenario that you want to replay it later on emulator. 36 | Then we need to export this scenario via [remote debugging](https://developers.google.com/cast/docs/debugging): 37 | 38 | 1. Open your Google Chrome browser 39 | 1. Navigate to [chrome://inspect/#devices](chrome://inspect/#devices) 40 | 1. Find the receiver app that is running in your network 41 | 1. Open up your Chrome DevTool by click the `inspect` link 42 | 1. Click `Console` Tab 43 | 1. Type `CDE.exportScenario();` to export recorded scenario JSON. 44 | 1. You'll see a huge JSON output like: 45 | ``` 46 | {"timeline":[{"time":900,"ipcMessage":"{\"data\":\"{\\\"...."}]} 47 | ``` 48 | 1. Copy and save it into a JSON file 49 | 50 | ### Start the emulator with recorded scenario JSON file 51 | 52 | Now we got a scenario file to serve from the emulator. 53 | 54 | You can start an emulator with CLI like: 55 | 56 | ```bash 57 | $ chrome-device-emulator start scenario.json 58 | ``` 59 | 60 | Then you'll see the output like: 61 | 62 | ``` 63 | chromecast-device-emulator: Scenario file has been loaded. 64 | chromecast-device-emulator: Established a websocket server at port 8008 65 | chromecast-device-emulator: Ready for Chromecast receiver connections.. 66 | ``` 67 | 68 | Congrats! 69 | It means the emulator is ready for receiver connections, 70 | and you may start your receiver app development on the local machine! -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require('ws'); 2 | const chalk = require('chalk'); 3 | const { log, error } = require('./log'); 4 | 5 | /** 6 | * The JSON Schema validator 7 | */ 8 | const Ajv = require('ajv'); 9 | const jsonSchemaValidator = new Ajv(); 10 | 11 | /** 12 | * Configuration for WebSocket server 13 | */ 14 | const serverConfig = { 15 | port: 8008, 16 | path: '/v2/ipc' 17 | }; 18 | 19 | class CastDeviceEmulator { 20 | constructor(options = {}) { 21 | this.options = options; 22 | 23 | /** 24 | * WebSocker server instance. 25 | */ 26 | this.wss = null; 27 | 28 | /** 29 | * The recorded messages that we're going to serve. 30 | */ 31 | this.recordedMessages = []; 32 | 33 | /** 34 | * Event handlers for WebSocket server 35 | */ 36 | this._webSocketMessageHandler = this._webSocketMessageHandler.bind(this); 37 | this._webSocketConnectionHandler = this._webSocketConnectionHandler.bind(this); 38 | } 39 | 40 | /** 41 | * Load the specific scenario file 42 | */ 43 | loadScenario(scenarioFile) { 44 | if (!jsonSchemaValidator.validate(require('../schemas/scenario.json'), scenarioFile)) { 45 | throw new Error('Invalid scenario schema!'); 46 | } 47 | this.recordedMessages = scenarioFile.timeline; 48 | } 49 | 50 | /** 51 | * Startup the emulator 52 | */ 53 | start(callback) { 54 | this.wss = new WebSocket.Server( 55 | { 56 | port: serverConfig.port, 57 | path: serverConfig.path 58 | }, 59 | /** 60 | * When WebSocket server start listening, 61 | * we're going to listen to connection event as well. 62 | */ 63 | (function onListeningCallback() { 64 | this.wss.on('connection', this._webSocketConnectionHandler); 65 | if (!this.options.silent) { 66 | log(`Established a websocket server at port ${serverConfig.port}`); 67 | log('Ready for Chromecast receiver connections..'); 68 | } 69 | if (callback) callback(); 70 | }).bind(this) 71 | ); 72 | } 73 | 74 | /** 75 | * Stop handling events from WebSocket server 76 | */ 77 | stop() { 78 | this.wss.removeAllListeners('message'); 79 | this.wss.removeAllListeners('connection'); 80 | } 81 | 82 | /** 83 | * Close the WebSocket server 84 | */ 85 | close(callback) { 86 | if (!this.wss) { 87 | log('There is no websocket existing.'); 88 | return; 89 | } 90 | this.wss.close(() => { 91 | if (!this.options.silent) { 92 | log('Chromecast Device Emulator is closed.'); 93 | } 94 | if (callback) callback(); 95 | }); 96 | } 97 | 98 | /** 99 | * Handle incoming WebSocket connections 100 | */ 101 | _webSocketConnectionHandler(ws) { 102 | if (!this.options.silent) log('There is a cast client just connected.'); 103 | /** 104 | * Listen to message events on each socket connection 105 | */ 106 | ws.on('message', this._webSocketMessageHandler); 107 | /** 108 | * Iterate over the recorded messages 109 | * and set a triggering timer for every single message. 110 | */ 111 | this.recordedMessages.map(m => { 112 | // FIXME: Validate format before send it 113 | const sendRecordedMessage = () => { 114 | if (ws.readyState === WebSocket.OPEN) { 115 | ws.send(m.ipcMessage); 116 | log(chalk.red('>>'), m.ipcMessage); 117 | } 118 | }; 119 | setTimeout(sendRecordedMessage, m.time); 120 | }); 121 | } 122 | 123 | /** 124 | * Handle incoming WebSocket messages 125 | */ 126 | _webSocketMessageHandler(message) { 127 | log(chalk.green('<<'), message); 128 | } 129 | } 130 | 131 | module.exports = CastDeviceEmulator; 132 | -------------------------------------------------------------------------------- /examples/scenarios/BasicReceiverCAF-sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeline": [{ 3 | "time": 844, 4 | "ipcMessage": "{\"data\":\"{\\\"applicationId\\\":\\\"628AC8D3\\\",\\\"applicationName\\\":\\\"CAFV1\\\",\\\"closedCaption\\\":{},\\\"deviceCapabilities\\\":{\\\"bluetooth_supported\\\":true,\\\"display_supported\\\":true,\\\"focus_state_supported\\\":true,\\\"hi_res_audio_supported\\\":false},\\\"launchingSenderId\\\":\\\"7f8b100d-a1fe-e60b-5a35-6feaa22976df.2:sender-l4koe754cbxf\\\",\\\"messagesVersion\\\":\\\"1.0\\\",\\\"sessionId\\\":\\\"46fd154e-f03d-4d58-986d-4998c43639a7\\\",\\\"type\\\":\\\"ready\\\",\\\"version\\\":\\\"1.30.113131\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.system\",\"senderId\":\"SystemSender\"}" 5 | }, { 6 | "time": 846, 7 | "ipcMessage": "{\"data\":\"{\\\"level\\\":1.0,\\\"muted\\\":false,\\\"type\\\":\\\"volumechanged\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.system\",\"senderId\":\"SystemSender\"}" 8 | }, { 9 | "time": 847, 10 | "ipcMessage": "{\"data\":\"{\\\"type\\\":\\\"visibilitychanged\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.system\",\"senderId\":\"SystemSender\"}" 11 | }, { 12 | "time": 847, 13 | "ipcMessage": "{\"data\":\"{\\\"type\\\":\\\"standbychanged\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.system\",\"senderId\":\"SystemSender\"}" 14 | }, { 15 | "time": 862, 16 | "ipcMessage": "{\"data\":\"{\\\"hdrType\\\":\\\"sdr\\\",\\\"type\\\":\\\"hdroutputtypechanged\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.system\",\"senderId\":\"SystemSender\"}" 17 | }, { 18 | "time": 864, 19 | "ipcMessage": "{\"data\":\"{\\\"state\\\":\\\"IN_FOCUS\\\",\\\"type\\\":\\\"FOCUS_STATE\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.cac\",\"senderId\":\"SystemSender\"}" 20 | }, { 21 | "time": 956, 22 | "ipcMessage": "{\"data\":\"{\\\"largeMessageSupported\\\":false,\\\"senderId\\\":\\\"7f8b100d-a1fe-e60b-5a35-6feaa22976df.2:152792770056491611\\\",\\\"type\\\":\\\"senderconnected\\\",\\\"userAgent\\\":\\\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.system\",\"senderId\":\"SystemSender\"}" 23 | }, { 24 | "time": 987, 25 | "ipcMessage": "{\"data\":\"{\\\"type\\\":\\\"GET_STATUS\\\",\\\"requestId\\\":889570261}\",\"namespace\":\"urn:x-cast:com.google.cast.media\",\"senderId\":\"7f8b100d-a1fe-e60b-5a35-6feaa22976df.2:sender-l4koe754cbxf\"}" 26 | }, { 27 | "time": 1064, 28 | "ipcMessage": "{\"data\":\"{\\\"type\\\":\\\"LOAD\\\",\\\"requestId\\\":889570262,\\\"sessionId\\\":\\\"46fd154e-f03d-4d58-986d-4998c43639a7\\\",\\\"media\\\":{\\\"contentId\\\":\\\"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4\\\",\\\"streamType\\\":\\\"BUFFERED\\\",\\\"contentType\\\":\\\"video/mp4\\\",\\\"metadata\\\":{\\\"type\\\":0,\\\"metadataType\\\":0,\\\"title\\\":\\\"Big Buck Bunny\\\",\\\"images\\\":[{\\\"url\\\":\\\"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg\\\"}]}},\\\"autoplay\\\":true}\",\"namespace\":\"urn:x-cast:com.google.cast.media\",\"senderId\":\"7f8b100d-a1fe-e60b-5a35-6feaa22976df.2:152792770056491611\"}" 29 | }, { 30 | "time": 2512, 31 | "ipcMessage": "{\"data\":\"{\\\"currentTime\\\":195.756235,\\\"type\\\":\\\"SEEK\\\",\\\"mediaSessionId\\\":1,\\\"sessionId\\\":\\\"46fd154e-f03d-4d58-986d-4998c43639a7\\\",\\\"requestId\\\":889570264}\",\"namespace\":\"urn:x-cast:com.google.cast.media\",\"senderId\":\"7f8b100d-a1fe-e60b-5a35-6feaa22976df.2:152792770056491611\"}" 32 | }, { 33 | "time": 22327, 34 | "ipcMessage": "{\"data\":\"{\\\"currentTime\\\":274,\\\"type\\\":\\\"SEEK\\\",\\\"mediaSessionId\\\":1,\\\"sessionId\\\":\\\"46fd154e-f03d-4d58-986d-4998c43639a7\\\",\\\"requestId\\\":889570266}\",\"namespace\":\"urn:x-cast:com.google.cast.media\",\"senderId\":\"7f8b100d-a1fe-e60b-5a35-6feaa22976df.2:152792770056491611\"}" 35 | }, { 36 | "time": 28241, 37 | "ipcMessage": "{\"data\":\"{\\\"currentTime\\\":376,\\\"type\\\":\\\"SEEK\\\",\\\"mediaSessionId\\\":1,\\\"sessionId\\\":\\\"46fd154e-f03d-4d58-986d-4998c43639a7\\\",\\\"requestId\\\":889570267}\",\"namespace\":\"urn:x-cast:com.google.cast.media\",\"senderId\":\"7f8b100d-a1fe-e60b-5a35-6feaa22976df.2:152792770056491611\"}" 38 | }] 39 | } -------------------------------------------------------------------------------- /dist/index.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var CastDeviceEmulator = require('./'); 4 | var WebSocket = require('ws'); 5 | var Ajv = require('ajv'); 6 | 7 | var jsonSchemaValidator = new Ajv(); 8 | 9 | var _require = require('chai'), 10 | assert = _require.assert, 11 | expect = _require.expect; 12 | 13 | describe('CastDeviceEmulator', function () { 14 | describe('Methods existance', function () { 15 | it('should have expected public methods', function () { 16 | expect(CastDeviceEmulator).to.respondTo('loadScenario'); 17 | expect(CastDeviceEmulator).to.respondTo('start'); 18 | expect(CastDeviceEmulator).to.respondTo('stop'); 19 | }); 20 | }); 21 | describe('.loadScenario()', function () { 22 | it("should fail when doesn't fit json schema", function (done) { 23 | this.timeout(30 * 1000); 24 | var falsyScenarioObject = { 25 | notATimeline: [{ 26 | notTime: 6722, 27 | notIpcMessage: '{"data":"{\\"applicationId\\":\\"ABCD1234\\",\\"applicationName\\":\\"Testing App\\",\\"closedCaption\\":{},\\"deviceCapabilities\\":{\\"bluetooth_supported\\":false,\\"display_supported\\":true,\\"hi_res_audio_supported\\":false},\\"launchingSenderId\\":\\"aaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.ff:com.example.android-000\\",\\"messagesVersion\\":\\"1.0\\",\\"sessionId\\":\\"d111111b-2222-3333-4444-55d5e5eebc5b\\",\\"type\\":\\"ready\\",\\"version\\":\\"1.22.78337\\"}","namespace":"urn:x-cast:com.google.cast.system","senderId":"SystemSender"}' 28 | }] 29 | }; 30 | var emulator = new CastDeviceEmulator(); 31 | var badFunction = function badFunction() { 32 | emulator.loadScenario(falsyScenarioObject); 33 | }; 34 | expect(badFunction).to.throw(Error); 35 | done(); 36 | }); 37 | }); 38 | describe('.close()', function () { 39 | it('should close websocket server properly', function (done) { 40 | this.timeout(30 * 1000); 41 | 42 | var scenario = require('../examples/scenarios/example.json'); 43 | 44 | var emulator1 = new CastDeviceEmulator(); 45 | var emulator2 = new CastDeviceEmulator(); 46 | 47 | emulator1.loadScenario(scenario); 48 | emulator2.loadScenario(scenario); 49 | 50 | // Well, I just don't want install promise-related packages.. 51 | emulator1.start(function () { 52 | emulator1.close(function () { 53 | emulator2.start(function () { 54 | emulator2.close(done); 55 | }); 56 | }); 57 | }); 58 | }); 59 | }); 60 | describe('WebSocket basic operations', function () { 61 | it('should respond to websocket client connection', function (done) { 62 | this.timeout(30 * 1000); 63 | 64 | var emulator = new CastDeviceEmulator({ 65 | silent: true 66 | }); 67 | emulator.loadScenario(require('../examples/scenarios/example.json')); 68 | emulator.start(); 69 | 70 | // Trying to mimic client behavior 71 | var wsc = new WebSocket('ws://localhost:8008/v2/ipc'); 72 | wsc.on('open', function open() { 73 | // Emit start-up messages once websocket connection is established. 74 | var MESSAGE = { 75 | READY: '{"namespace":"urn:x-cast:com.google.cast.system","senderId":"SystemSender","data":"{"type":"ready","statusText":"Ready to play","activeNamespaces":["urn:x-cast:com.example.cast.custom","urn:x-cast:com.google.cast.broadcast","urn:x-cast:com.google.cast.media","urn:x-cast:com.google.cast.inject"],"version":"2.0.0","messagesVersion":"1.0"}"}', 76 | START_HEARTBEAT: '{"namespace":"urn:x-cast:com.google.cast.system","senderId":"SystemSender","data":"{"type":"startheartbeat","maxInactivity":10}"}', 77 | MEDIA_STATUS: '{"namespace":"urn:x-cast:com.google.cast.media","senderId":"*:*","data":"{"type":"MEDIA_STATUS","status":[],"requestId":0}"}' 78 | }; 79 | wsc.send(MESSAGE.READY); 80 | wsc.send(MESSAGE.START_HEARTBEAT); 81 | wsc.send(MESSAGE.MEDIA_STATUS); 82 | }); 83 | wsc.on('message', function incoming(message, flags) { 84 | // console.log('wsc received:', message); 85 | var incomingMessageObject = JSON.parse(message); 86 | var dataProperty = JSON.parse(incomingMessageObject.data); 87 | var typeProperty = dataProperty.type; 88 | 89 | // Every incoming IPC messages should match expected json-schema 90 | expect(jsonSchemaValidator.validate(require('../schemas/ipc-message.json'), incomingMessageObject)).to.be.true; 91 | 92 | console.log('Received an event:', typeProperty); 93 | // Client did received QUEUE_LOAD message from emulator 94 | if (typeProperty.toUpperCase() === 'QUEUE_LOAD') { 95 | emulator.close(done); 96 | } 97 | }); 98 | }); 99 | }); 100 | }); -------------------------------------------------------------------------------- /src/index.spec.js: -------------------------------------------------------------------------------- 1 | const CastDeviceEmulator = require('./'); 2 | const WebSocket = require('ws'); 3 | const Ajv = require('ajv'); 4 | 5 | const jsonSchemaValidator = new Ajv(); 6 | const { 7 | assert, 8 | expect 9 | } = require('chai'); 10 | 11 | describe('CastDeviceEmulator', function () { 12 | describe('Methods existance', function () { 13 | it('should have expected public methods', function () { 14 | expect(CastDeviceEmulator).to.respondTo('loadScenario'); 15 | expect(CastDeviceEmulator).to.respondTo('start'); 16 | expect(CastDeviceEmulator).to.respondTo('stop'); 17 | }); 18 | }); 19 | describe('.loadScenario()', function () { 20 | it("should fail when doesn't fit json schema", function (done) { 21 | this.timeout(30 * 1000); 22 | var falsyScenarioObject = { 23 | notATimeline: [{ 24 | notTime: 6722, 25 | notIpcMessage: '{"data":"{\\"applicationId\\":\\"ABCD1234\\",\\"applicationName\\":\\"Testing App\\",\\"closedCaption\\":{},\\"deviceCapabilities\\":{\\"bluetooth_supported\\":false,\\"display_supported\\":true,\\"hi_res_audio_supported\\":false},\\"launchingSenderId\\":\\"aaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.ff:com.example.android-000\\",\\"messagesVersion\\":\\"1.0\\",\\"sessionId\\":\\"d111111b-2222-3333-4444-55d5e5eebc5b\\",\\"type\\":\\"ready\\",\\"version\\":\\"1.22.78337\\"}","namespace":"urn:x-cast:com.google.cast.system","senderId":"SystemSender"}' 26 | }] 27 | }; 28 | const emulator = new CastDeviceEmulator(); 29 | const badFunction = () => { 30 | emulator.loadScenario(falsyScenarioObject); 31 | }; 32 | expect(badFunction).to.throw(Error); 33 | done(); 34 | }); 35 | }); 36 | describe('.close()', function () { 37 | it('should close websocket server properly', function ( 38 | done 39 | ) { 40 | this.timeout(30 * 1000); 41 | 42 | const scenario = require('../examples/scenarios/example.json'); 43 | 44 | const emulator1 = new CastDeviceEmulator(); 45 | const emulator2 = new CastDeviceEmulator(); 46 | 47 | emulator1.loadScenario(scenario); 48 | emulator2.loadScenario(scenario); 49 | 50 | // Well, I just don't want install promise-related packages.. 51 | emulator1.start(function () { 52 | emulator1.close(function () { 53 | emulator2.start(function () { 54 | emulator2.close(done); 55 | }); 56 | }); 57 | }); 58 | }); 59 | }); 60 | describe('WebSocket basic operations', function () { 61 | it('should respond to websocket client connection', function (done) { 62 | this.timeout(30 * 1000); 63 | 64 | const emulator = new CastDeviceEmulator({ 65 | silent: true 66 | }); 67 | emulator.loadScenario(require('../examples/scenarios/example.json')); 68 | emulator.start(); 69 | 70 | // Trying to mimic client behavior 71 | const wsc = new WebSocket('ws://localhost:8008/v2/ipc'); 72 | wsc.on('open', function open() { 73 | // Emit start-up messages once websocket connection is established. 74 | const MESSAGE = { 75 | READY: '{"namespace":"urn:x-cast:com.google.cast.system","senderId":"SystemSender","data":"{"type":"ready","statusText":"Ready to play","activeNamespaces":["urn:x-cast:com.example.cast.custom","urn:x-cast:com.google.cast.broadcast","urn:x-cast:com.google.cast.media","urn:x-cast:com.google.cast.inject"],"version":"2.0.0","messagesVersion":"1.0"}"}', 76 | START_HEARTBEAT: '{"namespace":"urn:x-cast:com.google.cast.system","senderId":"SystemSender","data":"{"type":"startheartbeat","maxInactivity":10}"}', 77 | MEDIA_STATUS: '{"namespace":"urn:x-cast:com.google.cast.media","senderId":"*:*","data":"{"type":"MEDIA_STATUS","status":[],"requestId":0}"}' 78 | }; 79 | wsc.send(MESSAGE.READY); 80 | wsc.send(MESSAGE.START_HEARTBEAT); 81 | wsc.send(MESSAGE.MEDIA_STATUS); 82 | }); 83 | wsc.on('message', function incoming(message, flags) { 84 | // console.log('wsc received:', message); 85 | const incomingMessageObject = JSON.parse(message); 86 | const dataProperty = JSON.parse(incomingMessageObject.data); 87 | const typeProperty = dataProperty.type; 88 | 89 | // Every incoming IPC messages should match expected json-schema 90 | expect( 91 | jsonSchemaValidator.validate( 92 | require('../schemas/ipc-message.json'), 93 | incomingMessageObject 94 | ) 95 | ).to.be.true; 96 | 97 | console.log('Received an event:', typeProperty); 98 | // Client did received QUEUE_LOAD message from emulator 99 | if (typeProperty.toUpperCase() === 'QUEUE_LOAD') { 100 | emulator.close(done); 101 | } 102 | }); 103 | }); 104 | }); 105 | }); -------------------------------------------------------------------------------- /examples/scenarios/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeline": [{ 3 | "time": 12250, 4 | "ipcMessage": "{\"data\":\"{\\\"applicationId\\\":\\\"7BDF108F\\\",\\\"applicationName\\\":\\\"KKBOX Local Testing App v1.2\\\",\\\"closedCaption\\\":{},\\\"deviceCapabilities\\\":{\\\"bluetooth_supported\\\":false,\\\"display_supported\\\":true,\\\"focus_state_supported\\\":true,\\\"hi_res_audio_supported\\\":false},\\\"launchingSenderId\\\":\\\"e2bd069b-d6d0-7d35-d0cb-fd6287ffebde.45:com.skysoft.kkbox.android-29\\\",\\\"messagesVersion\\\":\\\"1.0\\\",\\\"sessionId\\\":\\\"010c6a19-f5b2-48fc-bf48-c7cafe1da920\\\",\\\"type\\\":\\\"ready\\\",\\\"version\\\":\\\"1.29.104827\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.system\",\"senderId\":\"SystemSender\"}" 5 | }, { 6 | "time": 12312, 7 | "ipcMessage": "{\"data\":\"{\\\"level\\\":1.0,\\\"muted\\\":false,\\\"type\\\":\\\"volumechanged\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.system\",\"senderId\":\"SystemSender\"}" 8 | }, { 9 | "time": 12336, 10 | "ipcMessage": "{\"data\":\"{\\\"type\\\":\\\"visibilitychanged\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.system\",\"senderId\":\"SystemSender\"}" 11 | }, { 12 | "time": 12341, 13 | "ipcMessage": "{\"data\":\"{\\\"type\\\":\\\"standbychanged\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.system\",\"senderId\":\"SystemSender\"}" 14 | }, { 15 | "time": 12473, 16 | "ipcMessage": "{\"data\":\"{\\\"hdrType\\\":\\\"sdr\\\",\\\"type\\\":\\\"hdroutputtypechanged\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.system\",\"senderId\":\"SystemSender\"}" 17 | }, { 18 | "time": 12531, 19 | "ipcMessage": "{\"data\":\"{\\\"senderId\\\":\\\"e2bd069b-d6d0-7d35-d0cb-fd6287ffebde.45:com.skysoft.kkbox.android-29\\\",\\\"type\\\":\\\"senderconnected\\\",\\\"userAgent\\\":\\\"Android CastSDK,12685023,SM-N9208,nobleltezt,7.0\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.system\",\"senderId\":\"SystemSender\"}" 20 | }, { 21 | "time": 12630, 22 | "ipcMessage": "{\"data\":\"{\\\"requestId\\\":1,\\\"type\\\":\\\"GET_STATUS\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.media\",\"senderId\":\"e2bd069b-d6d0-7d35-d0cb-fd6287ffebde.45:com.skysoft.kkbox.android-29\"}" 23 | }, { 24 | "time": 12707, 25 | "ipcMessage": "{\"data\":\"{\\\"requestId\\\":1,\\\"type\\\":\\\"GET_STATUS\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.media\",\"senderId\":\"e2bd069b-d6d0-7d35-d0cb-fd6287ffebde.43:com.google.android.gms-27\"}" 26 | }, { 27 | "time": 13951, 28 | "ipcMessage": "{\"data\":\"{\\\"requestId\\\":2,\\\"type\\\":\\\"QUEUE_LOAD\\\",\\\"items\\\":[{\\\"media\\\":{\\\"contentId\\\":\\\"207178635\\\",\\\"streamType\\\":\\\"BUFFERED\\\",\\\"contentType\\\":\\\"audio\\\\/mp3\\\",\\\"metadata\\\":{\\\"metadataType\\\":3,\\\"title\\\":\\\"ONE WAY~on my own~\\\",\\\"trackNumber\\\":207178635,\\\"contentId\\\":\\\"1nc00KqXlS2W00CMW00CM0XL\\\"},\\\"duration\\\":234},\\\"autoplay\\\":true,\\\"startTime\\\":0,\\\"preloadTime\\\":10},{\\\"media\\\":{\\\"contentId\\\":\\\"200144402\\\",\\\"streamType\\\":\\\"BUFFERED\\\",\\\"contentType\\\":\\\"audio\\\\/mp3\\\",\\\"metadata\\\":{\\\"metadataType\\\":3,\\\"title\\\":\\\"Two of us\\\",\\\"trackNumber\\\":200144402,\\\"contentId\\\":\\\"d-b0084H102CBsMeCBsMe0XL\\\"},\\\"duration\\\":292},\\\"autoplay\\\":true,\\\"startTime\\\":0,\\\"preloadTime\\\":10},{\\\"media\\\":{\\\"contentId\\\":\\\"209913592\\\",\\\"streamType\\\":\\\"BUFFERED\\\",\\\"contentType\\\":\\\"audio\\\\/mp3\\\",\\\"metadata\\\":{\\\"metadataType\\\":3,\\\"title\\\":\\\"Three\\\",\\\"trackNumber\\\":209913592,\\\"contentId\\\":\\\"EAl00ALCP-2HaZ.UHaZ.U0XL\\\"},\\\"duration\\\":268},\\\"autoplay\\\":true,\\\"startTime\\\":0,\\\"preloadTime\\\":10}],\\\"repeatMode\\\":\\\"REPEAT_SINGLE\\\",\\\"startIndex\\\":0,\\\"currentTime\\\":9.927,\\\"customData\\\":{\\\"sid\\\":\\\"V0008bwY18500A000000000001SAZnz01CLHpAbED0K135PoQDS001R3vxN43ed\\\",\\\"enc\\\":\\\"u\\\",\\\"ver\\\":\\\"06020082\\\",\\\"os\\\":\\\"android\\\",\\\"osver\\\":\\\"7.0\\\",\\\"lang\\\":\\\"ja\\\",\\\"dist\\\":\\\"0021\\\",\\\"dist2\\\":\\\"0021\\\",\\\"userName\\\":\\\"名前なし\\\",\\\"userAvatarUrl\\\":\\\"https:\\\\/\\\\/i.kfs.io\\\\/muser\\\\/global\\\\/noimg\\\\/cropresize\\\\/180x180.jpg\\\",\\\"playMode\\\":\\\"NORMAL\\\",\\\"playlistTitle\\\":\\\"Sort\\\",\\\"checksum\\\":\\\"91daa8ba0b0ab7cbc94ba12c0bbabd41\\\",\\\"timestamp\\\":\\\"1527759179\\\"}}\",\"namespace\":\"urn:x-cast:com.google.cast.media\",\"senderId\":\"e2bd069b-d6d0-7d35-d0cb-fd6287ffebde.45:com.skysoft.kkbox.android-29\"}" 29 | }, { 30 | "time": 20981, 31 | "ipcMessage": "{\"data\":\"{\\\"requestId\\\":3,\\\"type\\\":\\\"QUEUE_UPDATE\\\",\\\"mediaSessionId\\\":1,\\\"items\\\":[{\\\"media\\\":{\\\"contentId\\\":\\\"200144402\\\",\\\"streamType\\\":\\\"BUFFERED\\\",\\\"contentType\\\":\\\"audio\\\\/mp3\\\",\\\"metadata\\\":{\\\"metadataType\\\":3,\\\"images\\\":[{\\\"url\\\":\\\"https:\\\\/\\\\/i.kfs.io\\\\/album\\\\/jp\\\\/19256597,P\\\\/fit\\\\/500x500.jpg\\\",\\\"width\\\":0,\\\"height\\\":0}],\\\"title\\\":\\\"Two of us\\\",\\\"trackNumber\\\":200144402,\\\"contentId\\\":\\\"d-b0084H102CBsMeCBsMe0XL\\\"},\\\"duration\\\":292},\\\"itemId\\\":2,\\\"autoplay\\\":true,\\\"startTime\\\":0,\\\"preloadTime\\\":10}]}\",\"namespace\":\"urn:x-cast:com.google.cast.media\",\"senderId\":\"e2bd069b-d6d0-7d35-d0cb-fd6287ffebde.45:com.skysoft.kkbox.android-29\"}" 32 | }] 33 | } -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 4 | 5 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 6 | 7 | var WebSocket = require('ws'); 8 | var chalk = require('chalk'); 9 | 10 | var _require = require('./log'), 11 | log = _require.log, 12 | error = _require.error; 13 | 14 | /** 15 | * The JSON Schema validator 16 | */ 17 | 18 | 19 | var Ajv = require('ajv'); 20 | var jsonSchemaValidator = new Ajv(); 21 | 22 | /** 23 | * Configuration for WebSocket server 24 | */ 25 | var serverConfig = { 26 | port: 8008, 27 | path: '/v2/ipc' 28 | }; 29 | 30 | var CastDeviceEmulator = function () { 31 | function CastDeviceEmulator() { 32 | var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 33 | 34 | _classCallCheck(this, CastDeviceEmulator); 35 | 36 | this.options = options; 37 | 38 | /** 39 | * WebSocker server instance. 40 | */ 41 | this.wss = null; 42 | 43 | /** 44 | * The recorded messages that we're going to serve. 45 | */ 46 | this.recordedMessages = []; 47 | 48 | /** 49 | * Event handlers for WebSocket server 50 | */ 51 | this._webSocketMessageHandler = this._webSocketMessageHandler.bind(this); 52 | this._webSocketConnectionHandler = this._webSocketConnectionHandler.bind(this); 53 | } 54 | 55 | /** 56 | * Load the specific scenario file 57 | */ 58 | 59 | 60 | _createClass(CastDeviceEmulator, [{ 61 | key: 'loadScenario', 62 | value: function loadScenario(scenarioFile) { 63 | if (!jsonSchemaValidator.validate(require('../schemas/scenario.json'), scenarioFile)) { 64 | throw new Error('Invalid scenario schema!'); 65 | } 66 | this.recordedMessages = scenarioFile.timeline; 67 | } 68 | 69 | /** 70 | * Startup the emulator 71 | */ 72 | 73 | }, { 74 | key: 'start', 75 | value: function start(callback) { 76 | this.wss = new WebSocket.Server({ 77 | port: serverConfig.port, 78 | path: serverConfig.path 79 | }, 80 | /** 81 | * When WebSocket server start listening, 82 | * we're going to listen to connection event as well. 83 | */ 84 | function onListeningCallback() { 85 | this.wss.on('connection', this._webSocketConnectionHandler); 86 | if (!this.options.silent) { 87 | log('Established a websocket server at port ' + serverConfig.port); 88 | log('Ready for Chromecast receiver connections..'); 89 | } 90 | if (callback) callback(); 91 | }.bind(this)); 92 | } 93 | 94 | /** 95 | * Stop handling events from WebSocket server 96 | */ 97 | 98 | }, { 99 | key: 'stop', 100 | value: function stop() { 101 | this.wss.removeAllListeners('message'); 102 | this.wss.removeAllListeners('connection'); 103 | } 104 | 105 | /** 106 | * Close the WebSocket server 107 | */ 108 | 109 | }, { 110 | key: 'close', 111 | value: function close(callback) { 112 | var _this = this; 113 | 114 | if (!this.wss) { 115 | log('There is no websocket existing.'); 116 | return; 117 | } 118 | this.wss.close(function () { 119 | if (!_this.options.silent) { 120 | log('Chromecast Device Emulator is closed.'); 121 | } 122 | if (callback) callback(); 123 | }); 124 | } 125 | 126 | /** 127 | * Handle incoming WebSocket connections 128 | */ 129 | 130 | }, { 131 | key: '_webSocketConnectionHandler', 132 | value: function _webSocketConnectionHandler(ws) { 133 | if (!this.options.silent) log('There is a cast client just connected.'); 134 | /** 135 | * Listen to message events on each socket connection 136 | */ 137 | ws.on('message', this._webSocketMessageHandler); 138 | /** 139 | * Iterate over the recorded messages 140 | * and set a triggering timer for every single message. 141 | */ 142 | this.recordedMessages.map(function (m) { 143 | // FIXME: Validate format before send it 144 | var sendRecordedMessage = function sendRecordedMessage() { 145 | if (ws.readyState === WebSocket.OPEN) { 146 | ws.send(m.ipcMessage); 147 | log(chalk.red('>>'), m.ipcMessage); 148 | } 149 | }; 150 | setTimeout(sendRecordedMessage, m.time); 151 | }); 152 | } 153 | 154 | /** 155 | * Handle incoming WebSocket messages 156 | */ 157 | 158 | }, { 159 | key: '_webSocketMessageHandler', 160 | value: function _webSocketMessageHandler(message) { 161 | log(chalk.green('<<'), message); 162 | } 163 | }]); 164 | 165 | return CastDeviceEmulator; 166 | }(); 167 | 168 | module.exports = CastDeviceEmulator; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chromecast Device Emulator 2 | 3 | Testing your chromecast receiver app, without a real-device needed. 4 | 5 | [![npm](https://img.shields.io/npm/v/chromecast-device-emulator.svg)](https://www.npmjs.com/package/chromecast-device-emulator) 6 | [![Build Status](https://travis-ci.org/ajhsu/chromecast-device-emulator.svg?branch=master)](https://travis-ci.org/ajhsu/chromecast-device-emulator) 7 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 8 | 9 | ## TL;DR 10 | **Chromecast Device Emulator** is a set of tools that enables you to develop, run, and test your chromecast receiver app right on your local machine. 11 | 12 | ## What is this? 13 | So far the only way to test your receiver app is to run on a Google Cast device (e.g. Chromecast, Google Home). Also, you have to make sure that your app is public accessible via an HTTPS connection. 14 | 15 | It turns out whenever you're try to test the receiver app, you have to deploy the application every single time; This kind of deploy-and-debug routine could be redundant and time-wasting. 16 | 17 | The emulator is designed for letting developers getting away from this, by emulating a Google Cast right on your local machine. 18 | 19 | ## How it works? 20 | 21 | Before getting started, we have to understand how the emulator works: 22 | 23 | What a Google Cast device do, is providing a chromium browser with a socket server that handing over IPC messages between sender(s) and receiver. 24 | 25 | So we can emulate the same context by creating a socket server in the background while we're developing receiver app on a local machine (e.g. your laptop). 26 | 27 |

28 | Diagram of Emulator 29 |

30 | 31 | ## Usage 32 | 33 | There're two types of usage: 34 | 35 | 1. CLI: Running the emulator as a CLI. Ideal for local development. 36 | 37 | 2. Node API: Install `chromecast-device-emulator` as your dependency. Ideal for integrating your test automation. 38 | 39 | ### 1. CLI 40 | 41 | To run as a CLI, we can install executable npm package globally: 42 | 43 | ```bash 44 | $ npm install chromecast-device-emulator -g 45 | ``` 46 | 47 | Startup the emulator with a [pre-recorded scenario JSON file](#what-is-a-pre-recorded-scenario-json-file) 48 | 49 | ```bash 50 | $ chromecast-device-emulator start scenario.json 51 | ``` 52 | 53 | Or `cde` for short 54 | ```bash 55 | $ cde start scenario.json 56 | ``` 57 | 58 | The emulator will up and serve at port 8008 for local development. 59 | 60 | > Note that the emulator will establish a new connection for each receiver app so that you can test multiple receiver apps at the same time. 61 | 62 | ### 2. Node API 63 | 64 | To use as a node package, you can install as your dependency: 65 | ```bash 66 | npm install chromecast-device-emulator --save-dev 67 | ``` 68 | 69 | After that, we can import the package and create an emulator. 70 | 71 | ```javascript 72 | var CastDeviceEmulator = require('chromecast-device-emulator'); 73 | 74 | // Create a new instance 75 | var emulator = new CastDeviceEmulator(); 76 | ``` 77 | 78 | Load and serve your [pre-recorded scenario JSON file](#what-is-a-pre-recorded-scenario-json-file) 79 | 80 | ```javascript 81 | // Load pre-recorded scenario 82 | emulator.loadScenario(require('./scenario.json')); 83 | 84 | // Startup the emulator 85 | emulator.start(); 86 | 87 | // Server is up for receiver app 88 | // Do something... 89 | 90 | // Stop the emulator 91 | emulator.stop(); 92 | ``` 93 | 94 | ## What is a pre-recorded scenario JSON file? 95 | 96 | What emulator did, is try to **REPLAY** the IPC messages between sender and receiver. Which means that you have to **PRE-RECORD** from a real Google Cast device to get these IPC messages. 97 | 98 | *(See [The IPC Message Recorder](#record-your-scenario-with-ipc-message-recorder) chapter for details)* 99 | 100 | From receiver app booted until it was closed; Each of IPC message between sender and receiver should be recorded into one single JSON file with timestamps. 101 | By doing so, the emulator is able to **REPLAY** these message when we needed it. And we called it a "Scenario JSON file". 102 | 103 | A simple scenario JSON will look like this: 104 | ```json 105 | { 106 | "timeline": [ 107 | { 108 | "time": 5520, 109 | "ipcMessage": "{\"data\":\"{\\\"type\\\":\\\"visibilitychanged\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.system\",\"senderId\":\"SystemSender\"}" 110 | }, { 111 | "time": 5538, 112 | "ipcMessage": "{\"data\":\"{\\\"type\\\":\\\"standbychanged\\\"}\",\"namespace\":\"urn:x-cast:com.google.cast.system\",\"senderId\":\"SystemSender\"}" 113 | }, { 114 | "time": 5926, 115 | "ipcMessage": "{\"data\":\"{\\\"requestId\\\":1,\\\"type\\\":\\\"GET_STATUS\\\"}\"}" 116 | } 117 | ] 118 | } 119 | ``` 120 | - `timeline` is the array that contains every message that sender sent. 121 | - `time` represent when is the message was sent (counted from bootstrap). 122 | - `ipcMessage` represent the data sent from the sender. 123 | 124 | ## Receiver Utilities 125 | 126 | ### Record your scenario with IPC Message Recorder 127 | In order to **PRE-RECORD** messages from sender, we've created a tool that you can intercept these messages from a physical Google Cast device by following steps: 128 | 129 | #### 1. Add `receiver-utils` script into your receiver app 130 | 131 | First, you need to place the following script tag into your receiver app. 132 | ```html 133 | 134 | 135 | ``` 136 | 137 | Please make sure that `receiver-utils` was placed **BEFORE** the google cast SDKs; So that the message recorder can work correctly. 138 | 139 | After placed the script tag, your HTML might look like this: 140 | 141 | ```html 142 | 143 | 144 | 145 | 146 | 147 | 148 | ``` 149 | 150 | Once you are placed the script tag correctly, you should see the following debug message in your console (via remote debugging): 151 | 152 | (If you don't know how to remote debug, see [how to remote debug](https://developers.google.com/cast/docs/debugging) on your cast device) 153 | 154 | ``` 155 | chromecast-device-emulator: receiver-utils loaded. 156 | chromecast-device-emulator: device-polyfill module loaded. 157 | chromecast-device-emulator: scenario-recorder module loaded. 158 | ``` 159 | 160 | It means you're ready to go! 161 | 162 | > NOTE: 163 | > Since the `receiver-utils` need to pre-record messages from WebSocket, 164 | > it makes some hacky modification onto your receiver app during the runtime. 165 | > So please remember to remove the above script tag from your production build. 166 | 167 | #### 2. Start to record your scenario 168 | Once you placed the message recorder into your receiver app, you can start the user scenario on the physical device (e.g. casting to the device, pausing a video, changing the volume, seeking for specific progress). 169 | Each of the user behavior/interaction will be recorded in the scenario JSON file. 170 | 171 | #### 3. Export scenario JSON 172 | Once you've finished your user scenario, you are ready to export the scenario JSON from Chrome DevTool: 173 | 174 | Open up your console drawer and type `CDE.exportScenario()` to export the scenario JSON. 175 | 176 | Then you will get a HUGE JSON output like this: 177 | 178 | ```json 179 | {"timeline":[{"time":17163,"ipcMessage":"{\"data\":\"{\\\" 180 | .... 181 | .... 182 | .... 183 | \"}"}]} 184 | ``` 185 | 186 | Just copy and save it into a plain JSON file, and we will need to serve the file later with the emulator. 187 | 188 | #### 4. Serve your scenario JSON file with emulator 189 | 190 | Now, we got a scenario JSON file from Google Cast device. 191 | 192 | So we're ready to serve and "replay" the scenario with the emulator by running: 193 | 194 | ```bash 195 | $ chromecast-device-emulator start scenario.json 196 | ``` 197 | 198 | That's all! The emulator is now running in the background for you. Try to open up your receiver app on your local machine and see if the receiver is communicating with emulator correctly. 199 | 200 | *Happy casting!* 201 | 202 | ## Few benefits from developing with emulator 203 | 204 | #### 1. Able to run your receiver app on the local machine. 205 | 206 | You can test the receiver app with your local machine that runs 100x faster than a physical Google Case device (e.g. Chromecast 1/2/Ultra) 207 | 208 | #### 2. Debugging your receiver app on local machine 209 | 210 | You don't need to do remote debugging via Chrome inspector anymore; 211 | And you can take advantage of Chrome DevTools during the local development. 212 | 213 | #### 3. Debugging multiple receiver apps at the same time. 214 | 215 | You can test your receiver app in parallel. 216 | 217 | #### 4. Running end-to-end testing in your continuous integration system. 218 | 219 | Once we jump off our runtime from physical Google Cast devices, we're able to do end-to-end testing right on your local machine; And if you're able to run on your local machine, why not integrate with your CI build process? 220 | 221 | ## LICENSE 222 | 223 | MIT 224 | -------------------------------------------------------------------------------- /diagram.svg: -------------------------------------------------------------------------------- 1 | 2 |

Browser

<p style="margin: 4px 0px 0px ; text-align: center ; font-size: 13px">Browser</p>
Find the receiver app
via your App ID
[Not supported by viewer]
Receiver App
Receiver App
WebSocket
WebSocket
Cast Device
Cast Device
Sender
Sender
Sender SDK
Sender SDK
User Scenario

Load, Play, Pause, Seek .. etc
User Scenario<br/><br/>Load, Play, Pause, Seek .. etc

Browser

<p style="margin: 4px 0px 0px ; text-align: center ; font-size: 13px">Browser</p>
Receiver App
Receiver App
WebSocket
WebSocket
Cast Device Emulator
Cast Device Emulator
Pre-recorded User Scenario file

Load, Play, Pause, Seek .. etc
Pre-recorded User Scenario file<br/><br/>Load, Play, Pause, Seek .. etc
Play
Play
Pause
Pause
Seek to 30s
Seek to 30s
3s
3s
5s
5s
10s
10s
Load
Load
1s
1s
Sent Messages
Sent Messages
Play
Play
Pause
Pause
Seek to 30s
Seek to 30s
3s
3s
5s
5s
10s
10s
Load
Load
1s
1s
Sent Messages
Sent Messages
Play
Play
Pause
Pause
Seek to 30s
Seek to 30s
3s
3s
5s
5s
10s
10s
Load
Load
1s
1s
Sent Messages
Sent Messages
Play
Play
Pause
Pause
Seek to 30s
Seek to 30s
3s
3s
5s
5s
10s
10s
Load
Load
1s
1s
Sent Messages
Sent Messages
Load
Load
YOUR WEB APP

(Public accessible)

[Not supported by viewer]
Export IPC messages
Export IPC messages
receiver-utils
receiver-utils
Development on Google Cast devices
Development on Google Cast devices
Development with Emulator
Development with Emulator
--------------------------------------------------------------------------------