blob: 5e71019185b38519c63b54ede96e127ad5dfa5c3 [file] [log] [blame] [edit]
import * as fs from 'fs';
import * as path from 'path';
import puppeteer, { Browser } from 'puppeteer';
import { CHROME_EXECUTABLE_PATH } from './chrome_downloader/chrome_executable_path';
// File inside $ENV_DIR containing the demo page server's TCP port. Only applies to Bazel tests
// using the test_on_env rule.
const ENV_PORT_FILE_BASE_NAME = 'port';
/** A DOM event name. */
export type EventName = string;
/**
* Type of the function returned by addEventListenersToPuppeteerPage.
*
* It returns a promise that resolves when an event e of the given name is
* caught, and returns e.detail (assumed to be of type T).
*
* The generic type variable T is analogous to T in e.g. CustomEvent<T>.
*
* Note: this works for standard DOM events as well, not just custom events.
*/
export type EventPromiseFactory = <T>(eventName: EventName) => Promise<T>;
/**
* This function allows tests to catch document-level events in a Puppeteer
* page.
*
* It takes a Puppeteer page and a list of event names, and adds event listeners
* to the page's document for the given events. It must be called before the
* page is loaded with e.g. page.goto() for it to work.
*
* The returned function takes an event name in eventNames and returns a promise
* that will resolve to the corresponding Event object's "detail" field when the
* event is caught. Multiple promises for the same event will be resolved in the
* order that they were created, i.e. one caught event resolves the oldest
* pending promise.
*/
export const addEventListenersToPuppeteerPage = async (
page: puppeteer.Page,
eventNames: EventName[]
) => {
// Maps event names to FIFO queues of promise resolver functions.
const resolverFnQueues = new Map<EventName, ((evt: any) => void)[]>();
eventNames.forEach((eventName) => resolverFnQueues.set(eventName, []));
// Use an unlikely prefix to reduce chances of name collision.
await page.exposeFunction('__pptr_onEvent', (eventName: EventName, eventDetail: any) => {
const resolverFn = resolverFnQueues.get(eventName)!.shift(); // Dequeue.
if (resolverFn) {
// Undefined if queue length was 0.
resolverFn(eventDetail);
}
});
// This function will be executed inside the Puppeteer page for each of the
// events we want to listen for. It adds an event listener that will call the
// function we've exposed in the previous step.
const addEventListener = (name: EventName) => {
document.addEventListener(name, (event: Event) => {
(window as any).__pptr_onEvent(name, (event as any).detail);
});
};
// Add an event listener for each one of the given events.
const promises = eventNames.map((name) => page.evaluateOnNewDocument(addEventListener, name));
await Promise.all(promises);
// The returned function takes an event name and returns a promise that will
// resolve to the event details when the event is caught.
const eventPromiseFactory: EventPromiseFactory = (eventName: EventName) => {
if (!resolverFnQueues.has(eventName)) {
// Fail if the event wasn't included in eventNames.
throw new Error(`no event listener for "${eventName}"`);
}
return new Promise(
// Enqueue resolver function at the end of the queue.
(resolve) => resolverFnQueues.get(eventName)!.push(resolve)
);
};
return eventPromiseFactory;
};
/**
* Returns the path to the Bazel runfiles directory.
*
* See:
* - https://docs.bazel.build/versions/master/skylark/rules.html#runfiles-location
* - https://docs.bazel.build/versions/master/test-encyclopedia.html#initial-conditions
*/
const bazelRunfilesDir = () => path.join(process.env.RUNFILES_DIR!, process.env.TEST_WORKSPACE!);
/**
* Launches a Puppeteer browser. Set showBrowser to true to see the browser as it executes tests.
* This can be handy for debugging.
*/
export const launchBrowser = (showBrowser?: boolean): Promise<Browser> => {
// TODO(lovisolo): Do we need this? Can't we use //puppeteer-tests:chrome for everything?
const fontconfigSysroot = path.join(bazelRunfilesDir(), 'external', 'google_chrome');
return puppeteer.launch({
// Use the hermetically-downloaded Chrome binary, which we get via the //puppeteer-tests:chrome
// Bazel target, which in turn uses the @puppeteer/browsers NPM package.
executablePath: path.join(
bazelRunfilesDir(),
'puppeteer-tests',
'chrome',
CHROME_EXECUTABLE_PATH
),
// These options are required to run Puppeteer from within a Docker container, as is the case
// under Bazel and RBE. See
// https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md#running-puppeteer-in-docker.
//
// Flag --no-sandbox is necessary to run Puppeteer tests under Bazel locally (i.e. not on
// RBE) on a Swarming bot. If we do not provide said flag, we get the following error:
//
// No usable sandbox! Update your kernel or see
// https://chromium.googlesource.com/chromium/src/+/master/docs/linux/suid_sandbox_development.md
// for more information on developing with the SUID sandbox. If you want to live
// dangerously and need an immediate workaround, you can try using --no-sandbox.
args: ['--disable-dev-shm-usage', '--no-sandbox'],
headless: !showBrowser,
env: {
...process.env, // Headful mode breaks without this line (e.g. "unable to open X display").
FONTCONFIG_SYSROOT: fontconfigSysroot,
},
});
};
/**
* Type of the object returned by setUpPuppeteerAndDemoPageServer.
*
* A test suite should reuse this object in all its test cases. This object's
* fields will be automatically updated with a fresh page and base URL before
* each test case is executed.
*/
export interface TestBed {
page: puppeteer.Page;
baseUrl: string;
}
/**
* Returns the output directory where tests should e.g. save screenshots.
* Screenshots saved in this directory will be uploaded to Gold.
*/
export const outputDir = () => {
// Screenshots will be saved as test undeclared outputs, which will be found at
// at //_bazel_testlogs/path/to/my/puppeteer_test/test.outputs/outputs.zip. This is true when
// running on RBE as well (e.g. "bazel test --config=remote").
//
// See the following link for more:
// https://docs.bazel.build/versions/master/test-encyclopedia.html#test-interaction-with-the-filesystem.
const undeclaredOutputsDir = process.env.TEST_UNDECLARED_OUTPUTS_DIR;
if (!undeclaredOutputsDir) {
throw new Error('required environment variable TEST_UNDECLARED_OUTPUTS_DIR is unset');
}
const outputDir = path.join(undeclaredOutputsDir, 'puppeteer-test-screenshots');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir);
}
return outputDir;
};
/**
* Takes a screenshot and saves it to the tests output directory to be uploaded
* to Gold.
*
* The screenshot will be saved as <appName>_<testName>.png. Using the
* application name as a prefix prevents name collisions between different apps
* and increases consistency among test names.
*/
export function takeScreenshot(
handle: puppeteer.Page | puppeteer.ElementHandle,
appName: string,
testName: string
): Promise<Buffer | string> {
const pngPath = path.join(outputDir(), `${appName}_${testName}.png`);
// Typescript is unhappy about the type union due to the ElementHandle having a "this"
// typing. Both Page and ElementHandle have a screenshot method, so we can just
// pretend it's one of those two.
return (handle as puppeteer.Page).screenshot({ path: pngPath });
}
let browser: puppeteer.Browser;
let testBed: Partial<TestBed>;
/**
* Once per Mocha invocation, loadCachedTestBed will launch a new Puppeteer browser window to run
* the tests. On all subsequent calls, it will return essentially a cached handle to that
* invocation.
*
* Test cases can access the demo page server's base URL and a Puppeteer page ready to be used via
* the return value's baseUrl and page objects, respectively.
*
* This function assumes that each test case uses exactly one Puppeteer page (that's why it doesn't
* expose the Browser instance to tests). The page is set up with a cookie (name: "puppeteer",
* value: "true") to give demo pages a means to detect whether they are running within Puppeteer or
* not.
*
* When debugging, it can be handy to set showBrowser to true.
*/
export async function loadCachedTestBed(showBrowser?: boolean) {
if (testBed) {
return testBed as TestBed;
}
const newTestBed: Partial<TestBed> = {};
// Read the demo page server's TCP port.
const envDir = process.env.ENV_DIR; // This is set by the test_on_env Bazel rule.
if (!envDir) throw new Error('required environment variable ENV_DIR is unset');
const port = parseInt(fs.readFileSync(path.join(envDir, ENV_PORT_FILE_BASE_NAME), 'utf8'));
newTestBed.baseUrl = `http://localhost:${port}`;
if (typeof showBrowser === 'undefined') {
// The sk_element_puppeteer_test Bazel rule sets this environment variable to "true" for
// "<name>_debug_headful" targets.
showBrowser = !!process.env.PUPPETEER_TEST_SHOW_BROWSER;
}
browser = await launchBrowser(showBrowser);
testBed = newTestBed;
setBeforeAfterHooks();
return testBed as TestBed;
}
// This sets up some handy helpers to load a new page and shut it down w/o having to expose
// the puppeteer.Browser object to the callers.
function setBeforeAfterHooks() {
beforeEach(async () => {
testBed.page = await browser.newPage(); // Make page available to tests.
testBed.page.on('console', (msg) => console.log(`PAGE LOG[${msg.type()}]: ${msg.text()}`));
testBed.page.on('pageerror', (message) => console.log('PAGE ERROR: ', message));
testBed.page.on('response', (response) =>
console.log(`RESPONSE LOG[${response.status()}]: ${response.url()}`)
);
testBed.page.on('requestfailed', (request) =>
console.log(`REQUEST FAILED: [${request.url()}] ${request.failure()?.errorText}`)
);
// Tell demo pages this is a Puppeteer test. Demo pages should not fake RPC
// latency, render animations or exhibit any other non-deterministic
// behavior that could result in differences in the screenshots uploaded to
// Gold.
await testBed.page.setCookie({
url: testBed.baseUrl,
name: 'puppeteer',
value: 'true',
});
});
afterEach(async () => {
await testBed.page!.close();
});
// Shut down Puppeteer, otherwise tests will run forever, eventually timing out and failing.
after(async () => {
await browser.close();
});
}