diff --git a/auditor.js b/auditor.js index b1613b601202..3782d1af54a2 100644 --- a/auditor.js +++ b/auditor.js @@ -15,12 +15,18 @@ */ 'use strict'; -module.exports = function(audits) { - return function audit(results) { - var flattenedAudits = results.reduce(function(prev, curr) { +class Auditor { + + _flattenArtifacts(artifacts) { + return artifacts.reduce(function(prev, curr) { return Object.assign(prev, curr); }, {}); + } + + audit(artifacts, audits) { + const flattenedArtifacts = this._flattenArtifacts(artifacts); + return Promise.all(audits.map(audit => audit.audit(flattenedArtifacts))); + } +} - return Promise.all(audits.map(v => v(flattenedAudits))); - }; -}; +module.exports = Auditor; diff --git a/audits/manifest/background-color.js b/audits/manifest/background-color.js new file mode 100644 index 000000000000..2801b66b25ae --- /dev/null +++ b/audits/manifest/background-color.js @@ -0,0 +1,47 @@ +/** + * Copyright 2016 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const manifestParser = require('../../helpers/manifest-parser'); + +class ManifestBackgroundColor { + + static get tags() { + return ['Manifest']; + } + + static get description() { + return 'Contains background_color'; + } + + static audit(inputs) { + let hasBackgroundColor = false; + const manifest = manifestParser(inputs.manifest).value; + + if (manifest) { + hasBackgroundColor = (!!manifest.background_color); + } + + return { + value: hasBackgroundColor, + tags: ManifestBackgroundColor.tags, + description: ManifestBackgroundColor.description + }; + } +} + +module.exports = ManifestBackgroundColor; diff --git a/audits/service-worker/audit.js b/audits/manifest/exists.js similarity index 65% rename from audits/service-worker/audit.js rename to audits/manifest/exists.js index 3aefff67b99e..8fdaf07c1f18 100644 --- a/audits/service-worker/audit.js +++ b/audits/manifest/exists.js @@ -13,15 +13,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + 'use strict'; -module.exports = function(data) { - // Test the Service Worker registrations for validity. - let registrations = data.serviceWorkerRegistrations; - let activatedRegistrations = registrations.versions.filter(reg => - reg.status === 'activated'); +class ManifestExists { + + static get tags() { + return ['Manifest']; + } + + static get description() { + return 'Exists'; + } + + static audit(inputs) { + return { + value: inputs.manifest.length > 0, + tags: ManifestExists.tags, + description: ManifestExists.description + }; + } +} - return { - 'service-worker': activatedRegistrations.length > 0 - }; -}; +module.exports = ManifestExists; diff --git a/audits/manifest/icons-192.js b/audits/manifest/icons-192.js new file mode 100644 index 000000000000..e653a0d83ccf --- /dev/null +++ b/audits/manifest/icons-192.js @@ -0,0 +1,48 @@ +/** + * Copyright 2016 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const manifestParser = require('../../helpers/manifest-parser'); + +class ManifestIcons192 { + + static get tags() { + return ['Manifest']; + } + + static get description() { + return 'Contains 192px icons'; + } + + static audit(inputs) { + let hasIcons = false; + const manifest = manifestParser(inputs.manifest).value; + + if (manifest && manifest.icons) { + const icons192 = manifest.icons.raw.find(i => i.sizes === '192x192'); + hasIcons = (!!icons192); + } + + return { + value: hasIcons, + tags: ManifestIcons192.tags, + description: ManifestIcons192.description + }; + } +} + +module.exports = ManifestIcons192; diff --git a/audits/manifest/icons.js b/audits/manifest/icons.js new file mode 100644 index 000000000000..252565ece1b4 --- /dev/null +++ b/audits/manifest/icons.js @@ -0,0 +1,47 @@ +/** + * Copyright 2016 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const manifestParser = require('../../helpers/manifest-parser'); + +class ManifestIcons { + + static get tags() { + return ['Manifest']; + } + + static get description() { + return 'Contains icons'; + } + + static audit(inputs) { + let hasIcons = false; + const manifest = manifestParser(inputs.manifest).value; + + if (manifest) { + hasIcons = (!!manifest.icons); + } + + return { + value: hasIcons, + tags: ManifestIcons.tags, + description: ManifestIcons.description + }; + } +} + +module.exports = ManifestIcons; diff --git a/audits/minify-html/audit.js b/audits/manifest/name.js similarity index 54% rename from audits/minify-html/audit.js rename to audits/manifest/name.js index a3f23a6d4003..03d8bd97f181 100644 --- a/audits/minify-html/audit.js +++ b/audits/manifest/name.js @@ -13,21 +13,35 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + 'use strict'; -module.exports = function(data) { - // See how compressed the HTML _could_ be if whitespace was removed. - // This could be a lot more aggressive. - let htmlNoWhiteSpaces = data.html - .replace(/\n/igm, '') - .replace(/\t/igm, '') - .replace(/\s+/igm, ' '); - - let htmlLen = Math.max(1, data.html.length); - let htmlNoWhiteSpacesLen = htmlNoWhiteSpaces.length; - let ratio = Math.min(1, (htmlNoWhiteSpacesLen / htmlLen)); - - return { - 'minify-html': ratio - }; -}; +const manifestParser = require('../../helpers/manifest-parser'); + +class ManifestName { + + static get tags() { + return ['Manifest']; + } + + static get description() { + return 'Contains name'; + } + + static audit(inputs) { + let hasName = false; + const manifest = manifestParser(inputs.manifest).value; + + if (manifest) { + hasName = (!!manifest.name); + } + + return { + value: hasName, + tags: ManifestName.tags, + description: ManifestName.description + }; + } +} + +module.exports = ManifestName; diff --git a/audits/manifest/short-name.js b/audits/manifest/short-name.js new file mode 100644 index 000000000000..c0af3281fc37 --- /dev/null +++ b/audits/manifest/short-name.js @@ -0,0 +1,47 @@ +/** + * Copyright 2016 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const manifestParser = require('../../helpers/manifest-parser'); + +class ManifestShortName { + + static get tags() { + return ['Manifest']; + } + + static get description() { + return 'Contains short_name'; + } + + static audit(inputs) { + let hasShortName = false; + const manifest = manifestParser(inputs.manifest).value; + + if (manifest) { + hasShortName = (!!manifest.short_name); + } + + return { + value: hasShortName, + tags: ManifestShortName.tags, + description: ManifestShortName.description + }; + } +} + +module.exports = ManifestShortName; diff --git a/audits/manifest/start-url.js b/audits/manifest/start-url.js new file mode 100644 index 000000000000..3341324092ef --- /dev/null +++ b/audits/manifest/start-url.js @@ -0,0 +1,47 @@ +/** + * Copyright 2016 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const manifestParser = require('../../helpers/manifest-parser'); + +class ManifestStartUrl { + + static get tags() { + return ['Manifest']; + } + + static get description() { + return 'Contains start_url'; + } + + static audit(inputs) { + let hasStartUrl = false; + const manifest = manifestParser(inputs.manifest).value; + + if (manifest) { + hasStartUrl = (!!manifest.start_url); + } + + return { + value: hasStartUrl, + tags: ManifestStartUrl.tags, + description: ManifestStartUrl.description + }; + } +} + +module.exports = ManifestStartUrl; diff --git a/audits/manifest/theme-color.js b/audits/manifest/theme-color.js new file mode 100644 index 000000000000..73972e7199f6 --- /dev/null +++ b/audits/manifest/theme-color.js @@ -0,0 +1,47 @@ +/** + * Copyright 2016 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const manifestParser = require('../../helpers/manifest-parser'); + +class ManifestThemeColor { + + static get tags() { + return ['Manifest']; + } + + static get description() { + return 'Contains theme_color'; + } + + static audit(inputs) { + let hasThemeColor = false; + const manifest = manifestParser(inputs.manifest).value; + + if (manifest) { + hasThemeColor = (!!manifest.theme_color); + } + + return { + value: hasThemeColor, + tags: ManifestThemeColor.tags, + description: ManifestThemeColor.description + }; + } +} + +module.exports = ManifestThemeColor; diff --git a/audits/minify-html/package.json b/audits/minify-html/package.json deleted file mode 100644 index 74bf030f7ffe..000000000000 --- a/audits/minify-html/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "minify-html", - "version": "1.0.0", - "description": "Tests the HTML to see if it is minified.", - "main": "index.js", - "keywords": [], - "author": "", - "license": "Apache-2.0", - "inputs": [ - "html" - ] -} diff --git a/audits/mobile-friendly/viewport.js b/audits/mobile-friendly/viewport.js new file mode 100644 index 000000000000..88375b32436e --- /dev/null +++ b/audits/mobile-friendly/viewport.js @@ -0,0 +1,39 @@ +/** + * Copyright 2016 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +'use strict'; + +class Viewport { + + static get tags() { + return ['Mobile Friendly']; + } + + static get description() { + return 'Site has a viewport meta tag'; + } + + static audit(inputs) { + const viewportExpression = / { + return reg.status === 'activated' && + reg.scriptURL.startsWith(inputs.url); + }); + + return { + value: (activatedRegistrations.length > 0), + tags: ServiceWorker.tags, + description: ServiceWorker.description + }; + } +} + +module.exports = ServiceWorker; diff --git a/audits/service-worker/gather.js b/audits/security/is-on-https.js similarity index 68% rename from audits/service-worker/gather.js rename to audits/security/is-on-https.js index 5041ffbc4c8a..ac031615df89 100644 --- a/audits/service-worker/gather.js +++ b/audits/security/is-on-https.js @@ -15,12 +15,23 @@ */ 'use strict'; -var ServiceWorkerGatherer = { - run: function(driver, url) { - return driver.gotoURL(url, driver.WAIT_FOR_LOAD) - .then(driver.getServiceWorkerRegistrations) - .then(serviceWorkerRegistrations => ({serviceWorkerRegistrations})); +class HTTPS { + + static get tags() { + return ['Security']; + } + + static get description() { + return 'Site is on HTTPS'; + } + + static audit(inputs) { + return { + value: inputs.https, + tags: HTTPS.tags, + description: HTTPS.description + }; } -}; +} -module.exports = ServiceWorkerGatherer; +module.exports = HTTPS; diff --git a/audits/service-worker/package.json b/audits/service-worker/package.json deleted file mode 100644 index 115d0b44ff4f..000000000000 --- a/audits/service-worker/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "service-worker", - "version": "1.0.0", - "description": "Checks if there's a Service Worker.", - "main": "index.js", - "keywords": [], - "author": "", - "license": "Apache-2.0", - "inputs": [ - "chrome", - "url" - ] -} diff --git a/audits/time-in-javascript/package.json b/audits/time-in-javascript/package.json deleted file mode 100644 index 538a970bb75e..000000000000 --- a/audits/time-in-javascript/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "time-in-javascript", - "version": "1.0.0", - "description": "Figures out how long the page spends in JS during load.", - "main": "index.js", - "keywords": [], - "author": "", - "license": "Apache-2.0", - "inputs": [ - "chrome", - "url" - ] -} diff --git a/audits/viewport-meta-tag/audit.js b/audits/viewport-meta-tag/audit.js deleted file mode 100644 index 716a09df3b3e..000000000000 --- a/audits/viewport-meta-tag/audit.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @license - * Copyright 2016 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -'use strict'; - -module.exports = function(data) { - let viewportElement = data.viewportElement; - let hasValidViewport = viewportElement.type === 'object' && - viewportElement.subtype === 'node' && - viewportElement.props.content.includes('width='); - - return { - 'viewport-meta-tag': hasValidViewport - }; -}; - diff --git a/audits/viewport-meta-tag/gather.js b/audits/viewport-meta-tag/gather.js deleted file mode 100644 index 7aac3ce946a9..000000000000 --- a/audits/viewport-meta-tag/gather.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @license - * Copyright 2016 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -'use strict'; - -/* global document */ - -// must be defined as a standalone function expression to be stringified successfully. -function findMetaViewport() { - return document.head.querySelector('meta[name="viewport"]'); -} - -var ViewportHasMetaContent = { - run: function(driver, url) { - return driver.evaluateFunction(url, findMetaViewport) - .then(viewportElement => ({viewportElement})); - } -}; - -module.exports = ViewportHasMetaContent; diff --git a/audits/viewport-meta-tag/package.json b/audits/viewport-meta-tag/package.json deleted file mode 100644 index 771c867c061d..000000000000 --- a/audits/viewport-meta-tag/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "viewport-meta-tag", - "version": "1.0.0", - "description": "Tests the existence and validity of the viewport's meta tag", - "main": "index.js", - "keywords": [], - "author": "", - "license": "Apache-2.0", - "inputs": [ - "chrome" - ] -} diff --git a/extension/app/manifest.json b/extension/app/manifest.json index 7437e4853d73..4b427b728146 100644 --- a/extension/app/manifest.json +++ b/extension/app/manifest.json @@ -1,8 +1,8 @@ { - "name": "__MSG_appName__", - "version": "0.0.3", + "name": "Lighthouse", + "version": "0.0.5", "manifest_version": 2, - "description": "__MSG_appDescription__", + "description": "Helping you avoid Progressive Web App woes.", "icons": { "16": "images/icon-16.png", "128": "images/icon-128.png" @@ -15,13 +15,14 @@ ] }, "permissions": [ - "activeTab" + "activeTab", + "debugger" ], "browser_action": { "default_icon": { "38": "images/icon-38.png" }, - "default_title": "lighthouse", + "default_title": "Lighthouse", "default_popup": "popup.html" } } diff --git a/extension/app/scripts.babel/manifest-parser.js b/extension/app/scripts.babel/manifest-parser.js deleted file mode 100644 index 1288e993ead0..000000000000 --- a/extension/app/scripts.babel/manifest-parser.js +++ /dev/null @@ -1,322 +0,0 @@ -/** - * Copyright 2016 Google Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -var ManifestParser = function() { - 'use strict'; - - var _jsonInput = {}; - var _manifest = {}; - var _logs = []; - var _tips = []; - var _success = true; - - var ALLOWED_DISPLAY_VALUES = ['fullscreen', - 'standalone', - 'minimal-ui', - 'browser']; - - var ALLOWED_ORIENTATION_VALUES = ['any', - 'natural', - 'landscape', - 'portrait', - 'portrait-primary', - 'portrait-secondary', - 'landscape-primary', - 'landscape-secondary']; - - function _parseString(args) { - var object = args.object; - var property = args.property; - if (!(property in object)) { - return undefined; - } - - if (typeof object[property] !== 'string') { - _logs.push('ERROR: \'' + property + - '\' expected to be a string but is not.'); - return undefined; - } - - if (args.trim) { - return object[property].trim(); - } - return object[property]; - } - - function _parseBoolean(args) { - var object = args.object; - var property = args.property; - var defaultValue = args.defaultValue; - if (!(property in object)) { - return defaultValue; - } - - if (typeof object[property] !== 'boolean') { - _logs.push('ERROR: \'' + property + - '\' expected to be a boolean but is not.'); - return defaultValue; - } - - return object[property]; - } - - function _parseURL(args) { - var object = args.object; - var property = args.property; - - var str = _parseString({object: object, property: property, trim: false}); - if (str === undefined) { - return undefined; - } - - return object[property]; - } - - function _parseColor(args) { - var object = args.object; - var property = args.property; - if (!(property in object)) { - return undefined; - } - - if (typeof object[property] !== 'string') { - _logs.push('ERROR: \'' + property + - '\' expected to be a string but is not.'); - return undefined; - } - - // If style.color changes when set to the given color, it is valid. Testing - // against 'white' and 'black' in case of the given color is one of them. - var dummy = document.createElement('div'); - dummy.style.color = 'white'; - dummy.style.color = object[property]; - if (dummy.style.color !== 'white') { - return object[property]; - } - dummy.style.color = 'black'; - dummy.style.color = object[property]; - if (dummy.style.color !== 'black') { - return object[property]; - } - return undefined; - } - - function _parseName() { - return _parseString({object: _jsonInput, property: 'name', trim: true}); - } - - function _parseShortName() { - return _parseString({object: _jsonInput, - property: 'short_name', - trim: true}); - } - - function _parseStartUrl() { - return _parseURL({object: _jsonInput, property: 'start_url'}); - } - - function _parseDisplay() { - var display = _parseString({object: _jsonInput, - property: 'display', - trim: true}); - if (display === undefined) { - return display; - } - - if (ALLOWED_DISPLAY_VALUES.indexOf(display.toLowerCase()) === -1) { - _logs.push('ERROR: \'display\' has an invalid value, will be ignored.'); - return undefined; - } - - return display; - } - - function _parseOrientation() { - var orientation = _parseString({object: _jsonInput, - property: 'orientation', - trim: true}); - if (orientation === undefined) { - return orientation; - } - - if (ALLOWED_ORIENTATION_VALUES.indexOf(orientation.toLowerCase()) === -1) { - _logs.push('ERROR: \'orientation\' has an invalid value' + - ', will be ignored.'); - return undefined; - } - - return orientation; - } - - function _parseIcons() { - var property = 'icons'; - var icons = []; - - if (!(property in _jsonInput)) { - return icons; - } - - if (!Array.isArray(_jsonInput[property])) { - _logs.push('ERROR: \'' + property + - '\' expected to be an array but is not.'); - return icons; - } - - _jsonInput[property].forEach(function(object) { - var icon = {}; - if (!('src' in object)) { - return; - } - icon.src = _parseURL({object: object, property: 'src'}); - icon.type = _parseString({object: object, - property: 'type', - trim: true}); - - icon.density = parseFloat(object.density); - if (isNaN(icon.density) || !isFinite(icon.density) || icon.density <= 0) { - icon.density = 1.0; - } - - if ('sizes' in object) { - var set = new Set(); - var link = document.createElement('link'); - link.sizes = object.sizes; - - for (var i = 0; i < link.sizes.length; ++i) { - set.add(link.sizes.item(i).toLowerCase()); - } - - if (set.size !== 0) { - icon.sizes = set; - } - } - - icons.push(icon); - }); - - return icons; - } - - function _parseRelatedApplications() { - var property = 'related_applications'; - var applications = []; - - if (!(property in _jsonInput)) { - return applications; - } - - if (!Array.isArray(_jsonInput[property])) { - _logs.push('ERROR: \'' + property + - '\' expected to be an array but is not.'); - return applications; - } - - _jsonInput[property].forEach(function(object) { - var application = {}; - application.platform = _parseString({object: object, - property: 'platform', - trim: true}); - application.id = _parseString({object: object, - property: 'id', - trim: true}); - application.url = _parseURL({object: object, property: 'url'}); - applications.push(application); - }); - - return applications; - } - - function _parsePreferRelatedApplications() { - return _parseBoolean({object: _jsonInput, - property: 'prefer_related_applications', - defaultValue: false}); - } - - function _parseThemeColor() { - return _parseColor({object: _jsonInput, property: 'theme_color'}); - } - - function _parseBackgroundColor() { - return _parseColor({object: _jsonInput, property: 'background_color'}); - } - - function _parse(string) { - _logs = []; - _tips = []; - _success = true; - - try { - _jsonInput = JSON.parse(string); - } catch (e) { - _logs.push('File isn\'t valid JSON: ' + e); - _tips.push('Your JSON failed to parse, these are the main reasons why ' + - 'JSON parsing usually fails:\n' + - '- Double quotes should be used around property names and for ' + - 'strings. Single quotes are not valid.\n' + - '- JSON specification disallow trailing comma after the last ' + - 'property even if some implementations allow it.'); - - _success = false; - return; - } - - _logs.push('JSON parsed successfully.'); - - _manifest.name = _parseName(); - /*eslint-disable*/ - _manifest.short_name = _parseShortName(); - _manifest.start_url = _parseStartUrl(); - _manifest.display = _parseDisplay(); - _manifest.orientation = _parseOrientation(); - _manifest.icons = _parseIcons(); - _manifest.related_applications = _parseRelatedApplications(); - _manifest.prefer_related_applications = _parsePreferRelatedApplications(); - _manifest.theme_color = _parseThemeColor(); - _manifest.background_color = _parseBackgroundColor(); - - _logs.push('Parsed `name` property is: ' + - _manifest.name); - _logs.push('Parsed `short_name` property is: ' + - _manifest.short_name); - _logs.push('Parsed `start_url` property is: ' + - _manifest.start_url); - _logs.push('Parsed `display` property is: ' + - _manifest.display); - _logs.push('Parsed `orientation` property is: ' + - _manifest.orientation); - _logs.push('Parsed `icons` property is: ' + - JSON.stringify(_manifest.icons, null, 4)); - _logs.push('Parsed `related_applications` property is: ' + - JSON.stringify(_manifest.related_applications, null, 4)); - _logs.push('Parsed `prefer_related_applications` property is: ' + - JSON.stringify(_manifest.prefer_related_applications, null, 4)); - _logs.push('Parsed `theme_color` property is: ' + - _manifest.theme_color); - _logs.push('Parsed `background_color` property is: ' + - _manifest.background_color); - /*eslint-enable*/ - } - - return { - parse: _parse, - manifest: _ => _manifest, - logs: _ => _logs, - tips: _ => _tips, - success: _ => _success - }; -}; - -export {ManifestParser}; diff --git a/extension/app/scripts.babel/pwa-check.js b/extension/app/scripts.babel/pwa-check.js index 0acef70aa85d..677284ead4f8 100644 --- a/extension/app/scripts.babel/pwa-check.js +++ b/extension/app/scripts.babel/pwa-check.js @@ -14,197 +14,35 @@ * limitations under the License. */ -import {ManifestParser} from './manifest-parser.js'; - -var hasManifest = _ => { - return !!document.querySelector('link[rel=manifest]'); -}; - -var parseManifest = function() { - var link = document.querySelector('link[rel=manifest]'); - - if (!link) { - return {}; - } - - var request = new XMLHttpRequest(); - request.open('GET', link.href, false); // `false` makes the request synchronous - request.send(null); - - if (request.status === 200) { - /* eslint-disable new-cap*/ - let parserInstance = (window.__lighthouse.ManifestParser)(); - /* eslint-enable new-cap*/ - parserInstance.parse(request.responseText); - return parserInstance.manifest(); - } - - throw new Error('Unable to fetch manifest at ' + link); -}; - -var hasManifestThemeColor = _ => { - let manifest = __lighthouse.parseManifest(); - - return !!manifest.theme_color; -}; - -var hasManifestBackgroundColor = _ => { - let manifest = __lighthouse.parseManifest(); - - return !!manifest.background_color; -}; - -var hasManifestIcons = _ => { - let manifest = __lighthouse.parseManifest(); - - return !!manifest.icons; -}; - -var hasManifestIcons192 = _ => { - let manifest = __lighthouse.parseManifest(); - - if (!manifest.icons) { - return false; - } - - return !!manifest.icons.find(function(i) { - return i.sizes.has('192x192'); - }); -}; - -var hasManifestShortName = _ => { - let manifest = __lighthouse.parseManifest(); - - return !!manifest.short_name; -}; - -var hasManifestName = _ => { - let manifest = __lighthouse.parseManifest(); - - return !!manifest.name; -}; - -var hasManifestStartUrl = _ => { - let manifest = __lighthouse.parseManifest(); - - return !!manifest.start_url; -}; - -// quoth kinlan >> re: canonical, it is always good to have them, but not needed. This test is more of a warning than anything else to let you know you should consider it. -var hasCanonicalUrl = _ => { - var link = document.querySelector('link[rel=canonical]'); - - return !!link; -}; - -var hasServiceWorkerRegistration = _ => { - return new Promise((resolve, reject) => { - navigator.serviceWorker.getRegistration().then(r => { - // Fail immediately for non-existent registrations. - if (typeof r === 'undefined') { - return resolve(false); - } - - // If there's an active SW call this done. - if (r.active) { - return resolve(!!r.active); - } - - // Give any installing SW chance to install. - r.installing.onstatechange = function() { - resolve(this.state === 'installed'); - }; - }).catch(_ => { - resolve(false); - }); - }); -}; - -var isOnHTTPS = _ => location.protocol === 'https:'; - -function injectIntoTab(chrome, fnPair) { - var singleLineFn = fnPair[1].toString(); - - return new Promise((res, reject) => { - chrome.tabs.executeScript(null, { - code: `window.__lighthouse = window.__lighthouse || {}; - window.__lighthouse['${fnPair[0]}'] = ${singleLineFn}` - }, ret => { - if (chrome.runtime.lastError) { - return reject(chrome.runtime.lastError); - } - - res(ret); - }); - }); -} - -function convertAuditsToPromiseStrings(audits) { - const auditNames = Object.keys(audits); - - return auditNames.reduce((prevAuditGroup, auditName, auditGroupIndex) => { - // Then within each group, reduce each audit down to a Promise. - return prevAuditGroup + (auditGroupIndex > 0 ? ',' : '') + - audits[auditName].reduce((prevAudit, audit, auditIndex) => { - return prevAudit + (auditIndex > 0 ? ',' : '') + - convertAuditToPromiseString(auditName, audit); - }, ''); - }, ''); -} - -function convertAuditToPromiseString(auditName, audit) { - return `Promise.all([ - Promise.resolve("${auditName}"), - Promise.resolve("${audit[1]}"), - (${audit[0].toString()})() - ])`; -} - -function runAudits(chrome, audits) { - // Reduce each group of audits. - const fnString = convertAuditsToPromiseStrings(audits); - - // Ask the tab to run the promises, and beacon back the results. - chrome.tabs.executeScript(null, { - code: `Promise.all([${fnString}]).then(__lighthouse.postAuditResults)` - }, function() { - if (chrome.runtime.lastError) { - throw chrome.runtime.lastError; - } - }); -} - -function postAuditResults(results) { - chrome.runtime.sendMessage({onAuditsComplete: results}); -} - -var functionsToInject = [ - ['ManifestParser', ManifestParser], - ['parseManifest', parseManifest], - ['postAuditResults', postAuditResults] +'use strict'; + +const ExtensionProtocol = require('../../../helpers/extension/driver.js'); +const Auditor = require('../../../auditor'); +const Gatherer = require('../../../gatherer'); + +const driver = new ExtensionProtocol(); +const gatherer = new Gatherer(); +const auditor = new Auditor(); +const gatherers = [ + require('../../../gatherers/url'), + require('../../../gatherers/https'), + require('../../../gatherers/service-worker'), + require('../../../gatherers/html'), + require('../../../gatherers/manifest') +]; +const audits = [ + require('../../../audits/security/is-on-https'), + require('../../../audits/offline/service-worker'), + require('../../../audits/mobile-friendly/viewport'), + require('../../../audits/manifest/exists'), + require('../../../audits/manifest/background-color'), + require('../../../audits/manifest/theme-color'), + require('../../../audits/manifest/icons'), + require('../../../audits/manifest/icons-192'), + require('../../../audits/manifest/name'), + require('../../../audits/manifest/short-name'), + require('../../../audits/manifest/start-url') ]; - -var audits = { - Security: [ - [isOnHTTPS, 'Served over HTTPS'] - ], - Offline: [ - [hasServiceWorkerRegistration, 'Has a service worker registration'] - ], - Manifest: [ - [hasManifest, 'Exists'], - [hasManifestThemeColor, 'Contains theme_color'], - [hasManifestBackgroundColor, 'Contains background_color'], - [hasManifestStartUrl, 'Contains start_url'], - [hasManifestShortName, 'Contains short_name'], - [hasManifestName, 'Contains name'], - [hasManifestIcons, 'Contains icons defined'], - [hasManifestIcons192, 'Contains 192px icon'], - ], - Miscellaneous: [ - [hasCanonicalUrl, 'Site has a Canonical URL'] - ] -}; function createResultsHTML(results) { const resultsGroup = {}; @@ -212,14 +50,14 @@ function createResultsHTML(results) { // Go through each group and restructure the results accordingly. results.forEach(result => { - groupName = result[0]; + groupName = result.tags; if (!resultsGroup[groupName]) { resultsGroup[groupName] = []; } resultsGroup[groupName].push({ - title: result[1], - value: result[2] + title: result.description, + value: result.value }); }); @@ -251,22 +89,11 @@ function createResultsHTML(results) { return resultsHTML; } -export function runPwaAudits(chrome) { - return new Promise((resolve, reject) => { - chrome.runtime.onMessage.addListener(message => { - if (!message.onAuditsComplete) { - return; - } - resolve(createResultsHTML(message.onAuditsComplete)); +export function runPwaAudits() { + return gatherer + .gather(gatherers, {driver}) + .then(artifacts => auditor.audit(artifacts, audits)) + .then(results => { + return createResultsHTML(results); }); - - Promise.all(functionsToInject.map(fnPair => injectIntoTab(chrome, fnPair))) - .then(_ => runAudits(chrome, audits), - err => { - throw err; - }) - .catch(err => { - reject(err); - }); - }); } diff --git a/extension/gulpfile.babel.js b/extension/gulpfile.babel.js index c8c4d7e9415a..16c30dc86831 100644 --- a/extension/gulpfile.babel.js +++ b/extension/gulpfile.babel.js @@ -2,6 +2,7 @@ import gulp from 'gulp'; import gulpLoadPlugins from 'gulp-load-plugins'; import del from 'del'; +import browserify from 'gulp-browserify'; import runSequence from 'run-sequence'; import {stream as wiredep} from 'wiredep'; @@ -96,6 +97,7 @@ gulp.task('babel', () => { .pipe($.babel({ presets: ['es2015'] })) + .pipe(browserify()) .pipe(gulp.dest('app/scripts')) .pipe(gulp.dest('dist/scripts')); }); diff --git a/extension/package.json b/extension/package.json index 52a601c6374f..0c130caa48ac 100644 --- a/extension/package.json +++ b/extension/package.json @@ -11,9 +11,12 @@ "devDependencies": { "babel-core": "^6.5.2", "babel-preset-es2015": "^6.5.0", + "babelify": "^7.2.0", + "browserify": "^13.0.0", "del": "^2.2.0", "gulp": "^3.9.1", "gulp-babel": "^6.1.2", + "gulp-browserify": "^0.5.1", "gulp-cache": "^0.4.2", "gulp-chrome-manifest": "0.0.13", "gulp-debug": "^2.1.2", diff --git a/gatherer.js b/gatherer.js index 6c7ce8a142e2..01e55f9b2cc0 100644 --- a/gatherer.js +++ b/gatherer.js @@ -15,20 +15,22 @@ */ 'use strict'; -/** - * @param {Array<{run: function}>} gatherers - * @returns {Promise} - */ -module.exports = function(gatherers, url) { - return function gather(driver) { - let artifacts = []; +class Gatherer { + + gather(gatherers, options) { + const driver = options.driver; + const artifacts = []; // Execute gatherers sequentially and return results array when complete. - return gatherers.reduce((prev, curr) => { - return prev - .then(_ => curr.run(driver, url)) + return gatherers.reduce((chain, gatherer) => { + return chain + .then(_ => gatherer.gather(options)) .then(artifact => artifacts.push(artifact)); - }, Promise.resolve()) + }, driver.connect()) + .then(_ => driver.disconnect()) .then(_ => artifacts); - }; -}; + } + +} + +module.exports = Gatherer; diff --git a/audits/minify-html/gather.js b/gatherers/gather.js similarity index 74% rename from audits/minify-html/gather.js rename to gatherers/gather.js index c8af5f711ee1..31662c44bce2 100644 --- a/audits/minify-html/gather.js +++ b/gatherers/gather.js @@ -15,12 +15,10 @@ */ 'use strict'; -var MinifyHtmlGatherer = { - run: function(driver, url) { - return driver.gotoURL(url, driver.WAIT_FOR_LOAD) - .then(driver.getPageHTML) - .then(html => ({html})); +class Gather { + static gather() { + throw new Error('Must be overridden'); } -}; +} -module.exports = MinifyHtmlGatherer; +module.exports = Gather; diff --git a/gatherers/html.js b/gatherers/html.js new file mode 100644 index 000000000000..9bbcce644176 --- /dev/null +++ b/gatherers/html.js @@ -0,0 +1,34 @@ +/** + * Copyright 2016 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +'use strict'; + +const Gather = require('./gather'); + +class HTML extends Gather { + + static gather(options) { + const driver = options.driver; + + return driver.sendCommand('DOM.getDocument') + .then(result => result.root.nodeId) + .then(nodeId => driver.sendCommand('DOM.getOuterHTML', { + nodeId: nodeId + })) + .then(nodeHTML => ({html: nodeHTML.outerHTML})); + } +} + +module.exports = HTML; diff --git a/gatherers/https.js b/gatherers/https.js new file mode 100644 index 000000000000..b079b2fccac5 --- /dev/null +++ b/gatherers/https.js @@ -0,0 +1,36 @@ +/** + * Copyright 2016 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +'use strict'; + +const Gather = require('./gather'); + +class HTTPS extends Gather { + + static gather(options) { + const driver = options.driver; + + return driver + .sendCommand('Runtime.evaluate', { + expression: 'window.location.protocol' + }).then(options => { + return { + https: options.result.value === 'https:' + }; + }); + } +} + +module.exports = HTTPS; diff --git a/gatherers/trace.js b/gatherers/load-trace.js similarity index 73% rename from gatherers/trace.js rename to gatherers/load-trace.js index e2426770958c..118f649029c0 100644 --- a/gatherers/trace.js +++ b/gatherers/load-trace.js @@ -17,15 +17,18 @@ 'use strict'; const PAUSE_AFTER_LOAD = 3000; +const Gather = require('./gather'); -var TraceGatherer = { - run: function(driver, url) { - let artifacts = {}; +class LoadTrace extends Gather { - return driver.disableCaching() - // Begin trace and network recording. - .then(driver.beginTrace) - .then(driver.beginNetworkCollect) + static gather(options) { + const url = options.url; + const driver = options.driver; + const artifacts = {}; + + // Begin trace and network recording. + return driver.beginTrace() + .then(_ => driver.beginNetworkCollect()) // Go to the URL. .then(_ => driver.gotoURL(url, driver.WAIT_FOR_LOAD)) @@ -36,17 +39,17 @@ var TraceGatherer = { })) // Stop recording and save the results. - .then(driver.endNetworkCollect) + .then(_ => driver.endNetworkCollect()) .then(networkRecords => { artifacts.networkRecords = networkRecords; }) - .then(driver.endTrace) + .then(_ => driver.endTrace()) .then(traceContents => { artifacts.traceContents = traceContents; }) .then(_ => artifacts); } -}; +} -module.exports = TraceGatherer; +module.exports = LoadTrace; diff --git a/gatherers/manifest.js b/gatherers/manifest.js new file mode 100644 index 000000000000..1d024b3d2e71 --- /dev/null +++ b/gatherers/manifest.js @@ -0,0 +1,78 @@ +/** + * Copyright 2016 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +'use strict'; + +/* global window, fetch */ + +const Gather = require('./gather'); + +class Manifest extends Gather { + + static _loadFromURL(options, manifestURL) { + if (typeof window !== 'undefined') { + const finalURL = (new window.URL(options.driver.url).origin) + manifestURL; + return fetch(finalURL).then(response => response.text()); + } + + return new Promise((resolve, reject) => { + const url = require('url'); + const request = require('request'); + const finalURL = url.resolve(options.url, manifestURL); + + request(finalURL, function(err, response, body) { + if (err) { + return resolve(''); + } + + resolve(body); + }); + }); + } + + static gather(options) { + const driver = options.driver; + + return driver.sendCommand('DOM.getDocument') + .then(result => result.root.nodeId) + .then(nodeId => driver.sendCommand('DOM.querySelector', { + nodeId: nodeId, + selector: 'link[rel="manifest"]' + })) + .then(manifestNode => manifestNode.nodeId) + .then(manifestNodeId => { + if (manifestNodeId === 0) { + return ''; + } + + return driver.sendCommand('DOM.getAttributes', { + nodeId: manifestNodeId + }) + .then(manifestAttributes => manifestAttributes.attributes) + .then(attributes => { + const hrefIndex = attributes.indexOf('href'); + if (hrefIndex === -1) { + return ''; + } + + return attributes[hrefIndex + 1]; + }) + .then(manifestURL => Manifest._loadFromURL(options, manifestURL)); + }) + .then(manifest => ({manifest})); + } +} + +module.exports = Manifest; diff --git a/gatherers/service-worker.js b/gatherers/service-worker.js new file mode 100644 index 000000000000..5d1e86203a75 --- /dev/null +++ b/gatherers/service-worker.js @@ -0,0 +1,38 @@ +/** + * Copyright 2016 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +'use strict'; + +const Gather = require('./gather'); + +class ServiceWorker extends Gather { + + static gather(options) { + const driver = options.driver; + + return new Promise((resolve, reject) => { + // Register for the event. + driver.on('ServiceWorker.workerVersionUpdated', data => { + resolve({ + serviceWorkers: data + }); + }); + + driver.sendCommand('ServiceWorker.enable'); + }); + } +} + +module.exports = ServiceWorker; diff --git a/audits/time-in-javascript/audit.js b/gatherers/url.js similarity index 73% rename from audits/time-in-javascript/audit.js rename to gatherers/url.js index 82da6aa8a512..144acc7851ee 100644 --- a/audits/time-in-javascript/audit.js +++ b/gatherers/url.js @@ -15,12 +15,12 @@ */ 'use strict'; -var traceProcessor = require('../../lib/processor'); +const Gather = require('./gather'); -module.exports = function(data) { - let results = traceProcessor.analyzeTrace(data.traceContents); +class URL extends Gather { + static gather(options) { + return Promise.resolve({url: options.url}); + } +} - return { - 'time-in-javascript': results[0].extendedInfo.javaScript - }; -}; +module.exports = URL; diff --git a/helpers/browser/driver.js b/helpers/browser/driver.js index e88754a758ee..6a30a7e97470 100644 --- a/helpers/browser/driver.js +++ b/helpers/browser/driver.js @@ -17,16 +17,19 @@ 'use strict'; const chromeRemoteInterface = require('chrome-remote-interface'); - -var NetworkRecorder = require('../network-recorder'); - -const EXECUTION_CONTEXT_TIMEOUT = 4000; +const NetworkRecorder = require('../network-recorder'); class ChromeProtocol { - constructor(opts) { - opts = opts || {}; - this.categories = [ + get WAIT_FOR_LOAD() { + return true; + } + + constructor() { + this._url = null; + this._chrome = null; + this._traceEvents = []; + this._traceCategories = [ '-*', // exclude default 'toplevel', 'blink.console', @@ -39,183 +42,105 @@ class ChromeProtocol { 'disabled-by-default-v8.cpu_profile' ]; - this._traceEvents = []; - this._currentURL = null; - this._instance = null; - this._networkRecords = []; - this._networkRecorder = null; - - this.getPageHTML = this.getPageHTML.bind(this); - this.evaluateFunction = this.evaluateFunction.bind(this); - this.evaluateScript = this.evaluateScript.bind(this); - this.getServiceWorkerRegistrations = - this.getServiceWorkerRegistrations.bind(this); - this.beginTrace = this.beginTrace.bind(this); - this.endTrace = this.endTrace.bind(this); - this.beginNetworkCollect = this.beginNetworkCollect.bind(this); - this.endNetworkCollect = this.endNetworkCollect.bind(this); + this.timeoutID = null; } - get WAIT_FOR_LOAD() { - return true; + get url() { + return this._url; } - getInstance() { - if (!this._instance) { - this._instance = new Promise((resolve, reject) => { - // @see: github.com/cyrus-and/chrome-remote-interface#moduleoptions-callback - const OPTIONS = {}; - chromeRemoteInterface(OPTIONS, - resolve - ).on('error', e => reject(e)); - }); - } + set url(_url) { + this._url = _url; + } - return this._instance; + connect() { + return new Promise((resolve, reject) => { + if (this._chrome) { + return resolve(this._chrome); + } + + chromeRemoteInterface({}, chrome => { + this._chrome = chrome; + resolve(chrome); + }).on('error', e => reject(e)); + }); } - resetFailureTimeout(reject) { + disconnect() { + if (this._chrome === null) { + return; + } + if (this.timeoutID) { clearTimeout(this.timeoutID); + this.timeoutID = null; } - this.timeoutID = setTimeout(_ => { - // FIXME - // this.discardTab(); - reject(new Error('Trace retrieval timed out')); - }, 15000); + this._chrome.close(); + this._chrome = null; + this.url = null; } - getServiceWorkerRegistrations() { - return this.getInstance().then(chrome => { - return new Promise((resolve, reject) => { - chrome.ServiceWorker.enable(); - chrome.on('ServiceWorker.workerVersionUpdated', data => { - resolve(data); - }); - }); - }); - } - - getPageHTML() { - return this.getInstance().then(chrome => { - return new Promise((resolve, reject) => { - chrome.send('DOM.getDocument', null, (docErr, docResult) => { - if (docErr) { - return reject(docErr); - } - - let nodeId = { - nodeId: docResult.root.nodeId - }; - - chrome.send('DOM.getOuterHTML', nodeId, (htmlErr, htmlResult) => { - if (htmlErr) { - return reject(htmlErr); - } + on(eventName, cb) { + if (this._chrome === null) { + return; + } - resolve(htmlResult.outerHTML); - }); - }); - }); - }); + this._chrome.on(eventName, cb); } - static getEvaluationContextFor(chrome, url) { + sendCommand(command, params) { return new Promise((resolve, reject) => { - var errorTimeout = setTimeout((_ => - reject(new Error(`Timed out waiting for ${url} execution context`))), - EXECUTION_CONTEXT_TIMEOUT); - - chrome.Runtime.enable(); - chrome.on('Runtime.executionContextCreated', evalContext => { - // console.info(`executionContext: "${evalContext.context.origin}"`); - if (evalContext.context.origin.indexOf(url) !== -1) { - clearTimeout(errorTimeout); - resolve(evalContext.context.id); + this._chrome.send(command, params, (err, result) => { + if (err) { + return reject(err); } + + resolve(result); }); }); } - evaluateFunction(url, fn) { - let wrappedScriptStr = '(' + fn.toString() + ')()'; - return this.evaluateScript(url, wrappedScriptStr); - } + gotoURL(url, waitForLoad) { + return new Promise((resolve, reject) => { + this._chrome.Page.enable(); + this._chrome.Page.navigate({url}, (err, response) => { + if (err) { + reject(err); + } - evaluateScript(url, scriptSrc) { - return this.getInstance().then(chrome => { - // Set up executionContext listener before navigation. - let contextListener = ChromeProtocol.getEvaluationContextFor(chrome, url); - - return this.gotoURL(url, this.WAIT_FOR_LOAD) - .then(_ => contextListener) - .then(contextId => new Promise((resolve, reject) => { - let evalOpts = { - expression: scriptSrc, - contextId: contextId - }; - chrome.Runtime.evaluate(evalOpts, (err, evalResult) => { - if (err || evalResult.wasThrown) { - return reject(evalResult); - } - - let result = evalResult.result; - - chrome.Runtime.getProperties({ - objectId: result.objectId - }, (err, propsResult) => { - if (err) { - /* continue anyway */ - } - result.props = {}; - if (Array.isArray(propsResult.result)) { - propsResult.result.forEach(prop => { - result.props[prop.name] = prop.value ? prop.value.value : - prop.get.description; - }); - } - resolve(result); - }); - }); - })); - }); - } + this.url = url; - gotoURL(url, waitForLoad) { - return this.getInstance().then(chrome => { - return new Promise((resolve, reject) => { - chrome.Page.enable(); - chrome.Page.navigate({url}, (err, response) => { - if (err) { - reject(err); - } - - if (waitForLoad) { - chrome.Page.loadEventFired(_ => { - this._currentURL = url; - resolve(response); - }); - } else { + if (waitForLoad) { + this._chrome.Page.loadEventFired(_ => { resolve(response); - } - }); + }); + } else { + resolve(response); + } }); }); } - disableCaching() { - // TODO(paullewis): implement. - return Promise.resolve(); + _resetFailureTimeout(reject) { + if (this.timeoutID) { + clearTimeout(this.timeoutID); + this.timeoutID = null; + } + + this.timeoutID = setTimeout(_ => { + this.disconnect(); + reject(new Error('Trace retrieval timed out')); + }, 15000); } beginTrace() { this._traceEvents = []; - return this.getInstance().then(chrome => { + return this.connect().then(chrome => { chrome.Page.enable(); chrome.Tracing.start({ - categories: this.categories.join(','), + categories: this._traceCategories.join(','), options: 'sampling-frequency=10000' // 1000 is default and too slow. }); @@ -228,10 +153,10 @@ class ChromeProtocol { } endTrace() { - return this.getInstance().then(chrome => { + return this.connect().then(chrome => { return new Promise((resolve, reject) => { chrome.Tracing.end(); - this.resetFailureTimeout(reject); + this._resetFailureTimeout(reject); chrome.Tracing.tracingComplete(_ => { resolve(this._traceEvents); @@ -241,7 +166,7 @@ class ChromeProtocol { } beginNetworkCollect() { - return this.getInstance().then(chrome => { + return this.connect().then(chrome => { return new Promise((resolve, reject) => { this._networkRecords = []; this._networkRecorder = new NetworkRecorder(this._networkRecords); @@ -268,7 +193,7 @@ class ChromeProtocol { } endNetworkCollect() { - return this.getInstance().then(chrome => { + return this.connect().then(chrome => { return new Promise((resolve, reject) => { chrome.removeListener('Network.requestWillBeSent', this._networkRecorder.onRequestWillBeSent); diff --git a/helpers/extension/driver.js b/helpers/extension/driver.js new file mode 100644 index 000000000000..11501d637793 --- /dev/null +++ b/helpers/extension/driver.js @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +'use strict'; + +/* globals chrome */ + +class ExtensionProtocol { + + constructor() { + this._listeners = {}; + this._tabId = null; + this._url = null; + chrome.debugger.onEvent.addListener(this._onEvent.bind(this)); + } + + get url() { + return this._url; + } + + set url(_url) { + this._url = _url; + } + + connect() { + return this.queryCurrentTab_() + .then(tabId => { + this._tabId = tabId; + return this.attachDebugger_(tabId); + }); + } + + disconnect() { + if (this._tabId === null) { + return; + } + + this.detachDebugger_(this._tabId) + .then(_ => { + this._tabId = null; + this.url = null; + }); + } + + on(eventName, cb) { + if (typeof this._listeners[eventName] === 'undefined') { + this._listeners[eventName] = []; + } + + this._listeners[eventName].push(cb); + } + + _onEvent(source, method, params) { + if (typeof this._listeners[method] === 'undefined') { + return; + } + + this._listeners[method].forEach(cb => { + cb(params); + }); + + // Reset the listeners; + this._listeners[method].length = 0; + } + + sendCommand(command, params) { + return new Promise((resolve, reject) => { + chrome.debugger.sendCommand({tabId: this._tabId}, command, params, result => { + if (chrome.runtime.lastError) { + return reject(chrome.runtime.lastError); + } + + resolve(result); + }); + }); + } + + queryCurrentTab_() { + const currentTab = { + active: true, + windowId: chrome.windows.WINDOW_ID_CURRENT + }; + + return new Promise((resolve, reject) => { + chrome.tabs.query(currentTab, tabs => { + if (chrome.runtime.lastError) { + return reject(chrome.runtime.lastError); + } + + this.url = tabs[0].url; + resolve(tabs[0].id); + }); + }); + } + + attachDebugger_(tabId) { + return new Promise((resolve, reject) => { + chrome.debugger.attach({tabId}, '1.1', _ => { + if (chrome.runtime.lastError) { + return reject(chrome.runtime.lastError); + } + + resolve(tabId); + }); + }); + } + + detachDebugger_(tabId) { + return new Promise((resolve, reject) => { + chrome.debugger.detach({tabId}, _ => { + if (chrome.runtime.lastError) { + return reject(chrome.runtime.lastError); + } + + resolve(tabId); + }); + }); + } +} + +module.exports = ExtensionProtocol; diff --git a/helpers/manifest-parser.js b/helpers/manifest-parser.js new file mode 100644 index 000000000000..570b923d20e4 --- /dev/null +++ b/helpers/manifest-parser.js @@ -0,0 +1,325 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +'use strict'; + +const ALLOWED_DISPLAY_VALUES = [ + 'fullscreen', + 'standalone', + 'minimal-ui', + 'browser' +]; + +const ALLOWED_ORIENTATION_VALUES = [ + 'any', + 'natural', + 'landscape', + 'portrait', + 'portrait-primary', + 'portrait-secondary', + 'landscape-primary', + 'landscape-secondary' +]; + +function parseString(raw, trim) { + let value; + let warning; + + if (typeof raw === 'string') { + value = trim ? raw.trim() : raw; + } else { + if (raw !== undefined) { + warning = 'ERROR: expected a string.'; + } + value = undefined; + } + + return { + raw, + value, + warning + }; +} + +function parseURL(raw) { + // TODO: resolve url using baseURL + // var baseURL = args.baseURL; + // new URL(parseString(raw).value, baseURL); + return parseString(raw, true); +} + +function parseColor(raw) { + let color = parseString(raw); + + if (color.value === undefined) { + return color; + } + + // TODO(bckenny): validate color, but for reals + // possibly pull in devtools/front_end/common/Color.js + // If style.color changes when set to the given color, it is valid. Testing + // against 'white' and 'black' in case of the given color is one of them. + // var dummy = document.createElement('div'); + // dummy.style.color = 'white'; + // dummy.style.color = color.value; + // if (dummy.style.color !== 'white') { + // return color; + // } + // dummy.style.color = 'black'; + // dummy.style.color = color.value; + // if (dummy.style.color !== 'black') { + // return color; + // } + return color; + + // color.value = undefined; + // color.warning = 'ERROR: color parsing failed'; + + // return color; +} + +function parseName(jsonInput) { + return parseString(jsonInput.name, true); +} + +function parseShortName(jsonInput) { + return parseString(jsonInput.short_name, true); +} + +function parseStartUrl(jsonInput) { + // TODO: parse url using manifest_url as a base (missing). + // start_url must be same-origin as Document of the top-level browsing context. + return parseURL(jsonInput.start_url); +} + +function parseDisplay(jsonInput) { + let display = parseString(jsonInput.display, true); + + if (display.value && ALLOWED_DISPLAY_VALUES.indexOf(display.value.toLowerCase()) === -1) { + display.value = undefined; + display.warning = 'ERROR: \'display\' has an invalid value, will be ignored.'; + } + + return display; +} + +function parseOrientation(jsonInput) { + let orientation = parseString(jsonInput.orientation, true); + + if (orientation.value && + ALLOWED_ORIENTATION_VALUES.indexOf(orientation.value.toLowerCase()) === -1) { + orientation.value = undefined; + orientation.warning = 'ERROR: \'orientation\' has an invalid value, will be ignored.'; + } + + return orientation; +} + +function parseIcon(raw) { + // TODO: pass manifest url as base. + let src = parseURL(raw.src); + let type = parseString(raw.type, true); + + let density = { + raw: raw.density, + value: 1, + warning: undefined + }; + if (density.raw !== undefined) { + density.value = parseFloat(density.raw); + if (isNaN(density.value) || !isFinite(density.value) || density.value <= 0) { + density.value = 1; + density.warning = 'ERROR: icon density cannot be NaN, +∞, or less than or equal to +0.'; + } + } + + let sizes = parseString(raw.sizes); + if (sizes.value !== undefined) { + let set = new Set(); + sizes.value.split(/\s/).forEach(size => set.add(size.toLowerCase())); + + sizes.value = set.size > 0 ? Array.from(set) : undefined; + } + + return { + raw, + value: { + src, + type, + density, + sizes + }, + warning: undefined + }; +} + +function parseIcons(jsonInput) { + const raw = jsonInput.icons; + let value; + + if (raw === undefined) { + return { + raw, + value, + warning: undefined + }; + } + + if (!Array.isArray(raw)) { + return { + raw, + value, + warning: 'ERROR: \'icons\' expected to be an array but is not.' + }; + } + + // TODO(bckenny): spec says to skip icons missing `src`. Warn instead? + value = raw.filter(icon => !!icon.src).map(parseIcon); + + return { + raw, + value, + warning: undefined + }; +} + +function parseApplication(raw) { + let platform = parseString(raw.platform, true); + let id = parseString(raw.id, true); + // TODO: pass manfiest url as base. + let url = parseURL(raw.url); + + return { + raw, + value: { + platform, + id, + url + }, + warning: undefined + }; +} + +function parseRelatedApplications(jsonInput) { + const raw = jsonInput.related_applications; + let value; + + if (raw === undefined) { + return { + raw, + value, + warning: undefined + }; + } + + if (!Array.isArray(raw)) { + return { + raw, + value, + warning: 'ERROR: \'related_applications\' expected to be an array but is not.' + }; + } + + // TODO(bckenny): spec says to skip apps missing `platform`. Warn instead? + value = raw.filter(application => !!application.platform).map(parseApplication); + + return { + raw, + value, + warning: undefined + }; +} + +function parsePreferRelatedApplications(jsonInput) { + const raw = jsonInput.prefer_related_applications; + let value; + let warning; + + if (typeof raw === 'boolean') { + value = raw; + } else { + if (raw !== undefined) { + warning = 'ERROR: \'prefer_related_applications\' expected to be a boolean.'; + } + value = undefined; + } + + return { + raw, + value, + warning + }; +} + +function parseThemeColor(jsonInput) { + return parseColor(jsonInput.theme_color); +} + +function parseBackgroundColor(jsonInput) { + return parseColor(jsonInput.background_color); +} + +function parse(string, logToConsole) { + let jsonInput; + + try { + jsonInput = JSON.parse(string); + } catch (e) { + return { + raw: string, + value: undefined, + warning: 'ERROR: file isn\'t valid JSON: ' + e + }; + } + + /* eslint-disable camelcase */ + let manifest = { + name: parseName(jsonInput), + short_name: parseShortName(jsonInput), + start_url: parseStartUrl(jsonInput), + display: parseDisplay(jsonInput), + orientation: parseOrientation(jsonInput), + icons: parseIcons(jsonInput), + related_applications: parseRelatedApplications(jsonInput), + prefer_related_applications: parsePreferRelatedApplications(jsonInput), + theme_color: parseThemeColor(jsonInput), + background_color: parseBackgroundColor(jsonInput) + }; + /* eslint-enable camelcase */ + + if (logToConsole) { + console.log('JSON parsed successfully.'); + console.log('Parsed `name` property is: ' + manifest.name); + console.log('Parsed `short_name` property is: ' + manifest.short_name); + console.log('Parsed `start_url` property is: ' + manifest.start_url); + console.log('Parsed `display` property is: ' + manifest.display); + console.log('Parsed `orientation` property is: ' + manifest.orientation); + console.log('Parsed `icons` property is: ' + JSON.stringify(manifest.icons, null, 4)); + console.log('Parsed `related_applications` property is: ' + + JSON.stringify(manifest.related_applications, null, 4)); + console.log('Parsed `prefer_related_applications` property is: ' + + JSON.stringify(manifest.prefer_related_applications, null, 4)); + console.log('Parsed `theme_color` property is: ' + manifest.theme_color); + console.log('Parsed `background_color` property is: ' + manifest.background_color); + } + + return { + raw: string, + value: manifest, + warning: undefined + }; +} + +module.exports = parse; diff --git a/helpers/network-recorder.js b/helpers/network-recorder.js index cb5360995970..b15397798415 100644 --- a/helpers/network-recorder.js +++ b/helpers/network-recorder.js @@ -61,6 +61,25 @@ global.SecurityAgent = { } }; +// From https://chromium.googlesource.com/chromium/src/third_party/WebKit/Source/devtools/+/master/protocol.json#93 +global.PageAgent = { + ResourceType: { + Document: 'document', + Stylesheet: 'stylesheet', + Image: 'image', + Media: 'media', + Font: 'font', + Script: 'script', + TextTrack: 'texttrack', + XHR: 'xhr', + Fetch: 'fetch', + EventSource: 'eventsource', + WebSocket: 'websocket', + Manifest: 'manifest', + Other: 'other' + } +}; + require('chrome-devtools-frontend/front_end/common/Object.js'); require('chrome-devtools-frontend/front_end/common/ParsedURL.js'); require('chrome-devtools-frontend/front_end/common/ResourceType.js'); diff --git a/index.js b/index.js index b0673b251413..6b45d774894c 100644 --- a/index.js +++ b/index.js @@ -15,32 +15,44 @@ */ 'use strict'; -let URL = 'https://voice-memos.appspot.com'; +const url = 'https://voice-memos.appspot.com'; +const ChromeProtocol = require('./helpers/browser/driver'); -let gatherer = require('./gatherer'); -let auditor = require('./auditor'); -let ChromeProtocol = require('./helpers/browser/driver'); +const Auditor = require('./auditor'); +const Gatherer = require('./gatherer'); const driver = new ChromeProtocol(); +const gatherer = new Gatherer(); +const auditor = new Auditor(); +const gatherers = [ + require('./gatherers/url'), + require('./gatherers/load-trace'), + require('./gatherers/https'), + require('./gatherers/service-worker'), + require('./gatherers/html'), + require('./gatherers/manifest') +]; +const audits = [ + require('./audits/security/is-on-https'), + require('./audits/offline/service-worker'), + require('./audits/mobile-friendly/viewport'), + require('./audits/manifest/exists'), + require('./audits/manifest/background-color'), + require('./audits/manifest/theme-color'), + require('./audits/manifest/icons'), + require('./audits/manifest/icons-192'), + require('./audits/manifest/name'), + require('./audits/manifest/short-name'), + require('./audits/manifest/start-url') +]; -Promise.resolve(driver).then(gatherer([ - require('./audits/viewport-meta-tag/gather'), - require('./audits/minify-html/gather'), - require('./audits/service-worker/gather'), - require('./gatherers/trace') -], URL)).then(auditor([ - require('./audits/minify-html/audit'), - require('./audits/service-worker/audit'), - require('./audits/time-in-javascript/audit'), - require('./audits/viewport-meta-tag/audit'), - require('./metrics/first-meaningful-paint/audit') -])).then(function(results) { - console.log('all done'); - console.log(results); - // driver.discardTab(); // FIXME: close connection later - // process.exit(0); -}).catch(function(err) { - console.log('error encountered', err); - console.log(err.stack); - throw err; -}); +gatherer + .gather(gatherers, {url, driver}) + .then(artifacts => auditor.audit(artifacts, audits)) + .then(results => { + console.log(results); + }).catch(function(err) { + console.log('error encountered', err); + console.log(err.stack); + throw err; + }); diff --git a/package.json b/package.json index 47828d487a9d..e55f82e78b7e 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "chai": "^3.4.0", "eslint": "^2.4.0", "eslint-config-google": "^0.4.0", - "mocha": "^2.3.3" + "mocha": "^2.3.3", + "request": "^2.69.0" } } diff --git a/test/manifest-parser-tests.js b/test/manifest-parser-tests.js new file mode 100644 index 000000000000..e8842a92f0d8 --- /dev/null +++ b/test/manifest-parser-tests.js @@ -0,0 +1,105 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +'use strict'; + +/* global describe, it */ + +var manifestParser = require('../helpers/manifest-parser'); +var expect = require('chai').expect; + +describe('Manifest Parser', function() { + it('should not parse empty string input', function() { + let parsedManifest = manifestParser(''); + expect(!parsedManifest.warning).to.equal(false); + }); + + it('accepts empty dictionary', function() { + let parsedManifest = manifestParser('{}'); + expect(!parsedManifest.warning).to.equal(true); + expect(parsedManifest.value.name.value).to.equal(undefined); + expect(parsedManifest.value.short_name.value).to.equal(undefined); + expect(parsedManifest.value.start_url.value).to.equal(undefined); + expect(parsedManifest.value.display.value).to.equal(undefined); + expect(parsedManifest.value.orientation.value).to.equal(undefined); + expect(parsedManifest.value.theme_color.value).to.equal(undefined); + expect(parsedManifest.value.background_color.value).to.equal(undefined); + // TODO: + // icons + // related_applications + // prefer_related_applications + }); + + it('accepts unknown values', function() { + // TODO(bckenny): this is the same exact test as above + let parsedManifest = manifestParser('{}'); + expect(!parsedManifest.warning).to.equal(true); + expect(parsedManifest.value.name.value).to.equal(undefined); + expect(parsedManifest.value.short_name.value).to.equal(undefined); + expect(parsedManifest.value.start_url.value).to.equal(undefined); + expect(parsedManifest.value.display.value).to.equal(undefined); + expect(parsedManifest.value.orientation.value).to.equal(undefined); + expect(parsedManifest.value.theme_color.value).to.equal(undefined); + expect(parsedManifest.value.background_color.value).to.equal(undefined); + }); + + describe('name parsing', function() { + it('it parses basic string', function() { + let parsedManifest = manifestParser('{"name":"foo"}'); + expect(!parsedManifest.warning).to.equal(true); + expect(parsedManifest.value.name.value).to.equal('foo'); + }); + + it('it trims whitespaces', function() { + let parsedManifest = manifestParser('{"name":" foo "}'); + expect(!parsedManifest.warning).to.equal(true); + expect(parsedManifest.value.name.value).to.equal('foo'); + }); + + it('doesn\'t parse non-string', function() { + let parsedManifest = manifestParser('{"name": {} }'); + expect(!parsedManifest.warning).to.equal(true); + expect(parsedManifest.value.name.value).to.equal(undefined); + + parsedManifest = manifestParser('{"name": 42 }'); + expect(!parsedManifest.warning).to.equal(true); + expect(parsedManifest.value.name.value).to.equal(undefined); + }); + }); + + describe('short_name parsing', function() { + it('it parses basic string', function() { + let parsedManifest = manifestParser('{"short_name":"foo"}'); + expect(!parsedManifest.warning).to.equal(true); + expect(parsedManifest.value.short_name.value).to.equal('foo'); + }); + + it('it trims whitespaces', function() { + let parsedManifest = manifestParser('{"short_name":" foo "}'); + expect(!parsedManifest.warning).to.equal(true); + expect(parsedManifest.value.short_name.value).to.equal('foo'); + }); + + it('doesn\'t parse non-string', function() { + let parsedManifest = manifestParser('{"short_name": {} }'); + expect(!parsedManifest.warning).to.equal(true); + expect(parsedManifest.value.short_name.value).to.equal(undefined); + + parsedManifest = manifestParser('{"short_name": 42 }'); + expect(!parsedManifest.warning).to.equal(true); + expect(parsedManifest.value.short_name.value).to.equal(undefined); + }); + }); +});