Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tests: add page functions bundling test #15280

Merged
merged 5 commits into from
Jul 18, 2023
Merged
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
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ third-party/**
# ignore d.ts files until we can properly lint them
**/*.d.ts
**/*.d.cts

page-functions-test-case*out*.js
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,4 @@ yarn.lock
**/*.d.cts
!**/types/**/*.d.ts

page-functions-test-case*out*.js
Copy link
Member

Choose a reason for hiding this comment

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

Some unit tests store random output files in the .tmp directory. WDYT about moving this output there?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I prefer where it is, since it is right next to the input file. Nicer for debugging.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* @license Copyright 2022 The Lighthouse Authors. All Rights Reserved.
adamraine marked this conversation as resolved.
Show resolved Hide resolved
* @license Copyright 2023 The Lighthouse Authors. 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.
*/
Expand Down
38 changes: 38 additions & 0 deletions build/test/page-functions-build-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* @license Copyright 2023 The Lighthouse Authors. 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.
*/

// Our page functions are very sensitive to mangling performed by bundlers. Incorrect
// bundling will certainly result in `yarn test-bundle` or `yarn smoke --runner devtools` failing.
// The bundled lighthouse is a huge beast and hard to debug, so instead we have these smaller bundles
// which are much easier to reason about.

import path from 'path';
import {execFileSync} from 'child_process';

import {LH_ROOT} from '../../root.js';
import {buildBundle} from '../build-bundle.js';

describe('page functions build', () => {
const testCases = [
`${LH_ROOT}/build/test/page-functions-test-case-computeBenchmarkIndex.js`,
`${LH_ROOT}/build/test/page-functions-test-case-getNodeDetails.js`,
`${LH_ROOT}/build/test/page-functions-test-case-getElementsInDocument.js`,
];

for (const minify of [false, true]) {
describe(`minify: ${minify}`, () => {
for (const input of testCases) {
it(`bundle and evaluate ${path.basename(input)}`, async () => {
const output = minify ?
input.replace('.js', '-out.min.js') :
input.replace('.js', '-out.js');
await buildBundle(input, output, {minify});
execFileSync('node', [output]);
});
}
});
}
});
31 changes: 31 additions & 0 deletions build/test/page-functions-test-case-computeBenchmarkIndex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* @license Copyright 2023 The Lighthouse Authors. 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.
*/

import {ExecutionContext} from '../../core/gather/driver/execution-context.js';
import {pageFunctions} from '../../core/lib/page-functions.js';

/**
*
* @param {Function} mainFn
* @param {any[]} args
* @param {any[]} deps
* @return {string}
*/
function stringify(mainFn, args, deps) {
const argsSerialized = ExecutionContext.serializeArguments(args);
const depsSerialized = ExecutionContext.serializeDeps(deps);
const expression = `(() => {
${depsSerialized}
return (${mainFn})(${argsSerialized});
})()`;
return expression;
}

// Indirect eval so code is run in global scope, and won't have incidental access to the
// esbuild keepNames function wrapper.
const indirectEval = eval;
const result = indirectEval(stringify(pageFunctions.computeBenchmarkIndex, [], []));
if (typeof result !== 'number') throw new Error(`expected number, but got ${result}`);
80 changes: 80 additions & 0 deletions build/test/page-functions-test-case-getElementsInDocument.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @license Copyright 2023 The Lighthouse Authors. 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.
*/

/* eslint-disable no-undef */

import {ExecutionContext} from '../../core/gather/driver/execution-context.js';
import {pageFunctions} from '../../core/lib/page-functions.js';

/**
*
* @param {Function} mainFn
* @param {any[]} args
* @param {any[]} deps
* @return {string}
*/
function stringify(mainFn, args, deps) {
const argsSerialized = ExecutionContext.serializeArguments(args);
const depsSerialized = ExecutionContext.serializeDeps(deps);
const expression = `(() => {
${depsSerialized}
return (${mainFn})(${argsSerialized});
})()`;
return expression;
}

const fakeWindow = {};
fakeWindow.Node = class FakeNode {
querySelectorAll() {
return [
Object.assign(new HTMLElement(), {innerText: 'interesting'}),
Object.assign(new HTMLElement(), {innerText: 'not so interesting'}),
];
}
};
fakeWindow.Element = class FakeElement extends fakeWindow.Node {
matches() {
return true;
}
};
fakeWindow.HTMLElement = class FakeHTMLElement extends fakeWindow.Element {};

// @ts-expect-error
globalThis.window = fakeWindow;
// @ts-expect-error
globalThis.document = new fakeWindow.Node();
globalThis.HTMLElement = globalThis.window.HTMLElement;

/**
* @param {HTMLElement[]} elements
* @return {HTMLElement[]}
*/
function filterInterestingHtmlElements(elements) {
return elements.filter(e => e.innerText === 'interesting');
}

function mainFn() {
const el = Object.assign(new HTMLElement(), {
tagName: 'FakeHTMLElement',
innerText: 'contents',
classList: [],
});
/** @type {HTMLElement[]} */
// @ts-expect-error
const elements = getElementsInDocument(el);
return filterInterestingHtmlElements(elements);
}

// Indirect eval so code is run in global scope, and won't have incidental access to the
// esbuild keepNames function wrapper.
const indirectEval = eval;
const result = indirectEval(stringify(mainFn, [], [
pageFunctions.getElementsInDocument,
filterInterestingHtmlElements,
]));
if (!result || result.length !== 1 || result[0].innerText !== 'interesting') {
throw new Error(`unexpected result, got ${JSON.stringify(result, null, 2)}`);
}
65 changes: 65 additions & 0 deletions build/test/page-functions-test-case-getNodeDetails.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* @license Copyright 2023 The Lighthouse Authors. 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.
*/

