diff --git a/package-lock.json b/package-lock.json index 60f35d6..384b9ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "node-fetch": "^3.2.10", "os-browserify": "^0.3.0", "path-browserify": "^1.0.1", - "playwright": "^1.25.1", + "playwright-core": "^1.33.0", "prettier": "npm:wp-prettier@2.0.5", "puppeteer": "^16.2.0", "rollup": "^2.78.1", @@ -12541,26 +12541,10 @@ "node": ">=8" } }, - "node_modules/playwright": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.25.1.tgz", - "integrity": "sha512-kOlW7mllnQ70ALTwAor73q/FhdH9EEXLUqjdzqioYLcSVC4n4NBfDqeCikGuayFZrLECLkU6Hcbziy/szqTXSA==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "playwright-core": "1.25.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/playwright-core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.25.1.tgz", - "integrity": "sha512-lSvPCmA2n7LawD2Hw7gSCLScZ+vYRkhU8xH0AapMyzwN+ojoDqhkH/KIEUxwNu2PjPoE/fcE0wLAksdOhJ2O5g==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.33.0.tgz", + "integrity": "sha512-aizyPE1Cj62vAECdph1iaMILpT0WUDCq3E6rW6I+dleSbBoGbktvJtzS6VHkZ4DKNEOG9qJpiom/ZxO+S15LAw==", "dev": true, "bin": { "playwright": "cli.js" diff --git a/package.json b/package.json index 2c1f61e..a82f456 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "node-fetch": "^3.2.10", "os-browserify": "^0.3.0", "path-browserify": "^1.0.1", - "playwright": "^1.25.1", + "playwright-core": "^1.33.0", "prettier": "npm:wp-prettier@2.0.5", "puppeteer": "^16.2.0", "rollup": "^2.78.1", diff --git a/src/browser-interface-playwright.ts b/src/browser-interface-playwright.ts index 23a0f69..b7bf442 100644 --- a/src/browser-interface-playwright.ts +++ b/src/browser-interface-playwright.ts @@ -1,37 +1,102 @@ import { Viewport } from './types'; import { BrowserInterface, BrowserRunnable, FetchOptions } from './browser-interface'; +import { BrowserContext, Page } from 'playwright-core'; +import { objectPromiseAll } from './object-promise-all'; -interface Page { - setViewportSize( viewport: Viewport ): Promise< void >; - // eslint-disable-next-line @typescript-eslint/ban-types - evaluate( method: string | Function, arg: Record< string, unknown > ); -} +export type Tab = { page: Page; statusCode: number | null }; +export type TabsByUrl = { [ url: string ]: Tab }; + +const PAGE_GOTO_TIMEOUT_MS = 5 * 60 * 1000; export class BrowserInterfacePlaywright extends BrowserInterface { - constructor( private pages: { [ url: string ]: Page } ) { + private tabs: TabsByUrl; + + /** + * + * @param context The playwright browser context to work with. + * @param urls Array of urls to evaluate. The reason we are taking this as an argument is because we want to load all of them in parallel. + */ + constructor( private context: BrowserContext, private urls: string[] ) { super(); } + private async getTabs() { + if ( typeof this.tabs === 'undefined' ) { + await this.openUrls( this.context, this.urls ); + } + + return this.tabs; + } + + /** + * Open an array of urls in a new browser context. + * + * Take a browser instance and an array of urls to open in new tabs. + * + * @param {BrowserContext} context - Browser context to use. + * @param {string[]} urls - Array of urls to open. + * @return {Promise< TabsByUrl >} Promise resolving to the browser context. + */ + private async openUrls( context: BrowserContext, urls: string[] ): Promise< void > { + this.tabs = await objectPromiseAll< Tab >( + urls.reduce( ( set, url ) => { + set[ url ] = this.newTab( context, url ); + return set; + }, {} ) + ); + } + + /** + * Open url in a new tab in a given browserContext. + * + * @param {BrowserContext} browserContext - Browser context to use. + * @param {string} url - Url to open. + * @return {Promise} Promise resolving to the page instance. + */ + private async newTab( browserContext: BrowserContext, url: string ): Promise< Tab > { + const tab = { + page: await browserContext.newPage(), + statusCode: null, + }; + tab.page.on( 'response', async response => { + if ( response.url() === url ) { + tab.statusCode = response.status(); + } + } ); + + await tab.page.goto( url, { timeout: PAGE_GOTO_TIMEOUT_MS } ); + + return tab; + } + async runInPage< ReturnType >( pageUrl: string, viewport: Viewport | null, method: BrowserRunnable< ReturnType >, ...args: unknown[] ): Promise< ReturnType > { - const page = this.pages[ pageUrl ]; + const tabs = await this.getTabs(); + const tab = tabs[ pageUrl ]; - if ( ! page ) { + if ( ! tab || ! tab.page ) { throw new Error( `Playwright interface does not include URL ${ pageUrl }` ); } + // Bail early if the page returned a non-200 status code. + if ( ! tab.statusCode || ! this.isOkStatus( tab.statusCode ) ) { + const error = new Error( `Page returned status code ${ tab.statusCode }` ); + this.trackUrlError( pageUrl, error ); + throw error; + } + if ( viewport ) { - await page.setViewportSize( viewport ); + await tab.page.setViewportSize( viewport ); } // The inner window in Playwright is the directly accessible main window object. // The evaluating method does not need a separate window object. // Call inner method within the Playwright context. - return page.evaluate( method, { innerWindow: null, args } ); + return tab.page.evaluate( method, { innerWindow: null, args } ); } /** @@ -47,4 +112,8 @@ export class BrowserInterfacePlaywright extends BrowserInterface { return nodeFetch.default( url, options ); } + + private isOkStatus( statusCode: number ) { + return statusCode >= 200 && statusCode < 300; + } } diff --git a/src/browser-interface.ts b/src/browser-interface.ts index 420665d..e8f81b3 100644 --- a/src/browser-interface.ts +++ b/src/browser-interface.ts @@ -40,9 +40,9 @@ export class BrowserInterface { * Context-specific wrapper for fetch; uses window.fetch in browsers, or a * node library when using Puppeteer. * - * @param _url - * @param _options - * @param _role + * @param _url + * @param _options + * @param _role */ async fetch( _url: string, diff --git a/src/object-promise-all.ts b/src/object-promise-all.ts new file mode 100644 index 0000000..4c54ed4 --- /dev/null +++ b/src/object-promise-all.ts @@ -0,0 +1,18 @@ +/** + * Given an object full of promises, resolves all of them and returns an object containing resultant values. + * Roughly equivalent of Promise.all, but applies to an object. + * + * @param { Object } object - containing promises to resolve + * @return { Object } - Promise which resolves to an object containing resultant values + */ +export async function objectPromiseAll< ValueType >( object: { + [ key: string ]: Promise< ValueType >; +} ): Promise< { [ key: string ]: ValueType } > { + const keys = Object.keys( object ); + const values = await Promise.all( keys.map( key => object[ key ] ) ); + + return keys.reduce( ( acc, key, index ) => { + acc[ key ] = values[ index ]; + return acc; + }, {} as { [ key: string ]: ValueType } ); +}