diff --git a/package.json b/package.json index 9cdf523..9bd5740 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,6 @@ "dependencies": { "@apify/log": "^1.0.0", "fs-extra": "^9.1.0", - "lodash": "^4.17.21", "lodash.merge": "^4.6.2", "nanoid": "^3.1.22", "ow": "^0.23.0", @@ -54,7 +53,6 @@ "@apify/eslint-config-ts": "^0.1.1", "@types/fs-extra": "^9.0.11", "@types/jest": "^26.0.22", - "@types/lodash": "^4.14.172", "@types/lodash.merge": "^4.6.6", "@types/node": "^14.17.7", "@typescript-eslint/eslint-plugin": "^4.22.0", diff --git a/src/abstract-classes/browser-controller.ts b/src/abstract-classes/browser-controller.ts index 7e5e0fd..693cf3a 100644 --- a/src/abstract-classes/browser-controller.ts +++ b/src/abstract-classes/browser-controller.ts @@ -87,7 +87,7 @@ export interface Cookie { * @hideconstructor */ export abstract class BrowserController< - Library extends CommonLibrary, + Library extends CommonLibrary = CommonLibrary, LibraryOptions = Parameters[0], LaunchResult extends CommonBrowser = UnwrapPromise>, NewPageOptions = Parameters[0], diff --git a/src/abstract-classes/browser-plugin.ts b/src/abstract-classes/browser-plugin.ts index 86a9a8d..6758153 100644 --- a/src/abstract-classes/browser-plugin.ts +++ b/src/abstract-classes/browser-plugin.ts @@ -21,11 +21,14 @@ export interface CommonLibrary { name?: () => string; } -/** - * @internal - */ +/** @internal */ export interface CommonBrowser { - newPage(...args: unknown[]): unknown; + newPage(...args: unknown[]): Promise; +} + +/** @internal */ +export interface CommonPage { + close(...args: unknown[]): Promise; } export interface BrowserPluginOptions { @@ -57,7 +60,7 @@ export interface BrowserPluginOptions { export type CreateLaunchContextOptions< Library extends CommonLibrary, - LibraryOptions = Parameters[0], + LibraryOptions extends unknown = Parameters[0], LaunchResult extends CommonBrowser = UnwrapPromise>, NewPageOptions = Parameters[0], NewPageResult = UnwrapPromise>, @@ -70,8 +73,8 @@ export type CreateLaunchContextOptions< * feed them to {@link BrowserPool} for use. */ export abstract class BrowserPlugin< - Library extends CommonLibrary, - LibraryOptions = Parameters[0], + Library extends CommonLibrary = CommonLibrary, + LibraryOptions extends unknown = Parameters[0], LaunchResult extends CommonBrowser = UnwrapPromise>, NewPageOptions = Parameters[0], NewPageResult = UnwrapPromise>, @@ -137,7 +140,9 @@ export abstract class BrowserPlugin< /** * Launches the browser using provided launch context. */ - async launch(launchContext = this.createLaunchContext()): Promise { + async launch( + launchContext: LaunchContext = this.createLaunchContext(), + ): Promise { const { proxyUrl, useIncognitoPages, userDataDir } = launchContext; if (proxyUrl) { diff --git a/src/browser-pool.js b/src/browser-pool.ts similarity index 50% rename from src/browser-pool.js rename to src/browser-pool.ts index 89e0a86..7152202 100644 --- a/src/browser-pool.js +++ b/src/browser-pool.ts @@ -1,21 +1,186 @@ -const EventEmitter = require('events'); -const ow = require('ow').default; -const { nanoid } = require('nanoid'); -const { log } = require('./logger'); // eslint-disable-line import/extensions -const { addTimeoutToPromise } = require('./utils'); // eslint-disable-line import/extensions - -const { - BROWSER_POOL_EVENTS: { - BROWSER_LAUNCHED, - BROWSER_RETIRED, - PAGE_CREATED, - PAGE_CLOSED, - }, -} = require('./events'); // eslint-disable-line import/extensions +import { nanoid } from 'nanoid'; +import ow from 'ow'; +import { TypedEmitter } from 'tiny-typed-emitter'; +import { BrowserController } from './abstract-classes/browser-controller'; +import { BrowserPlugin } from './abstract-classes/browser-plugin'; +import { BROWSER_POOL_EVENTS } from './events'; +import { LaunchContext } from './launch-context'; +import { log } from './logger'; +import { addTimeoutToPromise, InferBrowserPluginArray, UnwrapPromise } from './utils'; const PAGE_CLOSE_KILL_TIMEOUT_MILLIS = 1000; const BROWSER_KILLER_INTERVAL_MILLIS = 10 * 1000; +export interface BrowserPoolEvents { + [BROWSER_POOL_EVENTS.PAGE_CREATED]: (page: Page) => void | Promise; + [BROWSER_POOL_EVENTS.PAGE_CLOSED]: (page: Page) => void | Promise; + [BROWSER_POOL_EVENTS.BROWSER_RETIRED]: (browserController: BC) => void | Promise; + [BROWSER_POOL_EVENTS.BROWSER_LAUNCHED]: (browserController: BC) => void | Promise; +} + +export interface BrowserPoolOptions { + /** + * Browser plugins are wrappers of browser automation libraries that + * allow `BrowserPool` to control browsers with those libraries. + * `browser-pool` comes with a `PuppeteerPlugin` and a `PlaywrightPlugin`. + */ + browserPlugins: readonly Plugin[]; + /** + * Sets the maximum number of pages that can be open in a browser at the + * same time. Once reached, a new browser will be launched to handle the excess. + * + * @default 20 + */ + maxOpenPagesPerBrowser?: number; + /** + * Browsers tend to get bloated after processing a lot of pages. This option + * configures the number of processed pages after which the browser will + * automatically retire and close. A new browser will launch in its place. + * + * @default 100 + */ + retireBrowserAfterPageCount?: number; + /** + * As we know from experience, async operations of the underlying libraries, + * such as launching a browser or opening a new page, can get stuck. + * To prevent `BrowserPool` from getting stuck, we add a timeout + * to those operations and you can configure it with this option. + * + * @default 15 + */ + operationTimeoutSecs?: number; + /** + * Browsers normally close immediately after their last page is processed. + * However, there could be situations where this does not happen. Browser Pool + * makes sure all inactive browsers are closed regularly, to free resources. + * + * @default 300 + */ + closeInactiveBrowserAfterSecs?: number; +} + +/** + * Pre-launch hooks are executed just before a browser is launched and provide + * a good opportunity to dynamically change the launch options. + * The hooks are called with two arguments: + * `pageId`: `string` and `launchContext`: {@link LaunchContext} + */ +export type PreLaunchHook = (pageId: string, launchContext: LC) => void | Promise; + +/** + * Post-launch hooks are executed as soon as a browser is launched. + * The hooks are called with two arguments: + * `pageId`: `string` and `browserController`: {@link BrowserController} + * To guarantee order of execution before other hooks in the same browser, + * the {@link BrowserController} methods cannot be used until the post-launch + * hooks complete. If you attempt to call `await browserController.close()` from + * a post-launch hook, it will deadlock the process. This API is subject to change. + */ +export type PostLaunchHook = (pageId: string, browserController: BC) => void | Promise; + +/** + * Pre-page-create hooks are executed just before a new page is created. They + * are useful to make dynamic changes to the browser before opening a page. + * The hooks are called with three arguments: + * `pageId`: `string`, `browserController`: {@link BrowserController} and + * `pageOptions`: `object|undefined` - This only works if the underlying `BrowserController` supports new page options. + * So far, new page options are only supported by `PlaywrightController`. + * If the page options are not supported by `BrowserController` the `pageOptions` argument is `undefined`. + */ +export type PrePageCreateHook< + BC extends BrowserController, + PO = Parameters[0] +> = (pageId: string, browserController: BC, pageOptions?: PO) => void | Promise; + +/** + * Post-page-create hooks are called right after a new page is created + * and all internal actions of Browser Pool are completed. This is the + * place to make changes to a page that you would like to apply to all + * pages. Such as injecting a JavaScript library into all pages. + * The hooks are called with two arguments: + * `page`: `Page` and `browserController`: {@link BrowserController} + */ +export type PostPageCreateHook< + BC extends BrowserController, + Page = UnwrapPromise>, +> = (page: Page, browserController: BC) => void | Promise; + +/** + * Pre-page-close hooks give you the opportunity to make last second changes + * in a page that's about to be closed, such as saving a snapshot or updating + * state. + * The hooks are called with two arguments: + * `page`: `Page` and `browserController`: {@link BrowserController} + */ +export type PrePageCloseHook< + BC extends BrowserController, + Page = UnwrapPromise>, +> = (page: Page, browserController: BC) => void | Promise; + +/** + * Post-page-close hooks allow you to do page related clean up. + * The hooks are called with two arguments: + * `pageId`: `string` and `browserController`: {@link BrowserController} + */ +export type PostPageCloseHook = (pageId: string, browserController: BC) => void | Promise; + +export interface BrowserPoolHooks< + BC extends BrowserController, + LC extends LaunchContext, + PR extends UnwrapPromise> = UnwrapPromise>, +> { + /** + * Pre-launch hooks are executed just before a browser is launched and provide + * a good opportunity to dynamically change the launch options. + * The hooks are called with two arguments: + * `pageId`: `string` and `launchContext`: {@link LaunchContext} + */ + preLaunchHooks?: PreLaunchHook[]; + /** + * Post-launch hooks are executed as soon as a browser is launched. + * The hooks are called with two arguments: + * `pageId`: `string` and `browserController`: {@link BrowserController} + * To guarantee order of execution before other hooks in the same browser, + * the {@link BrowserController} methods cannot be used until the post-launch + * hooks complete. If you attempt to call `await browserController.close()` from + * a post-launch hook, it will deadlock the process. This API is subject to change. + */ + postLaunchHooks?: PostLaunchHook[]; + /** + * Pre-page-create hooks are executed just before a new page is created. They + * are useful to make dynamic changes to the browser before opening a page. + * The hooks are called with three arguments: + * `pageId`: `string`, `browserController`: {@link BrowserController} and + * `pageOptions`: `object|undefined` - This only works if the underlying `BrowserController` supports new page options. + * So far, new page options are only supported by `PlaywrightController`. + * If the page options are not supported by `BrowserController` the `pageOptions` argument is `undefined`. + */ + prePageCreateHooks?: PrePageCreateHook[]; + /** + * Post-page-create hooks are called right after a new page is created + * and all internal actions of Browser Pool are completed. This is the + * place to make changes to a page that you would like to apply to all + * pages. Such as injecting a JavaScript library into all pages. + * The hooks are called with two arguments: + * `page`: `Page` and `browserController`: {@link BrowserController} + */ + postPageCreateHooks?: PostPageCreateHook[]; + /** + * Pre-page-close hooks give you the opportunity to make last second changes + * in a page that's about to be closed, such as saving a snapshot or updating + * state. + * The hooks are called with two arguments: + * `page`: `Page` and `browserController`: {@link BrowserController} + */ + prePageCloseHooks?: PrePageCloseHook[]; + /** + * Post-page-close hooks allow you to do page related clean up. + * The hooks are called with two arguments: + * `pageId`: `string` and `browserController`: {@link BrowserController} + */ + postPageCloseHooks?: PostPageCloseHook[]; +} + /** * The `BrowserPool` class is the most important class of the `browser-pool` module. * It manages opening and closing of browsers and their pages and its constructor @@ -62,69 +227,57 @@ const BROWSER_KILLER_INTERVAL_MILLIS = 10 * 1000; * }] * }); * ``` - * - * @param {object} options - * @param {BrowserPlugin[]} options.browserPlugins - * Browser plugins are wrappers of browser automation libraries that - * allow `BrowserPool` to control browsers with those libraries. - * `browser-pool` comes with a `PuppeteerPlugin` and a `PlaywrightPlugin`. - * @param {number} [options.maxOpenPagesPerBrowser=20] - * Sets the maximum number of pages that can be open in a browser at the - * same time. Once reached, a new browser will be launched to handle the excess. - * @param {number} [options.retireBrowserAfterPageCount=100] - * Browsers tend to get bloated after processing a lot of pages. This option - * configures the number of processed pages after which the browser will - * automatically retire and close. A new browser will launch in its place. - * @param {number} [options.operationTimeoutSecs=15] - * As we know from experience, async operations of the underlying libraries, - * such as launching a browser or opening a new page, can get stuck. - * To prevent `BrowserPool` from getting stuck, we add a timeout - * to those operations and you can configure it with this option. - * @param {number} [options.closeInactiveBrowserAfterSecs=300] - * Browsers normally close immediately after their last page is processed. - * However, there could be situations where this does not happen. Browser Pool - * makes sure all inactive browsers are closed regularly, to free resources. - * @param {function[]} [options.preLaunchHooks] - * Pre-launch hooks are executed just before a browser is launched and provide - * a good opportunity to dynamically change the launch options. - * The hooks are called with two arguments: - * `pageId`: `string` and `launchContext`: {@link LaunchContext} - * @param {function[]} [options.postLaunchHooks] - * Post-launch hooks are executed as soon as a browser is launched. - * The hooks are called with two arguments: - * `pageId`: `string` and `browserController`: {@link BrowserController} - * To guarantee order of execution before other hooks in the same browser, - * the {@link BrowserController} methods cannot be used until the post-launch - * hooks complete. If you attempt to call `await browserController.close()` from - * a post-launch hook, it will deadlock the process. This API is subject to change. - * @param {function[]} [options.prePageCreateHooks] - * Pre-page-create hooks are executed just before a new page is created. They - * are useful to make dynamic changes to the browser before opening a page. - * The hooks are called with two arguments: - * `pageId`: `string`, `browserController`: {@link BrowserController} and - * `pageOptions`: `object|undefined` - This only works if the underlying `BrowserController` supports new page options. - * So far, new page options are only supported by `PlaywrightController`. - * If the page options are not supported by `BrowserController` the `pageOptions` argument is `undefined`. - * @param {function[]} [options.postPageCreateHooks] - * Post-page-create hooks are called right after a new page is created - * and all internal actions of Browser Pool are completed. This is the - * place to make changes to a page that you would like to apply to all - * pages. Such as injecting a JavaScript library into all pages. - * The hooks are called with two arguments: - * `page`: `Page` and `browserController`: {@link BrowserController} - * @param {function[]} [options.prePageCloseHooks] - * Pre-page-close hooks give you the opportunity to make last second changes - * in a page that's about to be closed, such as saving a snapshot or updating - * state. - * The hooks are called with two arguments: - * `page`: `Page` and `browserController`: {@link BrowserController} - * @param {function[]} [options.postPageCloseHooks] - * Post-page-close hooks allow you to do page related clean up. - * The hooks are called with two arguments: - * `pageId`: `string` and `browserController`: {@link BrowserController} */ -class BrowserPool extends EventEmitter { - constructor(options = {}) { +export class BrowserPool< + Options extends BrowserPoolOptions, + BrowserPlugins extends BrowserPlugin[] = InferBrowserPluginArray, + BrowserControllerReturn extends BrowserController = ReturnType, + LaunchContextReturn extends LaunchContext = ReturnType, + PageOptions extends unknown = Parameters[0], + PageReturn extends UnwrapPromise> = UnwrapPromise>, +> extends TypedEmitter> { + browserPlugins: BrowserPlugins; + + maxOpenPagesPerBrowser: number; + + retireBrowserAfterPageCount: number; + + operationTimeoutMillis: number; + + closeInactiveBrowserAfterMillis: number; + + preLaunchHooks: PreLaunchHook[]; + + postLaunchHooks: PostLaunchHook[]; + + prePageCreateHooks: PrePageCreateHook[]; + + postPageCreateHooks: PostPageCreateHook[]; + + prePageCloseHooks: PrePageCloseHook[]; + + postPageCloseHooks: PostPageCloseHook[]; + + pageCounter = 0; + + pages = new Map(); + + pageIds = new WeakMap(); + + activeBrowserControllers = new Set(); + + retiredBrowserControllers = new Set(); + + pageToBrowserController = new WeakMap(); + + private browserKillerInterval? = setInterval( + () => this._closeInactiveRetiredBrowsers(), + BROWSER_KILLER_INTERVAL_MILLIS, + ); + + constructor(options: Options & BrowserPoolHooks) { + super(); + ow(options, ow.object.exactShape({ browserPlugins: ow.array.minLength(1), maxOpenPagesPerBrowser: ow.optional.number, @@ -153,9 +306,7 @@ class BrowserPool extends EventEmitter { postPageCloseHooks = [], } = options; - super(); - - this.browserPlugins = browserPlugins; + this.browserPlugins = browserPlugins as unknown as BrowserPlugins; this.maxOpenPagesPerBrowser = maxOpenPagesPerBrowser; this.retireBrowserAfterPageCount = retireBrowserAfterPageCount; this.operationTimeoutMillis = operationTimeoutSecs * 1000; @@ -168,42 +319,14 @@ class BrowserPool extends EventEmitter { this.postPageCreateHooks = postPageCreateHooks; this.prePageCloseHooks = prePageCloseHooks; this.postPageCloseHooks = postPageCloseHooks; - - this.pageCounter = 0; - this.pages = new Map(); - this.pageIds = new WeakMap(); - this.activeBrowserControllers = new Set(); - this.retiredBrowserControllers = new Set(); - this.pageToBrowserController = new WeakMap(); - - this.browserKillerInterval = setInterval( - () => this._closeInactiveRetiredBrowsers(), - BROWSER_KILLER_INTERVAL_MILLIS, - ); } /** * Opens a new page in one of the running browsers or launches * a new browser and opens a page there, if no browsers are active, * or their page limits have been exceeded. - * - * @param {object} options - * @param {string} [options.id] - * Assign a custom ID to the page. If you don't a random string ID - * will be generated. - * @param {object} [options.pageOptions] - * Some libraries (Playwright) allow you to open new pages with specific - * options. Use this property to set those options. - * @param {BrowserPlugin} [options.browserPlugin] - * Choose a plugin to open the page with. If none is provided, - * one of the pool's available plugins will be used. - * - * It must be one of the plugins browser pool was created with. - * If you wish to start a browser with a different configuration, - * see the `newPageInNewBrowser` function. - * @return {Promise} */ - async newPage(options = {}) { + async newPage(options: BrowserPoolNewPageOptions = {}): Promise { const { id = nanoid(), pageOptions, @@ -219,8 +342,8 @@ class BrowserPool extends EventEmitter { } let browserController = this._pickBrowserWithFreeCapacity(browserPlugin); - if (!browserController) browserController = await this._launchBrowser(id, { browserPlugin }); + return this._createPageForBrowser(id, browserController, pageOptions); } @@ -228,30 +351,8 @@ class BrowserPool extends EventEmitter { * Unlike {@link newPage}, `newPageInNewBrowser` always launches a new * browser to open the page in. Use the `launchOptions` option to * configure the new browser. - * - * @param {object} options - * @param {string} [options.id] - * Assign a custom ID to the page. If you don't a random string ID - * will be generated. - * @param {object} [options.pageOptions] - * Some libraries (Playwright) allow you to open new pages with specific - * options. Use this property to set those options. - * @param {object} [options.launchOptions] - * Options that will be used to launch the new browser. - * @param {BrowserPlugin} [options.browserPlugin] - * Provide a plugin to launch the browser. If none is provided, - * one of the pool's available plugins will be used. - * - * If you configured `BrowserPool` to rotate multiple libraries, - * such as both Puppeteer and Playwright, you should always set - * the `browserPlugin` when using the `launchOptions` option. - * - * The plugin will not be added to the list of plugins used by - * the pool. You can either use one of those, to launch a specific - * browser, or provide a completely new configuration. - * @return {Promise} */ - async newPageInNewBrowser(options = {}) { + async newPageInNewBrowser(options: BrowserPoolNewPageInNewBrowserOptions = {}): Promise { const { id = nanoid(), pageOptions, @@ -287,11 +388,10 @@ class BrowserPool extends EventEmitter { * const pages = await browserPool.newPageWithEachPlugin(); * const [chromiumPage, firefoxPage, webkitPage, puppeteerPage] = pages; * ``` - * - * @param {object[]} optionsList - * @return {Promise} */ - async newPageWithEachPlugin(optionsList = []) { + async newPageWithEachPlugin( + optionsList: Omit, 'browserPlugin'>[] = [], + ): Promise { const pagePromises = this.browserPlugins.map((browserPlugin, idx) => { const userOptions = optionsList[idx] || {}; return this.newPage({ @@ -313,10 +413,9 @@ class BrowserPool extends EventEmitter { * to control your browsers. The function returns `undefined` if the * browser is closed. * - * @param page {Page} - Browser plugin page - * @return {?BrowserController} + * @param page - Browser plugin page */ - getBrowserControllerByPage(page) { + getBrowserControllerByPage(page: PageReturn): BrowserControllerReturn | undefined { return this.pageToBrowserController.get(page); } @@ -325,11 +424,8 @@ class BrowserPool extends EventEmitter { * randomly generated one, you can use this function to retrieve * the page. If the page is no longer open, the function will * return `undefined`. - * - * @param {string} id - * @return {?Page} */ - getPage(id) { + getPage(id: string): PageReturn | undefined { return this.pages.get(id); } @@ -338,36 +434,29 @@ class BrowserPool extends EventEmitter { * events. You can use a page ID to track the full lifecycle of the page. * It is created even before a browser is launched and stays with the page * until it's closed. - * - * @param {Page} page - * @return {string} */ - getPageId(page) { + getPageId(page: PageReturn): string | undefined { return this.pageIds.get(page); } - /** - * @param {string} pageId - * @param {BrowserController} browserController - * @param {object} pageOptions - * @return {Promise} - * @private - */ - async _createPageForBrowser(pageId, browserController, pageOptions = {}) { + private async _createPageForBrowser(pageId: string, browserController: BrowserControllerReturn, pageOptions: PageOptions = {} as PageOptions) { // TODO This is needed for concurrent newPage calls to wait for the browser launch. // It's not ideal though, we need to come up with a better API. - await browserController.isActivePromise; + // eslint-disable-next-line dot-notation -- accessing private property + await browserController['isActivePromise']; const finalPageOptions = browserController.supportsPageOptions ? pageOptions : undefined; await this._executeHooks(this.prePageCreateHooks, pageId, browserController, finalPageOptions); - let page; + let page: PageReturn; + try { page = await addTimeoutToPromise( browserController.newPage(finalPageOptions), this.operationTimeoutMillis, 'browserController.newPage() timed out.', - ); + ) as PageReturn; + this.pages.set(pageId, page); this.pageIds.set(page, pageId); this.pageToBrowserController.set(page, browserController); @@ -381,43 +470,44 @@ class BrowserPool extends EventEmitter { this._overridePageClose(page); } catch (err) { this.retireBrowserController(browserController); - throw new Error(`browserController.newPage() failed: ${browserController.id}\nCause:${err.message}.`); + throw new Error(`browserController.newPage() failed: ${browserController.id}\nCause:${(err as Error).message}.`); } + await this._executeHooks(this.postPageCreateHooks, page, browserController); - this.emit(PAGE_CREATED, page); // @TODO: CONSIDER renaming this event. + + this.emit(BROWSER_POOL_EVENTS.PAGE_CREATED, page); // @TODO: CONSIDER renaming this event. + return page; } /** * Removes a browser controller from the pool. The underlying * browser will be closed after all its pages are closed. - * @param {BrowserController} browserController * */ - retireBrowserController(browserController) { + retireBrowserController(browserController: BrowserControllerReturn): void { const hasBeenRetiredOrKilled = !this.activeBrowserControllers.has(browserController); if (hasBeenRetiredOrKilled) return; this.retiredBrowserControllers.add(browserController); - this.emit(BROWSER_RETIRED, browserController); + this.emit(BROWSER_POOL_EVENTS.BROWSER_RETIRED, browserController); this.activeBrowserControllers.delete(browserController); } /** * Removes a browser from the pool. It will be * closed after all its pages are closed. - * @param {Page} page */ - retireBrowserByPage(page) { + retireBrowserByPage(page: PageReturn): void { const browserController = this.getBrowserControllerByPage(page); - this.retireBrowserController(browserController); + if (browserController) this.retireBrowserController(browserController); } /** * Removes all active browsers from the pool. The browsers will be * closed after all their pages are closed. */ - retireAllBrowsers() { + retireAllBrowsers(): void { this.activeBrowserControllers.forEach((controller) => { this.retireBrowserController(controller); }); @@ -427,55 +517,48 @@ class BrowserPool extends EventEmitter { * Closes all managed browsers without waiting for pages to close. * @return {Promise} */ - async closeAllBrowsers() { + async closeAllBrowsers(): Promise { const controllers = this._getAllBrowserControllers(); - const promises = []; + + const promises: Promise[] = []; + controllers.forEach((controller) => { promises.push(controller.close()); }); + await Promise.all(promises); } /** * Closes all managed browsers and tears down the pool. - * @return {Promise} */ - async destroy() { - this.browserKillerInterval = clearInterval(this.browserKillerInterval); + async destroy(): Promise { + clearInterval(this.browserKillerInterval!); + this.browserKillerInterval = undefined; + await this.closeAllBrowsers(); + this._teardown(); } - _teardown() { + private _teardown() { this.activeBrowserControllers.clear(); this.retiredBrowserControllers.clear(); this.removeAllListeners(); } - /** - * @return {Set} - * @private - */ - _getAllBrowserControllers() { + private _getAllBrowserControllers() { return new Set([...this.activeBrowserControllers, ...this.retiredBrowserControllers]); } - /** - * @param {string} pageId - * @param {object} options - * @param {BrowserPlugin} options.browserPlugin - * @param {object} [options.launchOptions] - * @return {Promise} - * @private - */ - async _launchBrowser(pageId, options = {}) { + private async _launchBrowser(pageId: string, options: InternalLaunchBrowserOptions) { const { browserPlugin, launchOptions, } = options; - const browserController = browserPlugin.createController(); + const browserController = browserPlugin.createController() as BrowserControllerReturn; this.activeBrowserControllers.add(browserController); const launchContext = browserPlugin.createLaunchContext({ @@ -512,42 +595,38 @@ class BrowserPool extends EventEmitter { } browserController.activate(); - this.emit(BROWSER_LAUNCHED, browserController); + this.emit(BROWSER_POOL_EVENTS.BROWSER_LAUNCHED, browserController); return browserController; } /** * Picks plugins round robin. - * @return {BrowserPlugin} * @private */ - _pickBrowserPlugin() { + private _pickBrowserPlugin() { const pluginIndex = this.pageCounter % this.browserPlugins.length; this.pageCounter++; return this.browserPlugins[pluginIndex]; } - /** - * @param {BrowserPlugin} browserPlugin - * @return {BrowserController} - * @private - */ - _pickBrowserWithFreeCapacity(browserPlugin) { - return Array.from(this.activeBrowserControllers.values()) - .find((controller) => { - // TODO if you synchronously trigger a lot of page launches, controller.activePages - // will not get updated because the picks are done before the newPage launches. - // Not sure if it's a problem, let's monitor it. - const hasCapacity = controller.activePages < this.maxOpenPagesPerBrowser; - const isCorrectPlugin = controller.browserPlugin === browserPlugin; - return hasCapacity && isCorrectPlugin; - }); + private _pickBrowserWithFreeCapacity(browserPlugin: BrowserPlugin) { + for (const controller of this.activeBrowserControllers) { + // TODO if you synchronously trigger a lot of page launches, controller.activePages + // will not get updated because the picks are done before the newPage launches. + // Not sure if it's a problem, let's monitor it. + const hasCapacity = controller.activePages < this.maxOpenPagesPerBrowser; + const isCorrectPlugin = controller.browserPlugin === browserPlugin; + if (hasCapacity && isCorrectPlugin) { + return controller; + } + } + return undefined; } - async _closeInactiveRetiredBrowsers() { - const closedBrowserIds = []; + private async _closeInactiveRetiredBrowsers() { + const closedBrowserIds: string[] = []; this.retiredBrowserControllers.forEach((controller) => { const millisSinceLastPageOpened = Date.now() - controller.lastPageOpenedAt; @@ -571,45 +650,36 @@ class BrowserPool extends EventEmitter { } } - /** - * @param {Page} page - * @private - */ - _overridePageClose(page) { + private _overridePageClose(page: PageReturn) { const originalPageClose = page.close; - const browserController = this.pageToBrowserController.get(page); - const pageId = this.getPageId(page); + const browserController = this.pageToBrowserController.get(page)!; + const pageId = this.getPageId(page)!; - page.close = async (...args) => { + page.close = async (...args: unknown[]) => { await this._executeHooks(this.prePageCloseHooks, page, browserController); + await originalPageClose.apply(page, args) - .catch((err) => { + .catch((err: Error) => { log.debug(`Could not close page.\nCause:${err.message}`, { id: browserController.id }); }); + await this._executeHooks(this.postPageCloseHooks, pageId, browserController); - this.pages.delete(this.getPageId(page)); + + this.pages.delete(pageId); this._closeRetiredBrowserWithNoPages(browserController); - this.emit(PAGE_CLOSED, page); + + this.emit(BROWSER_POOL_EVENTS.PAGE_CLOSED, page); }; } - /** - * @param {function[]} hooks - * @param {...*} args - * @return {Promise} - * @private - */ - async _executeHooks(hooks, ...args) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async _executeHooks(hooks: ((...args: any[]) => unknown)[], ...args: unknown[]) { for (const hook of hooks) { await hook(...args); } } - /** - * @param {BrowserController} browserController - * @private - */ - _closeRetiredBrowserWithNoPages(browserController) { + private _closeRetiredBrowserWithNoPages(browserController: BrowserControllerReturn) { if (browserController.activePages === 0 && this.retiredBrowserControllers.has(browserController)) { // Run this with a delay, otherwise page.close() // might fail with "Protocol error (Target.closeTarget): Target closed." @@ -622,4 +692,59 @@ class BrowserPool extends EventEmitter { } } -module.exports = BrowserPool; +export interface BrowserPoolNewPageOptions { + /** + * Assign a custom ID to the page. If you don't a random string ID + * will be generated. + */ + id?: string; + /** + * Some libraries (Playwright) allow you to open new pages with specific + * options. Use this property to set those options. + */ + pageOptions?: PageOptions; + /** + * Choose a plugin to open the page with. If none is provided, + * one of the pool's available plugins will be used. + * + * It must be one of the plugins browser pool was created with. + * If you wish to start a browser with a different configuration, + * see the `newPageInNewBrowser` function. + */ + browserPlugin?: BP; +} + +export interface BrowserPoolNewPageInNewBrowserOptions { + /** + * Assign a custom ID to the page. If you don't a random string ID + * will be generated. + */ + id?: string; + /** + * Some libraries (Playwright) allow you to open new pages with specific + * options. Use this property to set those options. + */ + pageOptions?: PageOptions; + /** + * Provide a plugin to launch the browser. If none is provided, + * one of the pool's available plugins will be used. + * + * If you configured `BrowserPool` to rotate multiple libraries, + * such as both Puppeteer and Playwright, you should always set + * the `browserPlugin` when using the `launchOptions` option. + * + * The plugin will not be added to the list of plugins used by + * the pool. You can either use one of those, to launch a specific + * browser, or provide a completely new configuration. + */ + browserPlugin?: BP; + /** + * Options that will be used to launch the new browser. + */ + launchOptions?: BP['launchOptions']; +} + +interface InternalLaunchBrowserOptions { + browserPlugin: BP; + launchOptions?: BP['launchOptions']; +} diff --git a/src/index.js b/src/index.ts similarity index 54% rename from src/index.js rename to src/index.ts index 1c54fc1..6095292 100644 --- a/src/index.js +++ b/src/index.ts @@ -1,9 +1,3 @@ -const BrowserPool = require('./browser-pool'); -// eslint-disable-next-line import/extensions -const { PuppeteerPlugin } = require('./puppeteer/puppeteer-plugin'); -// eslint-disable-next-line import/extensions -const { PlaywrightPlugin } = require('./playwright/playwright-plugin'); - /** * The `browser-pool` module exports three constructors. One for `BrowserPool` * itself and two for the included Puppeteer and Playwright plugins. @@ -26,13 +20,8 @@ const { PlaywrightPlugin } = require('./playwright/playwright-plugin'); * }); * ``` * - * @property {BrowserPool} BrowserPool - * @property {PuppeteerPlugin} PuppeteerPlugin - * @property {PlaywrightPlugin} PlaywrightPlugin * @module browser-pool */ -module.exports = { - BrowserPool, - PuppeteerPlugin, - PlaywrightPlugin, -}; +export * from './browser-pool'; +export * from './playwright/playwright-plugin'; +export * from './puppeteer/puppeteer-plugin'; diff --git a/src/launch-context.ts b/src/launch-context.ts index f931651..ecad5db 100644 --- a/src/launch-context.ts +++ b/src/launch-context.ts @@ -12,7 +12,7 @@ import { UnwrapPromise } from './utils'; * values, such as session IDs. */ export interface LaunchContextOptions< - Library extends CommonLibrary, + Library extends CommonLibrary = CommonLibrary, LibraryOptions = Parameters[0], LaunchResult extends CommonBrowser = UnwrapPromise>, NewPageOptions = Parameters[0], @@ -47,7 +47,7 @@ export interface LaunchContextOptions< } export class LaunchContext< - Library extends CommonLibrary, + Library extends CommonLibrary = CommonLibrary, LibraryOptions = Parameters[0], LaunchResult extends CommonBrowser = UnwrapPromise>, NewPageOptions = Parameters[0], diff --git a/src/utils.ts b/src/utils.ts index d29b03f..ad454e2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,6 @@ +import type { PlaywrightPlugin, PuppeteerPlugin } from '.'; +import type { BrowserPlugin } from './abstract-classes/browser-plugin'; + export function addTimeoutToPromise(promise: Promise, timeoutMillis: number, errorMessage: string): Promise { return new Promise(async (resolve, reject) => { // eslint-disable-line const timeout = setTimeout(() => reject(new Error(errorMessage)), timeoutMillis); @@ -16,3 +19,36 @@ export type UnwrapPromise = T extends PromiseLike ? UnwrapPromise // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars export function noop(..._args: unknown[]): void {} + +export type InferBrowserPluginArray< + // The original array input + Input extends readonly unknown[], + // The results of this type + Result extends BrowserPlugin[] = [] +> = + // If the input is a tuple or a readonly array (`[] as const`), get the first and the rest of the values + Input extends readonly [infer FirstValue, ...infer Rest] | [infer FirstValue, ...infer Rest] + // If the first value is a PlaywrightPlugin + ? FirstValue extends PlaywrightPlugin + // Add it to the result, and continue parsing + ? InferBrowserPluginArray + // Else if the first value is a PuppeteerPlugin + : FirstValue extends PuppeteerPlugin + // Add it to the result, and continue parsing + ? InferBrowserPluginArray + // Return never as it isn't a valid type + : never + // If there's no more inputs to parse + : Input extends [] + // Return the results + ? Result + // If the input is a general array of elements (not a tuple), infer it's values type + : Input extends Array + // If the values are a union of the plugins + ? [U] extends [PuppeteerPlugin | PlaywrightPlugin] + // Return an array of the union + ? U[] + // Return never as it isn't a valid type + : never + // Return the result + : Result; diff --git a/test/browser-plugins/plugins.test.js b/test/browser-plugins/plugins.test.ts similarity index 76% rename from test/browser-plugins/plugins.test.js rename to test/browser-plugins/plugins.test.ts index 7c72b87..e67eb93 100644 --- a/test/browser-plugins/plugins.test.js +++ b/test/browser-plugins/plugins.test.ts @@ -1,30 +1,36 @@ -/* eslint-disable import/extensions */ -const puppeteer = require('puppeteer'); -const playwright = require('playwright'); -const fs = require('fs'); +import puppeteer from 'puppeteer'; +import playwright from 'playwright'; +import fs from 'fs'; -const { PuppeteerPlugin } = require('../../src/puppeteer/puppeteer-plugin'); -const { PuppeteerController } = require('../../src/puppeteer/puppeteer-controller'); +import { PuppeteerPlugin } from '../../src/puppeteer/puppeteer-plugin'; +import { PuppeteerController } from '../../src/puppeteer/puppeteer-controller'; -const { PlaywrightPlugin } = require('../../src/playwright/playwright-plugin'); -const { PlaywrightController } = require('../../src/playwright/playwright-controller'); -const { Browser } = require('../../src/playwright/browser'); -const { LaunchContext } = require('../../src/launch-context'); // eslint-disable-line import/extensions +import { PlaywrightPlugin, PlaywrightPluginBrowsers } from '../../src/playwright/playwright-plugin'; +import { PlaywrightController } from '../../src/playwright/playwright-controller'; +import { Browser } from '../../src/playwright/browser'; + +import { LaunchContext } from '../../src/launch-context'; +import { UnwrapPromise } from '../../src/utils'; +import { CommonLibrary } from '../../src/abstract-classes/browser-plugin'; jest.setTimeout(120000); -const runPluginTest = (Plugin, Controller, library) => { - let plugin = new Plugin(library); +const runPluginTest = < + P extends typeof PlaywrightPlugin | typeof PuppeteerPlugin, + C extends typeof PuppeteerController | typeof PlaywrightController, + L extends CommonLibrary, +>(Plugin: P, Controller: C, library: L) => { + let plugin = new Plugin(library as never); + + describe(`${plugin.constructor.name} - ${'name' in library ? library.name!() : ''} general`, () => { + let browser: PlaywrightPluginBrowsers | UnwrapPromise> | undefined; - describe(`${plugin.constructor.name} - ${library.name ? library.name() : ''} general`, () => { - let browser; beforeEach(() => { - plugin = new Plugin(library); + plugin = new Plugin(library as never); }); + afterEach(async () => { - if (browser) { - await browser.close(); - } + await browser?.close(); }); test('should launch browser', async () => { @@ -39,6 +45,7 @@ const runPluginTest = (Plugin, Controller, library) => { const proxyUrl = 'http://proxy.com/'; const context = plugin.createLaunchContext({ id, + // @ts-expect-error Testing options launchOptions, }); @@ -64,18 +71,18 @@ const runPluginTest = (Plugin, Controller, library) => { expect(context.id).toEqual(desiredObject.id); expect(context.launchOptions).toEqual(desiredObject.launchOptions); expect(context.browserPlugin).toEqual(desiredObject.browserPlugin); - expect(context._proxyUrl).toEqual(desiredObject._proxyUrl); // eslint-disable-line + expect(context['_proxyUrl']).toEqual(desiredObject._proxyUrl); // eslint-disable-line expect(context.one).toEqual(desiredObject.one); expect(context.useIncognitoPages).toEqual(desiredObject.useIncognitoPages); }); test('should create userDatadir', async () => { - plugin = new Plugin(library, { + plugin = new Plugin(library as never, { useIncognitoPages: false, }); - const context = await plugin.createLaunchContext(); - browser = await plugin.launch(context); + const context = plugin.createLaunchContext(); + browser = await plugin.launch(context as never); expect(fs.existsSync(context.userDataDir)).toBeTruthy(); await browser.close(); @@ -83,13 +90,17 @@ const runPluginTest = (Plugin, Controller, library) => { test('should get default launchContext values from plugin options', async () => { const proxyUrl = 'http://apify1234@10.10.10.0:8080/'; - plugin = new Plugin(library, { + + plugin = new Plugin(library as never, { proxyUrl, userDataDir: 'test', useIncognitoPages: true, }); + // @ts-expect-error Private function jest.spyOn(plugin, '_getAnonymizedProxyUrl'); - const context = await plugin.createLaunchContext(); + + const context = plugin.createLaunchContext(); + expect(context.proxyUrl).toEqual(proxyUrl); expect(context.useIncognitoPages).toBeTruthy(); expect(context.userDataDir).toEqual('test'); @@ -102,24 +113,27 @@ const runPluginTest = (Plugin, Controller, library) => { test('should work with cookies', async () => { const browserController = plugin.createController(); - const context = await plugin.createLaunchContext(); - browser = await plugin.launch(context); - browserController.assignBrowser(browser, context); + const context = plugin.createLaunchContext(); + + browser = await plugin.launch(context as never); + + browserController.assignBrowser(browser as never, context as never); browserController.activate(); const page = await browserController.newPage(); - await browserController.setCookies(page, [{ name: 'TEST', value: 'TESTER-COOKIE', url: 'https://example.com' }]); + await browserController.setCookies(page as never, [{ name: 'TEST', value: 'TESTER-COOKIE', url: 'https://example.com' }]); await page.goto('https://example.com', { waitUntil: 'domcontentloaded' }); - const cookies = await browserController.getCookies(page); + const cookies = await browserController.getCookies(page as never); expect(cookies[0].name).toBe('TEST'); expect(cookies[0].value).toBe('TESTER-COOKIE'); }); }); }; + describe('Plugins', () => { describe('Puppeteer specifics', () => { - let browser; + let browser: puppeteer.Browser; afterEach(async () => { await browser.close(); @@ -128,27 +142,33 @@ describe('Plugins', () => { test('should work with non authenticated proxyUrl', async () => { const proxyUrl = 'http://10.10.10.0:8080'; const plugin = new PuppeteerPlugin(puppeteer); + + // @ts-expect-error Private function jest.spyOn(plugin, '_getAnonymizedProxyUrl'); - const context = await plugin.createLaunchContext({ proxyUrl }); + + const context = plugin.createLaunchContext({ proxyUrl }); browser = await plugin.launch(context); - const argWithProxy = context.launchOptions.args.find((arg) => arg.includes('--proxy-server=')); + const argWithProxy = context.launchOptions?.args?.find((arg) => arg.includes('--proxy-server=')); - expect(argWithProxy.includes(proxyUrl)).toBeTruthy(); - expect(plugin._getAnonymizedProxyUrl).not.toBeCalled(); // eslint-disable-line + expect(argWithProxy?.includes(proxyUrl)).toBeTruthy(); + expect(plugin['_getAnonymizedProxyUrl']).not.toBeCalled(); // eslint-disable-line }); test('should work with authenticated proxyUrl', async () => { const proxyUrl = 'http://apify1234@10.10.10.0:8080'; + const plugin = new PuppeteerPlugin(puppeteer); + // @ts-expect-error Private function jest.spyOn(plugin, '_getAnonymizedProxyUrl'); - const context = await plugin.createLaunchContext({ proxyUrl }); + + const context = plugin.createLaunchContext({ proxyUrl }); browser = await plugin.launch(context); - const argWithProxy = context.launchOptions.args.find((arg) => arg.includes('--proxy-server=')); + const argWithProxy = context.launchOptions?.args?.find((arg) => arg.includes('--proxy-server=')); - expect(argWithProxy.includes(context.anonymizedProxyUrl)).toBeTruthy(); - expect(plugin._getAnonymizedProxyUrl).toBeCalled(); // eslint-disable-line + expect(argWithProxy?.includes(context.anonymizedProxyUrl as string)).toBeTruthy(); + expect(plugin['_getAnonymizedProxyUrl']).toBeCalled(); // eslint-disable-line }); test('should use persistent context by default', async () => { @@ -187,7 +207,7 @@ describe('Plugins', () => { const plugin = new PuppeteerPlugin(puppeteer); jest.spyOn(plugin.library, 'launch'); - const launchOptions = { + const launchOptions: Record = { foo: 'bar', }; const launchContext = plugin.createLaunchContext({ launchOptions }); @@ -200,33 +220,39 @@ describe('Plugins', () => { runPluginTest(PuppeteerPlugin, PuppeteerController, puppeteer); describe('Playwright specifics', () => { - let browser; + let browser: PlaywrightPluginBrowsers; afterEach(async () => { await browser.close(); }); - describe.each(['chromium', 'firefox', 'webkit'])('with %s', (browserName) => { + describe.each(['chromium', 'firefox', 'webkit'] as const)('with %s', (browserName) => { test('should work with non authenticated proxyUrl', async () => { const proxyUrl = 'http://10.10.10.0:8080'; const plugin = new PlaywrightPlugin(playwright[browserName]); + + // @ts-expect-error Private function jest.spyOn(plugin, '_getAnonymizedProxyUrl'); - const context = await plugin.createLaunchContext({ proxyUrl }); + + const context = plugin.createLaunchContext({ proxyUrl }); browser = await plugin.launch(context); - expect(context.launchOptions.proxy.server).toEqual(proxyUrl); - expect(plugin._getAnonymizedProxyUrl).not.toBeCalled(); // eslint-disable-line + expect(context.launchOptions!.proxy!.server).toEqual(proxyUrl); + expect(plugin['_getAnonymizedProxyUrl']).not.toBeCalled(); // eslint-disable-line }); test('should work with authenticated proxyUrl', async () => { const proxyUrl = 'http://apify1234:password@10.10.10.0:8080'; const plugin = new PlaywrightPlugin(playwright[browserName]); + + // @ts-expect-error Private function jest.spyOn(plugin, '_getAnonymizedProxyUrl'); - const context = await plugin.createLaunchContext({ proxyUrl }); + + const context = plugin.createLaunchContext({ proxyUrl }); browser = await plugin.launch(context); - expect(context.launchOptions.proxy.server).toEqual(context.anonymizedProxyUrl); - expect(plugin._getAnonymizedProxyUrl).toBeCalled(); // eslint-disable-line + expect(context.launchOptions!.proxy!.server).toEqual(context.anonymizedProxyUrl); + expect(plugin['_getAnonymizedProxyUrl']).toBeCalled(); // eslint-disable-line }); test('should use incognito context by option', async () => { @@ -257,7 +283,7 @@ describe('Plugins', () => { browserController.activate(); const page = await browserController.newPage(); - const context = await page.context(); + const context = page.context(); await browserController.newPage(); expect(context.pages()).toHaveLength(3); // 3 pages because of the about:blank. @@ -267,13 +293,14 @@ describe('Plugins', () => { const plugin = new PlaywrightPlugin(playwright[browserName]); jest.spyOn(plugin.library, 'launch'); - const launchOptions = { + const launchOptions: Record = { foo: 'bar', }; const launchContext = plugin.createLaunchContext({ launchOptions, useIncognitoPages: true }); browser = await plugin.launch(launchContext); expect(plugin.library.launch).toHaveBeenCalledWith(launchOptions); }); + describe('Browser', () => { test('should create new page', async () => { const plugin = new PlaywrightPlugin(playwright[browserName]); @@ -337,7 +364,7 @@ describe('Plugins', () => { browser = await plugin.launch(launchContext); const contexts = browser.contexts(); expect(contexts).toHaveLength(1); - expect(contexts[0]).toEqual(browser.browserContext); + expect(contexts[0]).toEqual((browser as Browser).browserContext); }); test('should return correct connected status', async () => { @@ -364,7 +391,7 @@ describe('Plugins', () => { test('should have same public interface as playwright browserType', async () => { const plugin = new PlaywrightPlugin(playwright[browserName]); - const originalFunctionNames = ['close', 'contexts', 'isConnected', 'newContext', 'newPage', 'version']; + const originalFunctionNames = ['close', 'contexts', 'isConnected', 'newContext', 'newPage', 'version'] as const; const launchContext = plugin.createLaunchContext({ useIncognitoPages: true }); browser = await plugin.launch(launchContext); diff --git a/test/browser-pool.test.js b/test/browser-pool.test.ts similarity index 70% rename from test/browser-pool.test.js rename to test/browser-pool.test.ts index 9515f5f..0064ab0 100644 --- a/test/browser-pool.test.js +++ b/test/browser-pool.test.ts @@ -1,23 +1,18 @@ -/* eslint-disable import/extensions */ -const puppeteer = require('puppeteer'); -const playwright = require('playwright'); -const BrowserPool = require('../src/browser-pool'); -const { PuppeteerPlugin } = require('../src/puppeteer/puppeteer-plugin'); -const { PlaywrightPlugin } = require('../src/playwright/playwright-plugin'); -const { - BROWSER_POOL_EVENTS: { - BROWSER_LAUNCHED, - BROWSER_RETIRED, - PAGE_CREATED, - PAGE_CLOSED, - }, -} = require('../src/events'); // eslint-disable-line import/extensions +/* eslint-disable dot-notation -- Accessing private properties */ +import puppeteer from 'puppeteer'; +import playwright from 'playwright'; +import { BrowserPool, PrePageCreateHook } from '../src/browser-pool'; +import { PuppeteerPlugin } from '../src/puppeteer/puppeteer-plugin'; +import { PlaywrightPlugin } from '../src/playwright/playwright-plugin'; +import { BROWSER_POOL_EVENTS } from '../src/events'; +import { BrowserController } from '../src/abstract-classes/browser-controller'; +import { PlaywrightController } from '../src/playwright/playwright-controller'; // Tests could be generated from this blueprint for each plugin describe('BrowserPool', () => { const puppeteerPlugin = new PuppeteerPlugin(puppeteer); const playwrightPlugin = new PlaywrightPlugin(playwright.chromium); // chromium is faster than firefox and webkit - let browserPool; + let browserPool: BrowserPool<{ browserPlugins: [typeof puppeteerPlugin, typeof playwrightPlugin], closeInactiveBrowserAfterSecs: 2 }>; beforeEach(async () => { jest.clearAllMocks(); @@ -28,21 +23,21 @@ describe('BrowserPool', () => { }); afterEach(async () => { - browserPool = await browserPool.destroy(); + await browserPool?.destroy(); }); describe('Initialization & retirement', () => { test('should retire browsers', async () => { await browserPool.newPage(); - await browserPool.retireAllBrowsers(); + browserPool.retireAllBrowsers(); expect(browserPool.activeBrowserControllers.size).toBe(0); expect(browserPool.retiredBrowserControllers.size).toBe(1); }); test('should destroy pool', async () => { const page = await browserPool.newPage(); - const browserController = await browserPool.getBrowserControllerByPage(page); + const browserController = browserPool.getBrowserControllerByPage(page)!; jest.spyOn(browserController, 'close'); await browserPool.destroy(); @@ -50,7 +45,7 @@ describe('BrowserPool', () => { expect(browserController.close).toHaveBeenCalled(); expect(browserPool.activeBrowserControllers.size).toBe(0); expect(browserPool.retiredBrowserControllers.size).toBe(0); - expect(browserPool.browserKillerInterval).toBeUndefined(); + expect(browserPool['browserKillerInterval']).toBeUndefined(); }); }); @@ -64,28 +59,31 @@ describe('BrowserPool', () => { }); test('should open new page in incognito context', async () => { - browserPool = new BrowserPool({ - browserPlugins: [new PlaywrightPlugin(playwright.chromium, { useIncognitoPages: true })], + const browserPoolIncognito = new BrowserPool({ + browserPlugins: [new PlaywrightPlugin(playwright.chromium, { useIncognitoPages: true })] as const, closeInactiveBrowserAfterSecs: 2, }); - const page = await browserPool.newPage(); - await browserPool.newPage(); - await browserPool.newPage(); - expect(await page.context().pages()).toHaveLength(1); + const page = await browserPoolIncognito.newPage(); + await browserPoolIncognito.newPage(); + await browserPoolIncognito.newPage(); + + expect(page.context().pages()).toHaveLength(1); }); test('should open page in correct browser plugin', async () => { let page = await browserPool.newPage({ browserPlugin: playwrightPlugin, }); - let controller = browserPool.getBrowserControllerByPage(page); + + let controller = browserPool.getBrowserControllerByPage(page)!; expect(controller.launchContext.browserPlugin).toBe(playwrightPlugin); page = await browserPool.newPage({ browserPlugin: puppeteerPlugin, }); - controller = browserPool.getBrowserControllerByPage(page); + + controller = browserPool.getBrowserControllerByPage(page)!; expect(controller.launchContext.browserPlugin).toBe(puppeteerPlugin); expect(browserPool.activeBrowserControllers.size).toBe(2); }); @@ -105,7 +103,7 @@ describe('BrowserPool', () => { expect(pages).toHaveLength(correctPluginOrder.length); expect(browserPool.activeBrowserControllers.size).toBe(2); pages.forEach((page, idx) => { - const controller = browserPool.getBrowserControllerByPage(page); + const controller = browserPool.getBrowserControllerByPage(page)!; const { browserPlugin } = controller.launchContext; const correctPlugin = correctPluginOrder[idx]; expect(browserPlugin).toBe(correctPlugin); @@ -127,8 +125,8 @@ describe('BrowserPool', () => { test('newPageWithEachPlugin should open all pages', async () => { const [puppeteerPage, playwrightPage] = await browserPool.newPageWithEachPlugin(); - const puppeteerController = browserPool.getBrowserControllerByPage(puppeteerPage); - const playwrightController = browserPool.getBrowserControllerByPage(playwrightPage); + const puppeteerController = browserPool.getBrowserControllerByPage(puppeteerPage)!; + const playwrightController = browserPool.getBrowserControllerByPage(playwrightPage)!; expect(puppeteerController.launchContext.browserPlugin).toBe(puppeteerPlugin); expect(playwrightController.launchContext.browserPlugin).toBe(playwrightPlugin); }); @@ -152,13 +150,14 @@ describe('BrowserPool', () => { }); test('should correctly override page close', async () => { - jest.spyOn(browserPool, '_overridePageClose'); + // @ts-expect-error Private function + jest.spyOn(browserPool!, '_overridePageClose'); const page = await browserPool.newPage(); - expect(browserPool._overridePageClose).toBeCalled(); // eslint-disable-line + expect(browserPool['_overridePageClose']).toBeCalled(); - const controller = browserPool.getBrowserControllerByPage(page); + const controller = browserPool.getBrowserControllerByPage(page)!; expect(controller.activePages).toEqual(1); expect(controller.totalPages).toEqual(1); @@ -182,12 +181,13 @@ describe('BrowserPool', () => { expect(browserPool.activeBrowserControllers.size).toBe(1); expect(browserPool.retiredBrowserControllers.size).toBe(1); - expect(browserPool.retireBrowserController).toBeCalledTimes(1); // eslint-disable-line + expect(browserPool.retireBrowserController).toBeCalledTimes(1); }); test('should allow max pages per browser', async () => { browserPool.maxOpenPagesPerBrowser = 1; - jest.spyOn(browserPool, '_launchBrowser'); + // @ts-expect-error Private function + jest.spyOn(browserPool!, '_launchBrowser'); await browserPool.newPage(); expect(browserPool.activeBrowserControllers.size).toBe(1); @@ -196,61 +196,69 @@ describe('BrowserPool', () => { await browserPool.newPage(); expect(browserPool.activeBrowserControllers.size).toBe(3); - expect(browserPool._launchBrowser).toBeCalledTimes(3); // eslint-disable-line + expect(browserPool['_launchBrowser']).toBeCalledTimes(3); }); test('should close retired browsers', async () => { browserPool.retireBrowserAfterPageCount = 1; - clearInterval(browserPool.browserKillerInterval); - browserPool.browserKillerInterval = setInterval( - () => browserPool._closeInactiveRetiredBrowsers(), // eslint-disable-line + + clearInterval(browserPool['browserKillerInterval']!); + + browserPool['browserKillerInterval'] = setInterval( + () => browserPool['_closeInactiveRetiredBrowsers'](), 100, ); - jest.spyOn(browserPool, '_closeRetiredBrowserWithNoPages'); + + // @ts-expect-error Private function + jest.spyOn(browserPool!, '_closeRetiredBrowserWithNoPages'); expect(browserPool.retiredBrowserControllers.size).toBe(0); const page = await browserPool.newPage(); - const controller = await browserPool.getBrowserControllerByPage(page); + const controller = browserPool.getBrowserControllerByPage(page)!; jest.spyOn(controller, 'close'); expect(browserPool.retiredBrowserControllers.size).toBe(1); await page.close(); - await new Promise((resolve) => setTimeout(() => { + await new Promise((resolve) => setTimeout(() => { resolve(); }, 1000)); - expect(browserPool._closeRetiredBrowserWithNoPages).toHaveBeenCalled(); //eslint-disable-line + expect(browserPool['_closeRetiredBrowserWithNoPages']).toHaveBeenCalled(); expect(controller.close).toHaveBeenCalled(); expect(browserPool.retiredBrowserControllers.size).toBe(0); }); describe('hooks', () => { test('should run hooks in series with custom args', async () => { - const indexArray = []; - const createAsyncHookReturningIndex = (i) => async () => { - const index = await new Promise((resolve) => setTimeout(() => resolve(i), 100)); + const indexArray: number[] = []; + const createAsyncHookReturningIndex = (i: number) => async () => { + const index = await new Promise((resolve) => setTimeout(() => resolve(i), 100)); indexArray.push(index); }; + const hooks = new Array(10); for (let i = 0; i < hooks.length; i++) { hooks[i] = createAsyncHookReturningIndex(i); } - await browserPool._executeHooks(hooks); // eslint-disable-line + + await browserPool['_executeHooks'](hooks); expect(indexArray).toHaveLength(10); indexArray.forEach((v, index) => expect(v).toEqual(index)); }); describe('preLaunchHooks', () => { test('should evaluate hook before launching browser with correct args', async () => { - const myAsyncHook = () => Promise.resolve({}); + const myAsyncHook = () => Promise.resolve(); browserPool.preLaunchHooks = [myAsyncHook]; - jest.spyOn(browserPool, '_executeHooks'); + + // @ts-expect-error Private function + jest.spyOn(browserPool!, '_executeHooks'); const page = await browserPool.newPage(); - const pageId = await browserPool.getPageId(page); - const { launchContext } = browserPool.getBrowserControllerByPage(page); - expect(browserPool._executeHooks).toHaveBeenNthCalledWith(1, browserPool.preLaunchHooks, pageId, launchContext); // eslint-disable-line + const pageId = browserPool.getPageId(page)!; + const { launchContext } = browserPool.getBrowserControllerByPage(page)!; + expect(browserPool['_executeHooks']).toHaveBeenNthCalledWith(1, browserPool.preLaunchHooks, pageId, launchContext); }); // We had a problem where if the first newPage() call, which launches @@ -267,7 +275,7 @@ describe('BrowserPool', () => { try { await browserPool.newPage(); } catch (err) { - expect(err.message).toBe(errorMessage); + expect((err as Error).message).toBe(errorMessage); } } @@ -278,14 +286,17 @@ describe('BrowserPool', () => { describe('postLaunchHooks', () => { test('should evaluate hook after launching browser with correct args', async () => { - const myAsyncHook = () => Promise.resolve({}); + const myAsyncHook = () => Promise.resolve(); browserPool.postLaunchHooks = [myAsyncHook]; + + // @ts-expect-error Private function jest.spyOn(browserPool, '_executeHooks'); const page = await browserPool.newPage(); - const pageId = await browserPool.getPageId(page); - const browserController = browserPool.getBrowserControllerByPage(page); - expect(browserPool._executeHooks) // eslint-disable-line no-underscore-dangle + const pageId = browserPool.getPageId(page)!; + const browserController = browserPool.getBrowserControllerByPage(page)!; + + expect(browserPool['_executeHooks']) .toHaveBeenNthCalledWith(2, browserPool.postLaunchHooks, pageId, browserController); }); @@ -294,9 +305,9 @@ describe('BrowserPool', () => { // in limbo and subsequent newPage() calls would never resolve. test('error in hook does not leave browser stuck in limbo', async () => { const errorMessage = 'post-launch failed'; - const controllers = []; + const controllers: BrowserController[] = []; browserPool.postLaunchHooks = [ - async (pageId, browserController) => { + async (_pageId, browserController) => { controllers.push(browserController); throw new Error(errorMessage); }, @@ -307,13 +318,13 @@ describe('BrowserPool', () => { try { await browserPool.newPage(); } catch (err) { - expect(err.message).toBe(errorMessage); + expect((err as Error).message).toBe(errorMessage); } } // Wait until all browsers are closed. This will only resolve if all close, // if it does not resolve, the test will timeout and fail. - await new Promise((resolve) => { + await new Promise((resolve) => { const int = setInterval(() => { const stillWaiting = controllers.some((c) => c.isActive === true); if (!stillWaiting) { @@ -330,64 +341,89 @@ describe('BrowserPool', () => { describe('prePageCreateHooks', () => { test('should evaluate hook after launching browser with correct args', async () => { - const myAsyncHook = () => Promise.resolve({}); + const myAsyncHook = () => Promise.resolve(); browserPool.prePageCreateHooks = [myAsyncHook]; + + // @ts-expect-error Private function jest.spyOn(browserPool, '_executeHooks'); const page = await browserPool.newPage(); - const pageId = browserPool.getPageId(page); - const browserController = browserPool.getBrowserControllerByPage(page); - expect(browserPool._executeHooks).toHaveBeenNthCalledWith(3, browserPool.prePageCreateHooks, pageId, browserController, browserController.supportsPageOptions? {} : undefined); // eslint-disable-line + const pageId = browserPool.getPageId(page)!; + const browserController = browserPool.getBrowserControllerByPage(page)!; + + expect(browserPool['_executeHooks']).toHaveBeenNthCalledWith( + 3, + browserPool.prePageCreateHooks, + pageId, + browserController, + browserController.supportsPageOptions ? {} : undefined, + ); }); test('should allow changing pageOptions only when supported', async () => { - let browserController; - let options; - const myAsyncHook = (pageId, controller, pageOptions) => { - pageOptions.customOption = 'TEST'; + let browserController!: PlaywrightController; + let options: Parameters[0]; + + const myAsyncHook: PrePageCreateHook = (_pageId, controller, pageOptions) => { + // @ts-expect-error Custom option test + pageOptions!.customOption = 'TEST'; options = pageOptions; + jest.spyOn(controller, 'newPage'); + browserController = controller; }; - browserPool.prePageCreateHooks = [myAsyncHook]; - browserPool.browserPlugins = [new PlaywrightPlugin(playwright.chromium)]; - jest.spyOn(browserPool, '_executeHooks'); - await browserPool.newPage(); + const testPool = new BrowserPool({ + browserPlugins: [new PlaywrightPlugin(playwright.chromium)] as const, + prePageCreateHooks: [myAsyncHook], + }); + + // @ts-expect-error Private function + jest.spyOn(testPool, '_executeHooks'); + + await testPool.newPage(); expect(browserController.newPage).toHaveBeenCalledWith(options); }); }); describe('postPageCreateHooks', () => { test('should evaluate hook after launching browser with correct args', async () => { - const myAsyncHook = () => Promise.resolve({}); + const myAsyncHook = () => Promise.resolve(); browserPool.postPageCreateHooks = [myAsyncHook]; + + // @ts-expect-error Private function jest.spyOn(browserPool, '_executeHooks'); const page = await browserPool.newPage(); const browserController = browserPool.getBrowserControllerByPage(page); - expect(browserPool._executeHooks).toHaveBeenNthCalledWith(4, browserPool.postPageCreateHooks, page, browserController); // eslint-disable-line + + expect(browserPool['_executeHooks']).toHaveBeenNthCalledWith(4, browserPool.postPageCreateHooks, page, browserController); }); }); describe('prePageCloseHooks', () => { test('should evaluate hook after launching browser with correct args', async () => { - const myAsyncHook = () => Promise.resolve({}); + const myAsyncHook = () => Promise.resolve(); browserPool.prePageCloseHooks = [myAsyncHook]; + + // @ts-expect-error Private function jest.spyOn(browserPool, '_executeHooks'); const page = await browserPool.newPage(); await page.close(); const browserController = browserPool.getBrowserControllerByPage(page); - expect(browserPool._executeHooks).toHaveBeenNthCalledWith(5, browserPool.prePageCloseHooks, page, browserController); // eslint-disable-line + expect(browserPool['_executeHooks']).toHaveBeenNthCalledWith(5, browserPool.prePageCloseHooks, page, browserController); }); }); describe('postPageCloseHooks', () => { test('should evaluate hook after launching browser with correct args', async () => { - const myAsyncHook = () => Promise.resolve({}); + const myAsyncHook = () => Promise.resolve(); browserPool.postPageCloseHooks = [myAsyncHook]; + + // @ts-expect-error Private function jest.spyOn(browserPool, '_executeHooks'); const page = await browserPool.newPage(); @@ -395,18 +431,18 @@ describe('BrowserPool', () => { await page.close(); const browserController = browserPool.getBrowserControllerByPage(page); - expect(browserPool._executeHooks).toHaveBeenNthCalledWith(6, browserPool.postPageCloseHooks, pageId, browserController); // eslint-disable-line + expect(browserPool['_executeHooks']).toHaveBeenNthCalledWith(6, browserPool.postPageCloseHooks, pageId, browserController); }); }); }); describe('events', () => { - test(`should emit ${BROWSER_LAUNCHED} event`, async () => { + test(`should emit ${BROWSER_POOL_EVENTS.BROWSER_LAUNCHED} event`, async () => { browserPool.maxOpenPagesPerBrowser = 1; let calls = 0; let argument; - browserPool.on(BROWSER_LAUNCHED, (arg) => { + browserPool.on(BROWSER_POOL_EVENTS.BROWSER_LAUNCHED, (arg) => { argument = arg; calls++; }); @@ -417,11 +453,11 @@ describe('BrowserPool', () => { expect(argument).toEqual(browserPool.getBrowserControllerByPage(page)); }); - test(`should emit ${BROWSER_RETIRED} event`, async () => { + test(`should emit ${BROWSER_POOL_EVENTS.BROWSER_RETIRED} event`, async () => { browserPool.retireBrowserAfterPageCount = 1; let calls = 0; let argument; - browserPool.on(BROWSER_RETIRED, (arg) => { + browserPool.on(BROWSER_POOL_EVENTS.BROWSER_RETIRED, (arg) => { argument = arg; calls++; }); @@ -433,10 +469,10 @@ describe('BrowserPool', () => { expect(argument).toEqual(browserPool.getBrowserControllerByPage(page)); }); - test(`should emit ${PAGE_CREATED} event`, async () => { + test(`should emit ${BROWSER_POOL_EVENTS.PAGE_CREATED} event`, async () => { let calls = 0; let argument; - browserPool.on(PAGE_CREATED, (arg) => { + browserPool.on(BROWSER_POOL_EVENTS.PAGE_CREATED, (arg) => { argument = arg; calls++; }); @@ -448,10 +484,10 @@ describe('BrowserPool', () => { expect(argument).toEqual(page2); }); - test(`should emit ${PAGE_CLOSED} event`, async () => { + test(`should emit ${BROWSER_POOL_EVENTS.PAGE_CLOSED} event`, async () => { let calls = 0; let argument; - browserPool.on(PAGE_CLOSED, (arg) => { + browserPool.on(BROWSER_POOL_EVENTS.PAGE_CLOSED, (arg) => { argument = arg; calls++; }); diff --git a/test/index.test.js b/test/index.test.js deleted file mode 100644 index 96c2c9d..0000000 --- a/test/index.test.js +++ /dev/null @@ -1,14 +0,0 @@ -/* eslint-disable import/extensions */ -const BrowserPool = require('../src/browser-pool'); -const { PuppeteerPlugin } = require('../src/puppeteer/puppeteer-plugin'); -const { PlaywrightPlugin } = require('../src/playwright/playwright-plugin'); - -const modules = require('../src/index'); - -describe('Exports', () => { - test('Modules', () => { - expect(modules.BrowserPool).toEqual(BrowserPool); - expect(modules.PuppeteerPlugin).toEqual(PuppeteerPlugin); - expect(modules.PlaywrightPlugin).toEqual(PlaywrightPlugin); - }); -}); diff --git a/test/index.test.ts b/test/index.test.ts new file mode 100644 index 0000000..082f8cf --- /dev/null +++ b/test/index.test.ts @@ -0,0 +1,13 @@ +import { BrowserPool } from '../src/browser-pool'; +import { PuppeteerPlugin } from '../src/puppeteer/puppeteer-plugin'; +import { PlaywrightPlugin } from '../src/playwright/playwright-plugin'; + +import * as modules from '../src/index'; + +describe('Exports', () => { + test('Modules', () => { + expect(modules.BrowserPool).toEqual(BrowserPool); + expect(modules.PuppeteerPlugin).toEqual(PuppeteerPlugin); + expect(modules.PlaywrightPlugin).toEqual(PlaywrightPlugin); + }); +});