/* eslint-disable no-undef */

import {ExecutionContext} from '../../core/gather/driver/execution-context.js';
import {pageFunctions} from '../../core/lib/page-functions.js';

/**
*
* @param {Function} mainFn
* @param {any[]} args
* @param {any[]} deps
* @return {string}
*/
function stringify(mainFn, args, deps) {
const argsSerialized = ExecutionContext.serializeArguments(args);
const depsSerialized = ExecutionContext.serializeDeps(deps);
const expression = `(() => {
${depsSerialized}
return (${mainFn})(${argsSerialized});
})()`;
return expression;
}

const fakeWindow = {
HTMLElement: class FakeHTMLElement {
getAttribute() {
return '';
}

getBoundingClientRect() {
return {left: 42};
}
},
};

// @ts-expect-error
globalThis.window = fakeWindow;
globalThis.HTMLElement = globalThis.window.HTMLElement;
// @ts-expect-error
globalThis.ShadowRoot = class FakeShadowRoot {};
// @ts-expect-error
globalThis.Node = {DOCUMENT_FRAGMENT_NODE: 11};

function mainFn() {
const el = Object.assign(new HTMLElement(), {
tagName: 'FakeHTMLElement',
innerText: 'contents',
classList: [],
});
// @ts-expect-error
return getNodeDetails(el);
}

// Indirect eval so code is run in global scope, and won't have incidental access to the
// esbuild keepNames function wrapper.
const indirectEval = eval;
const result = indirectEval(stringify(mainFn, [], [pageFunctions.getNodeDetails]));
if (result.lhId !== 'page-0-FakeHTMLElement' || result.boundingRect.left !== 42) {
throw new Error(`unexpected result, got ${JSON.stringify(result, null, 2)}`);
}
48 changes: 24 additions & 24 deletions core/gather/driver/execution-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,28 +155,6 @@ class ExecutionContext {
}
}

/**
* Serializes an array of functions or strings.
*
* Also makes sure that an esbuild-bundled version of Lighthouse will
* continue to create working code to be executed within the browser.
* @param {Array<Function|string>=} deps
* @return {string}
*/
_serializeDeps(deps) {
deps = [pageFunctions.esbuildFunctionNameStubString, ...deps || []];
return deps.map(dep => {
if (typeof dep === 'function') {
// esbuild will change the actual function name (ie. function actualName() {})
// always, despite minification settings, but preserve the real name in `actualName.name`
// (see esbuildFunctionNameStubString).
return `const ${dep.name} = ${dep}`;
} else {
return dep;
}
}).join('\n');
}

/**
* Note: Prefer `evaluate` instead.
* Evaluate an expression in the context of the current page. If useIsolation is true, the expression
Expand Down Expand Up @@ -219,7 +197,7 @@ class ExecutionContext {
*/
evaluate(mainFn, options) {
const argsSerialized = ExecutionContext.serializeArguments(options.args);
const depsSerialized = this._serializeDeps(options.deps);
const depsSerialized = ExecutionContext.serializeDeps(options.deps);

const expression = `(() => {
${depsSerialized}
Expand All @@ -239,7 +217,7 @@ class ExecutionContext {
*/
async evaluateOnNewDocument(mainFn, options) {
const argsSerialized = ExecutionContext.serializeArguments(options.args);
const depsSerialized = this._serializeDeps(options.deps);
const depsSerialized = ExecutionContext.serializeDeps(options.deps);

const expression = `(() => {
${ExecutionContext._cachedNativesPreamble};
Expand Down Expand Up @@ -289,6 +267,28 @@ class ExecutionContext {
static serializeArguments(args) {
return args.map(arg => arg === undefined ? 'undefined' : JSON.stringify(arg)).join(',');
}

/**
* Serializes an array of functions or strings.
*
* Also makes sure that an esbuild-bundled version of Lighthouse will
* continue to create working code to be executed within the browser.
* @param {Array<Function|string>=} deps
* @return {string}
*/
static serializeDeps(deps) {
deps = [pageFunctions.esbuildFunctionNameStubString, ...deps || []];
return deps.map(dep => {
if (typeof dep === 'function') {
// esbuild will change the actual function name (ie. function actualName() {})
// always, despite minification settings, but preserve the real name in `actualName.name`
// (see esbuildFunctionNameStubString).
return `const ${dep.name} = ${dep}`;
} else {
return dep;
}
}).join('\n');
}
}

export {ExecutionContext};
11 changes: 5 additions & 6 deletions core/lib/page-functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -514,12 +514,11 @@ function truncate(string, characterLimit) {
}

// This is to support bundled lighthouse.
// esbuild calls every function with a builtin `__name`, whose purpose is to store the
// real name of the function so that esbuild can rename it to avoid collisions. There is no way to
// disable this renaming, even if esbuild minification (and thus function name mangling) is disabled.
// Anywhere we inject dynamically generated code at runtime for the browser to process,
// we must manually include this function (because esbuild only does so once at the top scope
// of the bundle, which is irrelevant for code executed in the browser).
// esbuild calls every function with a builtin `__name` (since we set keepNames: true),
// whose purpose is to store the real name of the function so that esbuild can rename it to avoid
// collisions. Anywhere we inject dynamically generated code at runtime for the browser to process,
// we must manually include this function (because esbuild only does so once at the top scope of
// the bundle, which is irrelevant for code executed in the browser).
const esbuildFunctionNameStubString = 'var __name=(fn)=>fn;';

/** @type {string} */
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"shared/localization/locales/en-US.json",
],
"exclude": [
"build/test/*test-case*.js",
"core/test/audits/**/*.js",
"core/test/fixtures/**/*.js",
"core/test/computed/**/*.js",
Expand Down