Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 91 additions & 21 deletions lighthouse-core/config/budget.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* 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 URL = require('../lib/url-shim');

class Budget {
/**
Expand All @@ -21,14 +22,10 @@ class Budget {
}

/**
* @param {LH.Budget.ResourceBudget} resourceBudget
* @return {LH.Budget.ResourceBudget}
* @return {Array<string>}
*/
static validateResourceBudget(resourceBudget) {
const {resourceType, budget, ...invalidRest} = resourceBudget;
Budget.assertNoExcessProperties(invalidRest, 'Resource Budget');

const validResourceTypes = [
static validResourceTypes() {
return [
'total',
'document',
'script',
Expand All @@ -39,9 +36,19 @@ class Budget {
'other',
'third-party',
];
if (!validResourceTypes.includes(resourceBudget.resourceType)) {
}

/**
* @param {LH.Budget.ResourceBudget} resourceBudget
* @return {LH.Budget.ResourceBudget}
*/
static validateResourceBudget(resourceBudget) {
const {resourceType, budget, ...invalidRest} = resourceBudget;
Budget.assertNoExcessProperties(invalidRest, 'Resource Budget');

if (!this.validResourceTypes().includes(resourceBudget.resourceType)) {
throw new Error(`Invalid resource type: ${resourceBudget.resourceType}. \n` +
`Valid resource types are: ${ validResourceTypes.join(', ') }`);
`Valid resource types are: ${this.validResourceTypes().join(', ') }`);
}
if (isNaN(resourceBudget.budget)) {
throw new Error('Invalid budget: ${resourceBudget.budget}');
Expand All @@ -52,6 +59,70 @@ class Budget {
};
}

/**
* @param {string} path
* @return {string}
*/
static validatePath(path) {
if (!path) {
throw new Error(`A valid path must be provided`);
}

const hasLeadingSlash = path[0] === '/';
const validWildcardQuantity = ((path.match(/\*/g) || []).length <= 1);
const validDollarSignQuantity = ((path.match(/\*/g) || []).length <= 1);
const validDollarSignPlacement = (path.indexOf('$') === -1) || (path[path.length - 1] === '$');

const isValid = hasLeadingSlash && validWildcardQuantity
&& validDollarSignQuantity && validDollarSignPlacement;

if (!isValid) {
throw new Error(`Invalid path ${path}. ` +
`'Path' should be specified using the 'robots.txt' format.\n` +
`Learn more about the 'robots.txt' format here:\n` +
`https://developers.google.com/search/reference/robots_txt#url-matching-based-on-path-values`);
}
return path;
}

/**
* @param {string} url
* @param {string} pattern
* @return {boolean}
*/
static urlMatchesPattern(url, pattern) {
/**
* Pattern should use the robots.txt format. E.g. "/*-article.html" or "/". Reference:
* https://developers.google.com/search/reference/robots_txt#url-matching-based-on-path-values
*/

const urlObj = new URL(url);
const urlPath = urlObj.pathname + urlObj.search;

const hasWildcard = pattern.includes('*');
const hasEndingPattern = pattern.includes('$');

// No *, No $: URL should start with given pattern
if (!hasWildcard && !hasEndingPattern) {
return urlPath.startsWith(pattern);
// No *, Yes $: URL should end with given pattern
} else if (!hasWildcard && hasEndingPattern) {
return urlPath.endsWith(pattern.slice(0, -1));
// Yes *, No $: URL should start with the string pattern that comes before the wildcard
// & later in the string contain the string pattern that comes after the wildcard.
} else if (hasWildcard && !hasEndingPattern) {
const [beforeWildcard, afterWildcard] = pattern.split('*');
const remainingUrl = urlPath.slice(beforeWildcard.length);
return urlPath.startsWith(beforeWildcard) && remainingUrl.includes(afterWildcard);
// Yes *, Yes $: URL should start with the string pattern that comes before the wildcard
// & end with the string pattern that comes after the wildcard.
} else if (hasWildcard && hasEndingPattern) {
const [beforeWildcard, afterWildcard] = pattern.split('*');
return urlPath.startsWith(beforeWildcard) && urlPath.endsWith(afterWildcard.slice(0, -1));
}
return false;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be impossible right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be, but TypeScript complains about it missing a return statement without it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah for sure :) maybe just a comment to that effect or throw even?

}

/**
* @param {LH.Budget.TimingBudget} timingBudget
* @return {LH.Budget.TimingBudget}
Expand Down Expand Up @@ -98,31 +169,30 @@ class Budget {
/** @type {LH.Budget} */
const budget = {};

const {resourceSizes, resourceCounts, timings, ...invalidRest} = b;
const {path, resourceSizes, resourceCounts, timings, ...invalidRest} = b;
Budget.assertNoExcessProperties(invalidRest, 'Budget');

if (b.resourceSizes !== undefined) {
budget.resourceSizes = b.resourceSizes.map((r) => {
budget.path = Budget.validatePath(path);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't seem to mutate path, WDYT about?

Suggested change
budget.path = Budget.validatePath(path);
Budget.assertValidPath(path);

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't. And I may be getting ahead of myself here, but I think budget input will be mutated soon.

(Specifically, in #8539 I think converting user budgets from KB to bytes should be handled in budget.js rather than the audit.)

Once it does, I personally find it more readable when everything uses the assignment pattern rather than mixing styles. TLDR; I feel like this style is more future-proof?

Copy link
Collaborator

@patrickhulce patrickhulce Apr 30, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gotcha thanks for the explanation that makes sense! ⚠️ counter point ahead :)

rather than mixing styles

unfortunately I feel like that might be inevitable when we assertNoExcessProperties the line above this 😕

as a reader, I start looking for how this method changes path but it doesn't. given that we will definitely have some functions that will assert and some that will mutate, how do you feel about aligning the non-mutating validation logic on assert* without assignment (which also aligns with our other config validation logic) and mutate/normalize/someOtherModificationVerb with the assignment pattern for the ones that do? :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, I have an imminent PR that does a little more budget validation that will helpfully make this decision more complicated :/


if (resourceSizes !== undefined) {
budget.resourceSizes = resourceSizes.map((r) => {
return Budget.validateResourceBudget(r);
});
}

if (b.resourceCounts !== undefined) {
budget.resourceCounts = b.resourceCounts.map((r) => {
if (resourceCounts !== undefined) {
budget.resourceCounts = resourceCounts.map((r) => {
return Budget.validateResourceBudget(r);
});
}

if (b.timings !== undefined) {
budget.timings = b.timings.map((t) => {
if (timings !== undefined) {
budget.timings = timings.map((t) => {
return Budget.validateTimingBudget(t);
});
}
budgets.push({
resourceSizes,
resourceCounts,
timings,
});

budgets.push(budget);
});
return budgets;
}
Expand Down
150 changes: 118 additions & 32 deletions lighthouse-core/test/config/budget-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ const assert = require('assert');
/* eslint-env jest */

describe('Budget', () => {
let budget;
let budgetJson;
beforeEach(() => {
budget = [
budgetJson = [
{
path: '/',
resourceSizes: [
{
resourceType: 'script',
Expand Down Expand Up @@ -48,6 +49,7 @@ describe('Budget', () => {
],
},
{
path: '/',
resourceSizes: [
{
resourceType: 'script',
Expand All @@ -59,70 +61,154 @@ describe('Budget', () => {
});

it('initializes correctly', () => {
const budgets = Budget.initializeBudget(budget);
assert.equal(budgets.length, 2);
const budget = Budget.initializeBudget(budgetJson);
assert.equal(budget.length, 2);

assert.equal(budget[0].path, '/');

// Sets resources sizes correctly
assert.equal(budgets[0].resourceSizes.length, 2);
assert.equal(budgets[0].resourceSizes[0].resourceType, 'script');
assert.equal(budgets[0].resourceSizes[0].budget, 123);
assert.equal(budget[0].resourceSizes.length, 2);
assert.equal(budget[0].resourceSizes[0].resourceType, 'script');
assert.equal(budget[0].resourceSizes[0].budget, 123);

// Sets resource counts correctly
assert.equal(budgets[0].resourceCounts.length, 2);
assert.equal(budgets[0].resourceCounts[0].resourceType, 'total');
assert.equal(budgets[0].resourceCounts[0].budget, 100);
assert.equal(budget[0].resourceCounts.length, 2);
assert.equal(budget[0].resourceCounts[0].resourceType, 'total');
assert.equal(budget[0].resourceCounts[0].budget, 100);

// Sets timings correctly
assert.equal(budgets[0].timings.length, 2);
assert.equal(budgets[0].timings[1].metric, 'first-contentful-paint');
assert.equal(budgets[0].timings[1].budget, 1000);
assert.equal(budgets[0].timings[1].tolerance, 500);
assert.equal(budget[0].timings.length, 2);
assert.equal(budget[0].timings[1].metric, 'first-contentful-paint');
assert.equal(budget[0].timings[1].budget, 1000);
assert.equal(budget[0].timings[1].tolerance, 500);

// Does not set unsupplied budgets
assert.equal(budgets[1].timings, null);
// Does not set unsupplied budget
assert.equal(budget[1].timings, null);
});

it('throws error if an unsupported budget property is used', () => {
budget[0].sizes = [];
assert.throws(_ => Budget.initializeBudget(budget), /[sizes]/);
budgetJson[0].sizes = [];
assert.throws(_ => Budget.initializeBudget(budgetJson), /[sizes]/);
});

describe('resource budget validation', () => {
it('throws when an invalid resource type is supplied', () => {
budget[0].resourceSizes[0].resourceType = 'movies';
assert.throws(_ => Budget.initializeBudget(budget), /Invalid resource type/);
budgetJson[0].resourceSizes[0].resourceType = 'movies';
assert.throws(_ => Budget.initializeBudget(budgetJson), /Invalid resource type/);
});

it('throws when an invalid budget is supplied', () => {
budget[0].resourceSizes[0].budget = '100 MB';
assert.throws(_ => Budget.initializeBudget(budget), /Invalid budget/);
budgetJson[0].resourceSizes[0].budget = '100 MB';
assert.throws(_ => Budget.initializeBudget(budgetJson), /Invalid budget/);
});

it('throws when an invalid property is supplied', () => {
budget[0].resourceSizes[0].browser = 'Chrome';
assert.throws(_ => Budget.initializeBudget(budget), /[browser]/);
budgetJson[0].resourceSizes[0].browser = 'Chrome';
assert.throws(_ => Budget.initializeBudget(budgetJson), /[browser]/);
})
;
it('throws when snake case is not used', () => {
budgetJson[0].resourceSizes[0].resourceType = 'thirdParty';
assert.throws(_ => Budget.initializeBudget(budgetJson), /Invalid resource type/);
});
});

describe('timing budget validation', () => {
it('throws when an invalid metric is supplied', () => {
budget[0].timings[0].metric = 'lastMeaningfulPaint';
assert.throws(_ => Budget.initializeBudget(budget), /Invalid timing metric/);
budgetJson[0].timings[0].metric = 'lastMeaningfulPaint';
assert.throws(_ => Budget.initializeBudget(budgetJson), /Invalid timing metric/);
});

it('throws when an invalid budget is supplied', () => {
budget[0].timings[0].budget = '100KB';
assert.throws(_ => Budget.initializeBudget(budget), /Invalid budget/);
budgetJson[0].timings[0].budget = '100KB';
assert.throws(_ => Budget.initializeBudget(budgetJson), /Invalid budget/);
});

it('throws when an invalid tolerance is supplied', () => {
budget[0].timings[0].tolerance = '100ms';
assert.throws(_ => Budget.initializeBudget(budget), /Invalid tolerance/);
budgetJson[0].timings[0].tolerance = '100ms';
assert.throws(_ => Budget.initializeBudget(budgetJson), /Invalid tolerance/);
});

it('throws when an invalid property is supplied', () => {
budget[0].timings[0].device = 'Phone';
assert.throws(_ => Budget.initializeBudget(budget), /[device]/);
budgetJson[0].timings[0].device = 'Phone';
assert.throws(_ => Budget.initializeBudget(budgetJson), /[device]/);
});

it('throws when "time-to-interactive is supplied', () => {
budgetJson[0].timings[0].metric = 'time-to-interactive';
assert.throws(_ => Budget.initializeBudget(budgetJson), /Invalid timing metric/);
});
});

describe('path validation', () => {
it('initializes correctly', () => {
const budgetArr = [{}];

budgetArr[0].path = '/';
assert.equal(Budget.initializeBudget(budgetArr)[0].path, '/');

budgetArr[0].path = '/*';
assert.equal(Budget.initializeBudget(budgetArr)[0].path, '/*');

budgetArr[0].path = '/fish*.php';
assert.equal(Budget.initializeBudget(budgetArr)[0].path, '/fish*.php');

budgetArr[0].path = '/*.php$';
assert.equal(Budget.initializeBudget(budgetArr)[0].path, '/*.php$');
});

it('throws error if an invalid path is used', () => {
const budget = {};

budget.path = '';
assert.throws(_ => Budget.initializeBudget(budget), /[A valid path]/);

budget.path = 'cat';
assert.throws(_ => Budget.initializeBudget(budget), /[Invalid path]/);

budget.path = '/cat*cat*cat';
assert.throws(_ => Budget.initializeBudget(budget), /[Invalid path]/);

budget.path = '/cat$html';
assert.throws(_ => Budget.initializeBudget(budget), /[Invalid path]/);
});

it('matches root', () => {
assert.ok(Budget.urlMatchesPattern('https://google.com', '/'));
assert.ok(Budget.urlMatchesPattern('https://google.com', '/*'));
});

it('ignores origin', () => {
assert.equal(Budget.urlMatchesPattern('https://yt.com/videos?id=', '/videos'), true);
assert.equal(Budget.urlMatchesPattern('https://go.com/dogs', '/go'), false);
});

it('is correct', () => {
const pathMatch = (path, pattern) => {
const origin = 'https://example.com';
return Budget.urlMatchesPattern(origin + path, pattern);
};

// No *, No $:
assert.equal(pathMatch('/anything', '/'), true);
assert.equal(pathMatch('/anything', '/any'), true);
assert.equal(pathMatch('/anything', '/anything1'), false);

// No *, Yes $:
assert.equal(pathMatch('/fish.php', '/fish.php$'), true);
assert.equal(pathMatch('/Fish.PHP', '/fish.php$'), false);

// Yes *, No $:
assert.equal(pathMatch('/anything', '/*'), true);
assert.equal(pathMatch('/fish', '/fish*'), true);
assert.equal(pathMatch('/fishfood', '/*food'), true);
assert.equal(pathMatch('/fis/', '/fish*'), false);

// Yes *, Yes $:
assert.equal(pathMatch('/fish.php', '/*.php$'), true);
assert.equal(pathMatch('/folder/filename.php', '/folder*.php$'), true);
assert.equal(pathMatch('/fish.php?species=', '/*.php$'), false);
assert.equal(pathMatch('/filename.php/', '/folder*.php$'), false);
});
});
});
1 change: 1 addition & 0 deletions lighthouse-core/test/config/config-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,7 @@ describe('Config', () => {
const configJson = {
settings: {
budgets: [{
path: '/',
resourceCounts: [{
resourceType: 'image',
budget: 500,
Expand Down
1 change: 1 addition & 0 deletions lighthouse-core/test/fixtures/simple-budget.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[
{
"path": "/",
"resourceSizes": [
{
"resourceType": "script",
Expand Down
1 change: 1 addition & 0 deletions lighthouse-core/test/results/artifacts/artifacts.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"channel": "cli",
"budgets": [
{
"path": "/",
"resourceSizes": [
{
"resourceType": "script",
Expand Down
Loading