| 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(); |
| }); |
| } |