shaka-player
Version:
DASH/EME video player library
880 lines (766 loc) • 31.7 kB
JavaScript
/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Karma configuration
// Install required modules by running "npm install"
// lodash is an indirect dependency, depended on by Karma
const _ = require('lodash');
const fs = require('fs');
const glob = require('glob');
const Jimp = require('jimp');
const path = require('path');
const rimraf = require('rimraf');
const {ssim} = require('ssim.js');
const util = require('karma/common/util');
const which = require('which');
const yaml = require('js-yaml');
/**
* Like Object.assign, but recursive and doesn't clobber objects and arrays.
* If two arrays are merged, they are concatenated.
* Ex:
* mergeConfigs({ foo: 'bar', args: [1, 2, 3] },
* { baz: 'blah', args: [4, 5, 6] })
* => { foo: 'bar', baz: 'blah', args: [1, 2, 3, 4, 5, 6] }
*
* @param {Object} first
* @param {Object} second
* @return {Object}
*/
function mergeConfigs(first, second) {
return _.mergeWith(
first,
second,
(firstValue, secondValue) => {
// Merge arrays by concatenation.
if (Array.isArray(firstValue)) {
return firstValue.concat(secondValue);
}
// Use lodash's default merge behavior for everything else.
return undefined;
});
}
/**
* @param {Object} config
*/
module.exports = (config) => {
const SHAKA_LOG_MAP = {
none: 0,
error: 1,
warning: 2,
info: 3,
debug: 4,
v1: 5,
v2: 6,
};
const KARMA_LOG_MAP = {
disable: config.LOG_DISABLE,
error: config.LOG_ERROR,
warn: config.LOG_WARN,
info: config.LOG_INFO,
debug: config.LOG_DEBUG,
};
// Find the settings JSON object in the command arguments
const args = process.argv;
const settingsIndex = args.indexOf('--settings');
const settings =
settingsIndex >= 0 ? JSON.parse(args[settingsIndex + 1]) : {};
if (settings.grid_config) {
const gridBrowserMetadata =
yaml.load(fs.readFileSync(settings.grid_config, 'utf8'));
const customLaunchers = {};
const [gridHostname, gridPort] = settings.grid_address.split(':');
console.log(`Using Selenium grid at ${gridHostname}:${gridPort}`);
// By default, run on all grid browsers instead of the platform-specific
// default. This does not disable local browsers, though. Users can still
// specify a mix of grid and local browsers explicitly.
settings.default_browsers = [];
for (const name in gridBrowserMetadata) {
if (name == 'vars') {
// Skip variable defs in the YAML file
continue;
}
const metadata = gridBrowserMetadata[name];
const launcher = {};
customLaunchers[name] = launcher;
// Disabled-by-default browsers are still defined, but not put in the
// default list. A user can ask for one explicitly. This allows us to
// disable a browser that is down for some reason in the lab, but still
// ask for it manually if we want to test it before re-enabling it for
// everyone.
if (!metadata.disabled) {
settings.default_browsers.push(name);
}
// Add standard WebDriver configs.
mergeConfigs(launcher, {
base: 'WebDriver',
config: {hostname: gridHostname, port: gridPort},
pseudoActivityInterval: 20000,
browserName: metadata.browser,
platform: metadata.os,
version: metadata.version,
});
if (metadata.extra_configs) {
for (const config of metadata.extra_configs) {
mergeConfigs(launcher, config);
}
}
}
config.set({
customLaunchers: customLaunchers,
});
}
if (settings.browsers && settings.browsers.length == 1 &&
settings.browsers[0] == 'help') {
console.log('Available browsers:');
console.log('===================');
for (const name of allUsableBrowserLaunchers(config)) {
console.log(' ' + name);
}
process.exit(1);
}
// Resolve the set of browsers we will use.
const browserSet = new Set(settings.browsers && settings.browsers.length ?
settings.browsers : settings.default_browsers);
if (settings.exclude_browsers) {
for (const excluded of settings.exclude_browsers) {
browserSet.delete(excluded);
}
}
let browsers = Array.from(browserSet).sort();
if (settings.no_browsers) {
console.warn(
'--no-browsers: In this mode, you must connect browsers to Karma.');
browsers = null;
} else {
console.warn('Running tests on: ' + browsers.join(', '));
}
config.set({
// base path that will be used to resolve all patterns (eg. files, exclude)
basePath: '.',
// frameworks to use
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: [
'jasmine-ajax',
'jasmine',
],
// An expressjs middleware, essentially a component that handles requests
// in Karma's webserver. This one is custom, and will let us take
// screenshots of browsers connected through WebDriver.
middleware: ['webdriver-screenshot'],
plugins: [
'karma-*', // default plugins
'@*/karma-*', // default scoped plugins
// An inline plugin which supplies the webdriver-screenshot middleware.
{
'middleware:webdriver-screenshot': [
'factory', WebDriverScreenshotMiddlewareFactory,
],
},
],
// list of files / patterns to load in the browser
files: [
// Polyfills first, primarily for IE 11 and older TVs:
// Promise polyfill, required since we test uncompiled code on IE11
'node_modules/es6-promise-polyfill/promise.js',
// Babel polyfill, required for async/await
'node_modules/@babel/polyfill/dist/polyfill.js',
// codem-isoboxer module next
'node_modules/codem-isoboxer/dist/iso_boxer.min.js',
// EME encryption scheme polyfill, compiled into Shaka Player, but outside
// of the Closure deps system, so not in shaka-player.uncompiled.js. This
// is specifically the compiled, minified, cross-browser build of it.
// eslint-disable-next-line max-len
'node_modules/eme-encryption-scheme-polyfill/dist/eme-encryption-scheme-polyfill.js',
// load closure base, the deps tree, and the uncompiled library
'node_modules/google-closure-library/closure/goog/base.js',
'dist/deps.js',
'shaka-player.uncompiled.js',
// the demo's config tab will register with shakaDemoMain, and will be
// tested in test/demo/demo_unit.js
'demo/config.js',
// cajon module (an AMD variant of requirejs) next
'node_modules/cajon/cajon.js',
// define the test namespace next (shaka.test)
'test/test/namespace.js',
// test utilities next, which fill in that namespace
'test/test/util/*.js',
// bootstrapping for the test suite last; this will load the actual tests
'test/test/boot.js',
// if --test-custom-asset *is not* present, we will add unit tests.
// if --quick *is not* present, we will add integration tests.
// if --external *is* present, we will add external asset tests.
// source files - these are only watched and served.
// anything not listed here can't be dynamically loaded by other scripts.
{pattern: 'lib/**/*.js', included: false},
{pattern: 'ui/**/*.js', included: false},
{pattern: 'ui/**/*.less', included: false},
{pattern: 'third_party/**/*.js', included: false},
{pattern: 'test/**/*.js', included: false},
{pattern: 'test/test/assets/*', included: false},
{pattern: 'test/test/assets/dash-multi-codec/*', included: false},
{pattern: 'test/test/assets/3675/*', included: false},
{pattern: 'test/test/assets/dash-aes-128/*', included: false},
{pattern: 'test/test/assets/hls-raw-aac/*', included: false},
{pattern: 'test/test/assets/hls-raw-ac3/*', included: false},
{pattern: 'test/test/assets/hls-raw-ec3/*', included: false},
{pattern: 'test/test/assets/hls-raw-mp3/*', included: false},
{pattern: 'test/test/assets/hls-ts-aac/*', included: false},
{pattern: 'test/test/assets/hls-ts-ac3/*', included: false},
{pattern: 'test/test/assets/hls-ts-ec3/*', included: false},
{pattern: 'test/test/assets/hls-ts-h264/*', included: false},
{pattern: 'test/test/assets/hls-ts-h265/*', included: false},
{pattern: 'test/test/assets/hls-ts-mp3/*', included: false},
{pattern: 'test/test/assets/hls-ts-muxed-aac-h264/*', included: false},
{pattern: 'test/test/assets/hls-ts-muxed-aac-h265/*', included: false},
{pattern: 'test/test/assets/hls-ts-muxed-ac3-h264/*', included: false},
{pattern: 'test/test/assets/hls-ts-muxed-mp3-h264/*', included: false},
{pattern: 'test/test/assets/hls-ts-muxed-ec3-h264/*', included: false},
{pattern: 'dist/shaka-player.ui.js', included: false},
{pattern: 'dist/locales.js', included: false},
{pattern: 'demo/**/*.js', included: false},
{pattern: 'demo/locales/en.json', included: false},
{pattern: 'demo/locales/source.json', included: false},
{pattern: 'node_modules/sprintf-js/src/sprintf.js', included: false},
{pattern: 'node_modules/less/dist/less.js', included: false},
{
pattern: 'node_modules/fontfaceonload/dist/fontfaceonload.js',
included: false,
},
],
// NOTE: Do not use proxies at all! They cannot be used with the --hostname
// option, which is necessary for some of our lab testing.
proxies: {},
// to avoid DISCONNECTED messages on Safari:
browserDisconnectTimeout: 10 * 1000, // 10s to reconnect
browserDisconnectTolerance: 1, // max of 1 disconnect is OK
browserNoActivityTimeout: 5 * 60 * 1000, // disconnect after 5m silence
processKillTimeout: 5 * 1000, // allow up to 5s for process to shut down
captureTimeout: settings.capture_timeout,
// https://support.saucelabs.com/customer/en/portal/articles/2440724
client: {
// Hide the list of connected clients in Karma, to make screenshots more
// stable.
clientDisplayNone: true,
// Only capture the client's logs if the settings want logging.
captureConsole: !!settings.logging && settings.logging != 'none',
// |args| must be an array; pass a key-value map as the sole client
// argument.
args: [{
// Run Player integration tests against external assets.
// Skipped by default.
external: !!settings.external,
// Run Player integration tests against DRM license servers.
// Skipped by default.
drm: !!settings.drm,
// Run quarantined tests which do not consistently pass.
// Skipped by default.
quarantined: !!settings.quarantined,
// Run Player integration tests with uncompiled code for debugging.
uncompiled: !!settings.uncompiled,
// Limit which tests to run. If undefined, all tests should run.
filter: settings.filter,
// Set what level of logs for the player to print.
logLevel: SHAKA_LOG_MAP[settings.logging],
// Delay tests to aid in debugging async failures that pollute
// subsequent tests.
delayTests: settings.delay_tests,
// Run playback tests on a custom manifest URI.
testCustomAsset: settings.test_custom_asset,
testCustomLicenseServer: settings.test_custom_license_server,
// Overrides the default test timeout value.
testTimeout: settings.test_timeout,
}],
},
// Specify the hostname to be used when capturing browsers.
hostname: settings.hostname,
// Specify the port where the server runs.
port: settings.port,
// Set which browsers to run on. If this is null, then Karma will wait for
// an incoming connection.
browsers,
// Enable / disable colors in the output (reporters and logs). Defaults
// to true.
colors: settings.colors,
// Set Karma's level of logging.
logLevel: KARMA_LOG_MAP[settings.log_level],
// Should Karma xecute tests whenever a file changes?
autoWatch: settings.auto_watch,
// Do a single run of the tests on captured browsers and then quit.
// Defaults to true.
singleRun: settings.single_run,
// Set the time limit (ms) that should be used to identify slow tests.
reportSlowerThan: settings.report_slower_than,
// Force failure when running empty test-suites.
failOnEmptyTestSuite: true,
specReporter: {
suppressSkipped: true,
showBrowser: true,
},
});
if (settings.babel) {
config.set({
preprocessors: {
// Use babel to convert ES6 to ES5 so we can still run tests everywhere.
// Use sourcemap to read inline source maps from babel into karma.
'demo/**/*.js': ['babel', 'sourcemap'],
'lib/**/*.js': ['babel', 'sourcemap'],
'ui/**/*.js': ['babel', 'sourcemap'],
'test/**/*.js': ['babel', 'sourcemap'],
'third_party/**/*.js': ['babel', 'sourcemap'],
},
babelPreprocessor: {
// Cache results in .babel-cache
cachePath: '.babel-cache',
options: {
presets: ['@babel/preset-env'],
// Add source maps so that backtraces refer to the original code.
// Babel will output inline source maps, and the 'sourcemap'
// preprocessor will read them and feed them to Karma. Karma will
// then use them to reformat stack traces in errors.
sourceMap: 'inline',
// Add instrumentation for code coverage.
plugins: [
['istanbul', {
// Don't instrument these parts of the codebase.
exclude: [
'demo/**/*.js',
'lib/(debug|deprecate|polyfill)/*.js',
'test/**/*.js',
'third_party/**/*.js',
],
}],
],
},
},
});
}
const clientArgs = config.client.args[0];
clientArgs.testFiles = [];
if (settings.test_custom_asset) {
// If testing custom assets, we don't serve other unit or integration tests.
// External asset tests are the basis for custom asset testing, so this file
// is automatically included.
clientArgs.testFiles.push('demo/common/asset.js');
clientArgs.testFiles.push('demo/common/assets.js');
clientArgs.testFiles.push('test/player_external.js');
} else {
// In a normal test run, we serve unit tests.
clientArgs.testFiles.push('test/**/*_unit.js');
if (!settings.quick) {
// If --quick is present, we don't serve integration tests.
clientArgs.testFiles.push('test/**/*_integration.js');
}
if (settings.external) {
// If --external is present, we serve external asset tests.
clientArgs.testFiles.push('demo/common/asset.js');
clientArgs.testFiles.push('demo/common/assets.js');
clientArgs.testFiles.push('test/**/*_external.js');
}
}
// These are the test files that will be dynamically loaded by boot.js.
clientArgs.testFiles = resolveGlobs(clientArgs.testFiles);
const reporters = [];
if (settings.reporters) {
// Explicit reporters, use these.
reporters.push(...settings.reporters);
} else if (settings.logging && settings.logging != 'none') {
// With logging, default to 'spec', which makes logs easier to associate
// with individual tests.
reporters.push('spec');
} else {
// Without logging, default to 'progress'.
reporters.push('progress');
}
if (settings.html_coverage_report) {
// Wipe out any old coverage reports to avoid confusion.
rimraf.sync('coverage', {}); // Like rm -rf
config.set({
coverageReporter: {
includeAllSources: true,
reporters: [
{type: 'html', dir: 'coverage'},
{type: 'cobertura', dir: 'coverage', file: 'coverage.xml'},
{type: 'json-summary', dir: 'coverage', file: 'coverage.json'},
{type: 'json', dir: 'coverage', file: 'coverage-details.json'},
],
},
});
// The report requires the 'coverage' reporter to be added to the list.
reporters.push('coverage');
}
config.set({reporters: reporters});
if (reporters.includes('spec') && settings.spec_hide_passed) {
config.set({specReporter: {suppressPassed: true}});
}
if (settings.random) {
// If --seed was specified use that value, else generate a seed so that the
// exact order can be reproduced if it catches an issue.
const seed = settings.seed == null ? new Date().getTime() : settings.seed;
// Run tests in a random order.
clientArgs.random = true;
clientArgs.seed = seed;
console.log('Using a random test order (--random) with --seed=' + seed);
}
if (settings.tls_key && settings.tls_cert) {
config.set({
protocol: 'https',
httpsServerOptions: {
key: fs.readFileSync(settings.tls_key),
cert: fs.readFileSync(settings.tls_cert),
},
});
}
};
/**
* Resolves a list of paths using globs into a list of explicit paths.
* Paths are all relative to the source directory.
*
* @param {!Array.<string>} list
* @return {!Array.<string>}
*/
function resolveGlobs(list) {
const options = {
cwd: __dirname,
};
const resolved = [];
for (const path of list) {
for (const resolvedPath of glob.sync(path, options)) {
resolved.push(resolvedPath);
}
}
return resolved;
}
/**
* Determines which launchers and customLaunchers can be used and returns an
* array of strings.
*
* @param {!Object} config
* @return {!Array.<string>}
*/
function allUsableBrowserLaunchers(config) {
const browsers = [];
// Load all launcher plugins.
// The format of the items in this list is something like:
// {
// 'launcher:foo1': ['type', Function],
// 'launcher:foo2': ['type', Function],
// }
// Where the launchers grouped together into one item were defined by a single
// plugin, and the Functions in the inner array are the constructors for those
// launchers.
const plugins = require('karma/lib/plugin').resolve(['karma-*-launcher']);
for (const map of plugins) {
for (const name in map) {
// Launchers should all start with 'launcher:', but occasionally we also
// see 'test' come up for some reason.
if (!name.startsWith('launcher:')) {
continue;
}
const browserName = name.split(':')[1];
const pluginConstructor = map[name][1];
// Most launchers requiring configuration through customLaunchers have
// no DEFAULT_CMD. Some launchers have DEFAULT_CMD, but not for this
// platform. Finally, WebDriver has DEFAULT_CMD, but still requires
// configuration, so we simply reject it by name.
// eslint-disable-next-line no-restricted-syntax
const DEFAULT_CMD = pluginConstructor.prototype.DEFAULT_CMD;
if (!DEFAULT_CMD || !DEFAULT_CMD[process.platform]) {
continue;
}
if (browserName == 'WebDriver') {
continue;
}
// Now that we've filtered out the browsers that can't be launched without
// custom config or that can't be launched on this platform, we filter out
// the browsers you don't have installed.
// eslint-disable-next-line no-restricted-syntax
const ENV_CMD = pluginConstructor.prototype.ENV_CMD;
const browserPath = process.env[ENV_CMD] || DEFAULT_CMD[process.platform];
if (!fs.existsSync(browserPath) &&
!which.sync(browserPath, {nothrow: true})) {
continue;
}
browsers.push(browserName);
}
}
// Once we've found the names of all the standard launchers, add to that list
// the names of any custom launcher configurations.
if (config.customLaunchers) {
browsers.push(...Object.keys(config.customLaunchers));
}
return browsers.sort();
}
/**
* This is a factory for a "middleware" component that handles requests in
* Karma's webserver. This one will let us take screenshots of browsers
* connected through WebDriver. The factory uses Karma's dependency injection
* system to get a reference to the launcher module, which we will use to get
* access to the remote browsers.
*
* @param {karma.Launcher} launcher
* @return {karma.Middleware}
*/
function WebDriverScreenshotMiddlewareFactory(launcher) {
return screenshotMiddleware;
/**
* Extract URL params from the request.
*
* @param {express.Request} request
* @return {!Object.<string, string>}
*/
function getParams(request) {
// This can be null for manually-connected browsers.
if (!request._parsedUrl.search) {
return {};
}
return util.parseQueryParams(request._parsedUrl.search);
}
/**
* Find the browser associated with the "id" parameter of the request.
* This ID was assigned by Karma when the browser was launched, and passed to
* the web server from the Jasmine tests.
*
* If the browser is not found, this function will return null.
*
* @param {?string} id
* @return {karma.Launcher.Browser|null}
*/
function getBrowser(id) {
if (!id) {
// No ID parameter? No such browser.
return null;
}
const browser = launcher._browsers.find((b) => b.id == id);
if (!browser) {
return null;
}
return browser;
}
/**
* @param {?karma.Launcher.Browser} browser
* @return {wd.remote|null} A WebDriver client, an object from the "wd"
* package, created by "wd.remote()".
*/
function getWebDriverClient(browser) {
if (!browser) {
// If we didn't launch the browser, then there's definitely no WebDriver
// client for it.
return null;
}
// If this browser was launched by the WebDriver launcher, the launcher's
// browser object has a WebDriver client in the "browser" field. Yes, this
// looks weird.
const webDriverClient = browser.browser;
// To make sure we have an actual WebDriver client and to screen out other
// launchers who may also have a "browser" field in their browser object,
// we check to make sure it has a screenshot method.
if (webDriverClient && webDriverClient.takeScreenshot) {
return webDriverClient;
}
return null;
}
/**
* @param {karma.Launcher.Browser.spec} spec
* @param {wd.remote} webDriverClient A WebDriver client, an object from the
* "wd" package, created by "wd.remote()".
* @return {!Promise.<!Buffer>} A Buffer containing a PNG screenshot
*/
function getScreenshot(spec, webDriverClient) {
return new Promise((resolve, reject) => {
webDriverClient.takeScreenshot((error, pngBase64) => {
if (error) {
reject(error);
} else if (pngBase64.error) {
// In some failure cases, pngBase64 is an object with "error",
// "message", and "stacktrace" fields. This happens, for example,
// with a timeout from the screenshot command. This is not an
// expected situation, so log it. The extra newlines keep this from
// being overwritten on the terminal when running tests against many
// browsers at once.
console.log('\n\nUnexpected screenshot failure:\n' +
` Error: ${JSON.stringify(pngBase64)}\n` +
` WebDriver spec: ${JSON.stringify(spec)}\n\n\n`);
reject(pngBase64);
} else {
// Convert the screenshot to a binary buffer.
resolve(Buffer.from(pngBase64, 'base64'));
}
});
});
}
/**
* Take a screenshot, write it to disk, and diff it against the old one.
* Write the diff to disk, as well.
*
* @param {karma.Launcher.Browser} browser
* @param {!Object.<string, string>} params
* @return {!Promise.<number>} A similarity score between 0 and 1.
*/
async function diffScreenshot(browser, params) {
const webDriverClient = getWebDriverClient(browser);
if (!webDriverClient) {
throw new Error('No screenshot support!');
}
/** @type {!Buffer} */
const fullPageScreenshotData =
await getScreenshot(browser.spec, webDriverClient);
// Crop the screenshot to the dimensions specified in the test.
// Jimp is picky about types, so convert these strings to numbers.
const x = parseFloat(params.x);
const y = parseFloat(params.y);
const width = parseFloat(params.width);
const height = parseFloat(params.height);
const bodyWidth = parseFloat(params.bodyWidth);
const bodyHeight = parseFloat(params.bodyHeight);
/** @type {!Jimp.image} */
const fullScreenshot = (await Jimp.read(fullPageScreenshotData));
// Because WebDriver may screenshot at a different resolution than we
// saw in JS, convert the crop region coordinates to the screenshot scale,
// then crop, then resize. This order produces the most accurate cropped
// screenshot.
// Scaling by height breaks everything on Android, which has screenshots
// that are taller than expected based on the body size. So use width only.
const scale = fullScreenshot.bitmap.width / bodyWidth;
/** @type {!Jimp.image} */
const newScreenshot = fullScreenshot.clone()
.crop(
// Sub-pixel rendering in browsers makes this much trickier than you
// might expect. Offsets are not necessarily integers even before
// we scale them, but the image has been quantized into pixels at
// that scale. Experimentation with different rounding methods has
// led to the conclusion that rounding up is the only way to get
// consistent results.
Math.ceil(x * scale),
Math.ceil(y * scale),
Math.ceil(width * scale),
Math.ceil(height * scale))
.resize(width, height, Jimp.RESIZE_BICUBIC);
// Get the WebDriver spec (including browser name, platform, etc)
const spec = browser.spec;
// Compute the folder for the screenshots for this platform.
const baseFolder = `${__dirname}/test/test/assets/screenshots`;
let folder = `${baseFolder}/${spec.browserName}`;
if (spec.platform) {
folder += `-${spec.platform}`;
}
const oldScreenshotPath = `${folder}/${params.name}.png`;
const fullScreenshotPath = `${folder}/${params.name}.png-full`;
const newScreenshotPath = `${folder}/${params.name}.png-new`;
const diffScreenshotPath = `${folder}/${params.name}.png-diff`;
// Write the full screenshot to disk. This should be done early in case a
// later stage fails and we need to analyze what happened.
fs.mkdirSync(folder, {recursive: true});
fs.writeFileSync(
fullScreenshotPath, await fullScreenshot.getBufferAsync('image/png'));
// Write the cropped screenshot to disk next. This is used in review
// changes and to update the "official" screenshot when needed.
fs.writeFileSync(
newScreenshotPath, await newScreenshot.getBufferAsync('image/png'));
/** @type {!Jimp.image} */
let oldScreenshot;
if (!fs.existsSync(oldScreenshotPath)) {
// If the "official" screenshot doesn't exist yet, create a blank image
// in memory.
oldScreenshot = new Jimp(width, height);
} else {
oldScreenshot = await Jimp.read(oldScreenshotPath);
}
// Compare the new screenshot to the old one and produce a diff image.
// Initially, the image data will be raw pixels, 4 bytes per pixel.
// The threshold parameter affects the sensitivity of individual pixel
// comparisons. This diff is only used for visual review, not for
// automated similarity checks, so the threshold setting is not so critical
// as it used to be.
const threshold = 0.10;
const diff = Jimp.diff(oldScreenshot, newScreenshot, threshold);
// Write the diff to disk. This is used to review when there are changes.
const fullSizeDiff =
diff.image.clone().resize(width, height, Jimp.RESIZE_BICUBIC);
fs.writeFileSync(
diffScreenshotPath, await fullSizeDiff.getBufferAsync('image/png'));
// Compare with a structural similarity algorithm. This produces a
// similarity score that we will use to pass or fail the test.
const ssimResult = ssim(oldScreenshot.bitmap, newScreenshot.bitmap);
return ssimResult.mssim; // A score between 0 and 1.
}
/**
* This function is the middleware. It gets request and response objects and
* a next() callback which passes control off to the next middleware in the
* system. This is similar to how expressjs works.
*
* @param {karma.MiddleWare.Request} request
* @param {karma.MiddleWare.Response} response
* @param {function()} next
*/
async function screenshotMiddleware(request, response, next) {
const pathname = request._parsedUrl.pathname;
if (pathname == '/screenshot/isSupported') {
const params = getParams(request);
const browser = getBrowser(params.id);
const webDriverClient = getWebDriverClient(browser);
let isSupported = false;
if (webDriverClient) {
// Some platforms in our Selenium grid can't take screenshots. We don't
// have a good way to check for this in the platform capabilities
// reported by Selenium, so we have to take a screenshot to find out.
// The result is cached for the sake of performance.
if (webDriverClient.canTakeScreenshot === undefined) {
try {
await getScreenshot(browser.spec, webDriverClient);
webDriverClient.canTakeScreenshot = true;
} catch (error) {
webDriverClient.canTakeScreenshot = false;
}
}
isSupported = webDriverClient.canTakeScreenshot;
}
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(isSupported));
} else if (pathname == '/screenshot/diff') {
const params = getParams(request);
const browser = getBrowser(params.id);
if (!browser) {
response.writeHead(404);
response.end('No such browser!');
return;
}
// Check the URL parameters.
const requiredParams = [
'x', 'y', 'width', 'height', 'bodyWidth', 'bodyHeight', 'name',
];
for (const k of requiredParams) {
if (!params[k]) {
response.writeHead(400);
response.end(`Screenshot param ${k} is missing!`);
return;
}
}
// To avoid creating an open HTTP endpoint where anyone can write to any
// path on the filesystem, only accept alphanumeric names (plus
// underscore and dash). No colons, periods, forward slashes, or
// backslashes should ever be added to this regex, as any of those could
// be used on some platform to write outside of the screenshots folder.
if (!params.name.match(/[a-zA-Z0-9_-]+/)) {
response.writeHead(400);
response.end(`Screenshot name not valid: "${params.name}"`);
return;
}
try {
const pixelsChanged = await diffScreenshot(browser, params);
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(pixelsChanged));
} catch (error) {
console.error(error);
response.writeHead(500);
response.end('Screenshot error: ' + JSON.stringify(error));
}
} else {
// Requests for paths that we don't handle are passed on to the next
// middleware in the system.
next();
}
}
}
WebDriverScreenshotMiddlewareFactory.$inject = ['launcher'];