diff --git a/.github/workflows/test-functional-local-multiple-windows-na.yml b/.github/workflows/test-functional-local-multiple-windows-na.yml new file mode 100644 index 00000000000..4820675cb09 --- /dev/null +++ b/.github/workflows/test-functional-local-multiple-windows-na.yml @@ -0,0 +1,19 @@ +name: Test Functional (Multiple Windows in Native Automation mode) + +on: + workflow_dispatch: + inputs: + sha: + description: 'The test commit SHA or ref' + required: true + default: 'master' + merged_sha: + description: 'The merge commit SHA' + deploy_run_id: + description: 'The ID of a deployment workspace run with artifacts' +jobs: + test: + uses: ./.github/workflows/test-functional.yml + with: + test-script: 'npx gulp test-functional-local-multiple-windows-na-run --steps-as-tasks' + display: ':99.0' diff --git a/Gulpfile.js b/Gulpfile.js index 54096a5d8d1..7302f58815f 100644 --- a/Gulpfile.js +++ b/Gulpfile.js @@ -472,6 +472,12 @@ gulp.step('test-functional-local-multiple-windows-run', () => { gulp.task('test-functional-local-multiple-windows', gulp.series('prepare-tests', 'test-functional-local-multiple-windows-run')); +gulp.step('test-functional-local-multiple-windows-na-run', () => { + return testFunctional(MULTIPLE_WINDOWS_TESTS_GLOB, functionalTestConfig.testingEnvironmentNames.localChrome, { nativeAutomation: true }); +}); + +gulp.task('test-functional-local-multiple-windows-na', gulp.series('prepare-tests', 'test-functional-local-multiple-windows-na-run')); + gulp.step('test-functional-local-chrome-firefox-headed-run', () => { return testFunctional(HEADED_CHROME_FIREFOX_TESTS_GLOB, functionalTestConfig.testingEnvironmentNames.localBrowsersChromeFirefox); }); diff --git a/package-lock.json b/package-lock.json index e7f968bbb56..6c77a34a0e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@babel/plugin-transform-async-to-generator": "^7.22.5", "@babel/plugin-transform-exponentiation-operator": "^7.22.5", "@babel/plugin-transform-for-of": "^7.22.15", - "@babel/plugin-transform-runtime": "^7.23.2", + "@babel/plugin-transform-runtime": "7.23.3", "@babel/preset-env": "^7.23.2", "@babel/preset-flow": "^7.22.15", "@babel/preset-react": "^7.22.15", @@ -90,7 +90,7 @@ "source-map-support": "^0.5.16", "strip-bom": "^2.0.0", "testcafe-browser-tools": "2.0.26", - "testcafe-hammerhead": "31.6.4", + "testcafe-hammerhead": "31.7.0", "testcafe-legacy-api": "5.1.6", "testcafe-reporter-json": "^2.1.0", "testcafe-reporter-list": "^2.2.0", @@ -7098,9 +7098,9 @@ } }, "node_modules/esotope-hammerhead": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/esotope-hammerhead/-/esotope-hammerhead-0.6.6.tgz", - "integrity": "sha512-pHTz6hKpCotF/VDMK6UlMo6A1Y8nQsYKuZPtNKWJSKWgvJvB35m91wqeSVz4TlkxTHyIfoeIwMiffdv7Z0JCRA==", + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/esotope-hammerhead/-/esotope-hammerhead-0.6.7.tgz", + "integrity": "sha512-nejJRHWvdoymlWnAXJGm8qfaK1hQ7NiMnTQzMSHPUzBrY7Nogu8O0Q6/HcY8AvY58pkkq2loto7oDDZ0zXYQcg==", "dependencies": { "@types/estree": "0.0.46" } @@ -17567,9 +17567,10 @@ } }, "node_modules/testcafe-hammerhead": { - "version": "31.6.4", - "resolved": "https://registry.npmjs.org/testcafe-hammerhead/-/testcafe-hammerhead-31.6.4.tgz", - "integrity": "sha512-Pkr2ybw58KNUgArpj7GuV41dzHGgcFm3/uauWTKMPzhZt5k7Etat98lR+J1IPUb64FhlOxC4aADKR4VocIyqxw==", + "version": "31.7.0", + "resolved": "https://github.com/AlexKamaev/issues/raw/master/testcafe-hammerhead-31.7.0.tgz", + "integrity": "sha512-ovVj9/wPlStItl/ACzHwQRZxLQBj7kTVN+wbKGs9f+2gEEcCwhWuAVoH33Sj68OCi7RuTcZGZ1BP6WaEwJ1hjA==", + "license": "MIT", "dependencies": { "@adobe/css-tools": "^4.3.0-rc.1", "@electron/asar": "^3.2.3", @@ -17577,7 +17578,7 @@ "bowser": "1.6.0", "crypto-md5": "^1.0.0", "debug": "4.3.1", - "esotope-hammerhead": "0.6.6", + "esotope-hammerhead": "0.6.7", "http-cache-semantics": "^4.1.0", "httpntlm": "^1.8.10", "iconv-lite": "0.5.1", diff --git a/package.json b/package.json index 9cef782a32f..4b3b5c9bcb8 100644 --- a/package.json +++ b/package.json @@ -142,7 +142,7 @@ "source-map-support": "^0.5.16", "strip-bom": "^2.0.0", "testcafe-browser-tools": "2.0.26", - "testcafe-hammerhead": "31.6.4", + "testcafe-hammerhead": "31.7.0", "testcafe-legacy-api": "5.1.6", "testcafe-reporter-json": "^2.1.0", "testcafe-reporter-list": "^2.2.0", diff --git a/src/api/test-controller/index.js b/src/api/test-controller/index.js index ba84d564f10..a92269d87fc 100644 --- a/src/api/test-controller/index.js +++ b/src/api/test-controller/index.js @@ -185,7 +185,7 @@ export default class TestController { _validateMultipleWindowCommand (apiMethodName) { const { disableMultipleWindows, activeWindowId } = this.testRun; - if (this.testRun.isNativeAutomation) + if (this.testRun.isNativeAutomation && !this.testRun.isExperimentalMultipleWindows) throw new MultipleWindowsModeIsNotSupportedInNativeAutomationModeError(apiMethodName); if (disableMultipleWindows) diff --git a/src/browser/connection/gateway/index.ts b/src/browser/connection/gateway/index.ts index 0e96b6cd59a..dce64a17b48 100644 --- a/src/browser/connection/gateway/index.ts +++ b/src/browser/connection/gateway/index.ts @@ -84,6 +84,7 @@ export default class BrowserConnectionGateway extends EventEmitter { this._dispatch(`${SERVICE_ROUTES.initScript}/{id}`, proxy, BrowserConnectionGateway._onInitScriptResponse, 'POST'); this._dispatch(`${SERVICE_ROUTES.activeWindowId}/{id}`, proxy, BrowserConnectionGateway._onGetActiveWindowIdRequest, 'GET'); this._dispatch(`${SERVICE_ROUTES.activeWindowId}/{id}`, proxy, BrowserConnectionGateway._onSetActiveWindowIdRequest, 'POST'); + this._dispatch(`${SERVICE_ROUTES.ensureWindowInNativeAutomation}/{id}`, proxy, BrowserConnectionGateway._ensureWindowInNativeAutomation, 'POST'); this._dispatch(`${SERVICE_ROUTES.closeWindow}/{id}`, proxy, BrowserConnectionGateway._onCloseWindowRequest, 'POST'); this._dispatch(`${SERVICE_ROUTES.openFileProtocol}/{id}`, proxy, BrowserConnectionGateway._onOpenFileProtocolRequest, 'POST'); this._dispatch(`${SERVICE_ROUTES.dispatchNativeAutomationEvent}/{id}`, proxy, BrowserConnectionGateway._onDispatchNativeAutomationEvent, 'POST'); @@ -202,6 +203,16 @@ export default class BrowserConnectionGateway extends EventEmitter { } } + private static async _ensureWindowInNativeAutomation (req: IncomingMessage, res: ServerResponse, connection: BrowserConnection): Promise { + if (BrowserConnectionGateway._ensureConnectionReady(res, connection)) { + BrowserConnectionGateway._fetchRequestData(req, async data => { + const windowId = await connection.getNewWindowIdInNativeAutomation(JSON.parse(data).windowId); + + respondWithJSON(res, { windowId }); + }); + } + } + private static _onSetActiveWindowIdRequest (req: IncomingMessage, res: ServerResponse, connection: BrowserConnection): void { if (BrowserConnectionGateway._ensureConnectionReady(res, connection)) { BrowserConnectionGateway._fetchRequestData(req, data => { diff --git a/src/browser/connection/index.ts b/src/browser/connection/index.ts index fd78e949003..4047abf289e 100644 --- a/src/browser/connection/index.ts +++ b/src/browser/connection/index.ts @@ -35,7 +35,7 @@ import { TestRun as LegacyTestRun } from 'testcafe-legacy-api'; import { Proxy } from 'testcafe-hammerhead'; import { NextTestRunInfo, OpenBrowserAdditionalOptions } from '../../shared/types'; import { EventType } from '../../native-automation/types'; -import NativeAutomation from '../../native-automation'; +import { NativeAutomationBase } from '../../native-automation'; const getBrowserConnectionDebugScope = (id: string): string => `testcafe:browser:connection:${id}`; @@ -119,6 +119,7 @@ export default class BrowserConnection extends EventEmitter { public heartbeatUrl = ''; public statusUrl = ''; public activeWindowIdUrl = ''; + public ensureWindowInNativeAutomationUrl = ''; public closeWindowUrl = ''; public statusDoneUrl = ''; public heartbeatRelativeUrl = ''; @@ -202,6 +203,7 @@ export default class BrowserConnection extends EventEmitter { this.statusDoneRelativeUrl = `${SERVICE_ROUTES.statusDone}/${this.id}`; this.idleRelativeUrl = `${SERVICE_ROUTES.idle}/${this.id}`; this.activeWindowIdUrl = `${SERVICE_ROUTES.activeWindowId}/${this.id}`; + this.ensureWindowInNativeAutomationUrl = `${SERVICE_ROUTES.ensureWindowInNativeAutomation}/${this.id}`; this.closeWindowUrl = `${SERVICE_ROUTES.closeWindow}/${this.id}`; this.openFileProtocolRelativeUrl = `${SERVICE_ROUTES.openFileProtocol}/${this.id}`; this.dispatchNativeAutomationEventRelativeUrl = `${SERVICE_ROUTES.dispatchNativeAutomationEvent}/${this.id}`; @@ -646,11 +648,19 @@ export default class BrowserConnection extends EventEmitter { return this.provider.supportNativeAutomation(); } - public getNativeAutomation (): NativeAutomation { + public getNativeAutomation (): NativeAutomationBase { return this.provider.getNativeAutomation(this.id); } public isNativeAutomationEnabled (): boolean { return this._options.nativeAutomation; } + + async getNewWindowIdInNativeAutomation (windowId: string): Promise { + return this.provider.getNewWindowIdInNativeAutomation(this.id, windowId); + } + + public resetActiveWindowId (): void { + this.provider.resetActiveWindowId(this.id); + } } diff --git a/src/browser/connection/service-routes.ts b/src/browser/connection/service-routes.ts index 09968b3352b..d601acbd907 100644 --- a/src/browser/connection/service-routes.ts +++ b/src/browser/connection/service-routes.ts @@ -8,6 +8,7 @@ export default { idle: '/browser/idle', idleForced: '/browser/idle-forced', activeWindowId: '/browser/active-window-id', + ensureWindowInNativeAutomation: '/browser/ensure-window-in-native-automation', closeWindow: '/browser/close-window', serviceWorker: '/service-worker.js', openFileProtocol: '/browser/open-file-protocol', diff --git a/src/browser/provider/built-in/dedicated/base.js b/src/browser/provider/built-in/dedicated/base.js index bc4594c0e59..6083d201a5a 100644 --- a/src/browser/provider/built-in/dedicated/base.js +++ b/src/browser/provider/built-in/dedicated/base.js @@ -14,6 +14,10 @@ export default { return this.openedBrowsers[browserId].activeWindowId; }, + resetActiveWindowId (browserId) { + this.openedBrowsers[browserId].activeWindowId = this.openedBrowsers[browserId].nativeAutomation?.windowId; + }, + setActiveWindowId (browserId, val) { this.openedBrowsers[browserId].activeWindowId = val; }, @@ -101,10 +105,10 @@ export default { await this.resizeWindow(browserId, maximumSize.width, maximumSize.height, maximumSize.width, maximumSize.height); }, - async closeBrowserChildWindow (browserId) { + async closeBrowserChildWindow (browserId, windowId) { const runtimeInfo = this.openedBrowsers[browserId]; const browserClient = this._getBrowserProtocolClient(runtimeInfo); - return browserClient.closeBrowserChildWindow(); + return browserClient.closeBrowserChildWindow(windowId); }, }; diff --git a/src/browser/provider/built-in/dedicated/chrome/build-chrome-args.js b/src/browser/provider/built-in/dedicated/chrome/build-chrome-args.js index f438d5b0bcd..d3b53b23660 100644 --- a/src/browser/provider/built-in/dedicated/chrome/build-chrome-args.js +++ b/src/browser/provider/built-in/dedicated/chrome/build-chrome-args.js @@ -1,12 +1,14 @@ export const CONTAINERIZED_CHROME_FLAGS = ['--no-sandbox', '--disable-dev-shm-usage']; -export function buildChromeArgs ({ config, cdpPort, platformArgs, tempProfileDir, isContainerized }) { +export function buildChromeArgs ({ config, cdpPort, platformArgs, tempProfileDir, isContainerized, isNativeAutomation }) { let chromeArgs = [] .concat( cdpPort ? [`--remote-debugging-port=${cdpPort}`] : [], !config.userProfile ? [`--user-data-dir=${tempProfileDir.path}`] : [], config.headless ? ['--headless'] : [], config.userArgs ? [config.userArgs] : [], + // NOTE: we need to prevent new window blocking for multiple windows in Native Automation + isNativeAutomation ? ['--disable-popup-blocking'] : [], platformArgs ? [platformArgs] : [] ) .join(' '); diff --git a/src/browser/provider/built-in/dedicated/chrome/cdp-client/index.ts b/src/browser/provider/built-in/dedicated/chrome/cdp-client/index.ts index 69c0301d451..ca3a88f97d5 100644 --- a/src/browser/provider/built-in/dedicated/chrome/cdp-client/index.ts +++ b/src/browser/provider/built-in/dedicated/chrome/cdp-client/index.ts @@ -2,7 +2,7 @@ import { Dictionary } from '../../../../../../configuration/interfaces'; import Protocol from 'devtools-protocol'; import path from 'path'; import os from 'os'; -import remoteChrome from 'chrome-remote-interface'; +import remoteChrome, { TargetInfo } from 'chrome-remote-interface'; import debug from 'debug'; import { GET_WINDOW_DIMENSIONS_INFO_SCRIPT } from '../../../../utils/client-functions'; import WARNING_MESSAGE from '../../../../../../notifications/warning-message'; @@ -20,12 +20,25 @@ import delay from '../../../../../../utils/delay'; import StartScreencastRequest = Protocol.Page.StartScreencastRequest; import ScreencastFrameEvent = Protocol.Page.ScreencastFrameEvent; +import { NativeAutomationInitOptions } from '../../../../../../shared/types'; + +import { + NativeAutomationBase, + NativeAutomationChildWindow, + NativeAutomationMainWindow, +} from '../../../../../../native-automation'; + +import { + getActiveTab, + getFirstTab, + getTabById, + getTabs, +} + from './utils'; const DEBUG_SCOPE = (id: string): string => `testcafe:browser:provider:built-in:chrome:browser-client:${id}`; const DOWNLOADS_DIR = path.join(os.homedir(), 'Downloads'); -const DEVTOOLS_TAB_URL_REGEX = /^devtools:\/\/devtools/; - const debugLog = debug('testcafe:browser:provider:built-in:dedicated:chrome'); class ProtocolApiInfo { @@ -48,6 +61,8 @@ interface VideoFrameData { sessionId: number; } +export const NEW_WINDOW_OPENED_IN_NATIVE_AUTOMATION = 'new-window-opened-in-native-automation'; + export class BrowserClient { private _clients: Dictionary = {}; private readonly _runtimeInfo: RuntimeInfo; @@ -67,6 +82,10 @@ export class BrowserClient { this._lastFrame = null; } + private get _port (): number { + return this._runtimeInfo.cdpPort; + } + private get _clientKey (): string { return this._runtimeInfo.activeWindowId || this._runtimeInfo.browserId; } @@ -75,21 +94,6 @@ export class BrowserClient { return this._runtimeInfo.config; } - private async _getTabs (): Promise { - const tabs = await remoteChrome.List({ port: this._runtimeInfo.cdpPort }); - - return tabs.filter(t => t.type === 'page' && !DEVTOOLS_TAB_URL_REGEX.test(t.url)); - } - - private async _getActiveTab (): Promise { - let tabs = await this._getTabs(); - - if (this._runtimeInfo.activeWindowId) - tabs = tabs.filter(t => t.title.includes(this._runtimeInfo.activeWindowId)); - - return tabs[0]; - } - private _checkDropOfPerformance (method: CheckedCDPMethod, elapsedTime: [number, number]): void { this.debugLogger(`CDP method '${method}' took ${prettyTime(elapsedTime)}`); @@ -103,12 +107,12 @@ export class BrowserClient { } } - private async _createClient (): Promise { - const target = await this._getActiveTab(); - const client = await remoteChrome({ target, port: this._runtimeInfo.cdpPort }); + private async _createClient (target: TargetInfo, cacheKey: string = this._clientKey): Promise { + const client = await remoteChrome({ target, port: this._port }); + const { Page, Network, Runtime } = client; - this._clients[this._clientKey] = new ProtocolApiInfo(client); + this._clients[cacheKey] = new ProtocolApiInfo(client); await guardTimeExecution( async () => await Page.enable(), @@ -232,11 +236,8 @@ export class BrowserClient { return !!this._parentTarget && this._config.headless; } - public async setClientInactive (): Promise { - // NOTE: ensure client exists - await this.getActiveClient(); - - const client = this._clients[this._clientKey]; + public async setClientInactive (windowId: string): Promise { + const client = this._clients[windowId]; if (client) client.inactive = true; @@ -244,8 +245,11 @@ export class BrowserClient { public async getActiveClient (): Promise { try { - if (!this._clients[this._clientKey]) - await this._createClient(); + if (!this._clients[this._clientKey]) { + const target = await getActiveTab(this._port, this._runtimeInfo.activeWindowId); + + await this._createClient(target); + } } catch (err) { debugLog(err); @@ -261,9 +265,9 @@ export class BrowserClient { return info.client; } - public async init (): Promise { + public async initMainWindowCdpClient (): Promise { try { - const tabs = await this._getTabs(); + const tabs = await getTabs(this._port); this._parentTarget = tabs.find(t => t.url.includes(this._runtimeInfo.browserId)); @@ -311,7 +315,7 @@ export class BrowserClient { public async closeTab (): Promise { if (this._parentTarget) - await remoteChrome.Close({ id: this._parentTarget.id, port: this._runtimeInfo.cdpPort }); + await remoteChrome.Close({ id: this._parentTarget.id, port: this._port }); } public async updateMobileViewportSize (): Promise { @@ -328,8 +332,8 @@ export class BrowserClient { this._runtimeInfo.viewportSize.height = windowDimensions.outerHeight; } - public async closeBrowserChildWindow (): Promise { - await this.setClientInactive(); + public async closeBrowserChildWindow (windowId: string): Promise { + await this.setClientInactive(windowId); // NOTE: delay browser window closing await delay(100); @@ -387,4 +391,37 @@ export class BrowserClient { return Buffer.from(currentVideoFrame.data, 'base64'); } + + private async _onTargetCreatedHandler (targetInfo: remoteChrome.TargetInfo, options: NativeAutomationInitOptions): Promise { + const target = await getTabById(this._port, targetInfo.targetId); + + if (!target) + return null; + + try { + const client = await this._createClient(target, targetInfo.targetId); + const nativeAutomation = new NativeAutomationChildWindow(this._runtimeInfo.browserId, target.id, client, options); + + await nativeAutomation.start(); + + return target.id; + } + catch (err) { + debugLog(err); + + return null; + } + } + + public async createMainWindowNativeAutomation (options: NativeAutomationInitOptions): Promise { + const target = await getFirstTab(this._port); + const client = await this._createClient(target); + + const nativeAutomation = new NativeAutomationMainWindow(this._runtimeInfo.browserId, target.id, client, options); + + nativeAutomation.on(NEW_WINDOW_OPENED_IN_NATIVE_AUTOMATION, async targetInfo => this._onTargetCreatedHandler(targetInfo, options)); + + return nativeAutomation; + } } + diff --git a/src/browser/provider/built-in/dedicated/chrome/cdp-client/utils.ts b/src/browser/provider/built-in/dedicated/chrome/cdp-client/utils.ts new file mode 100644 index 00000000000..092ab83315c --- /dev/null +++ b/src/browser/provider/built-in/dedicated/chrome/cdp-client/utils.ts @@ -0,0 +1,30 @@ +import remoteChrome, { TargetInfo } from 'chrome-remote-interface'; + +const DEVTOOLS_TAB_URL_REGEX = /^devtools:\/\/devtools/; + +export async function getTabs (port: number): Promise { + const tabs = await remoteChrome.List({ port }); + + return tabs.filter(t => t.type === 'page' && !DEVTOOLS_TAB_URL_REGEX.test(t.url)); +} + +export async function getTabById (port: number, id: string): Promise { + const tabs = await getTabs(port); + + return tabs.find(tab => tab.id === id) as TargetInfo; +} + +export async function getFirstTab (port: number): Promise { + const tabs = await getTabs(port); + + return tabs[0]; +} + +export async function getActiveTab (port: number, activeWindowId: string): Promise { + const tabs = await getTabs(port); + + if (activeWindowId) + return tabs.find(t => t.title.includes(activeWindowId)) as TargetInfo; + + return tabs[0]; +} diff --git a/src/browser/provider/built-in/dedicated/chrome/index.js b/src/browser/provider/built-in/dedicated/chrome/index.js index 68aa97876a7..b64cd60e7ba 100644 --- a/src/browser/provider/built-in/dedicated/chrome/index.js +++ b/src/browser/provider/built-in/dedicated/chrome/index.js @@ -11,7 +11,6 @@ import { import { GET_WINDOW_DIMENSIONS_INFO_SCRIPT } from '../../../utils/client-functions'; import { BrowserClient } from './cdp-client'; import { dispatchEvent as dispatchNativeAutomationEvent, navigateTo } from '../../../../../native-automation/utils/cdp'; -import NativeAutomation from '../../../../../native-automation'; import { chromeBrowserProviderLogger } from '../../../../../utils/debug-loggers'; import { EventType } from '../../../../../native-automation/types'; import delay from '../../../../../utils/delay'; @@ -56,20 +55,19 @@ export default { this.setUserAgentMetaInfo(browserId, metaInfo, options); }, - async _setupNativeAutomation ({ browserId, browserClient, runtimeInfo, nativeAutomationOptions }) { - const cdpClient = await browserClient.getActiveClient(); - const nativeAutomation = new NativeAutomation(browserId, cdpClient, nativeAutomationOptions); + async _setupNativeAutomation ({ browserClient, runtimeInfo, nativeAutomationOptions }) { + const nativeAutomation = await browserClient.createMainWindowNativeAutomation(nativeAutomationOptions); await nativeAutomation.start(); runtimeInfo.nativeAutomation = nativeAutomation; }, - async _startChrome (runtimeInfo, pageUrl) { - if (runtimeInfo.isContainerized) - await startLocalChromeOnDocker(pageUrl, runtimeInfo); + async _startChrome (startOptions, pageUrl) { + if (startOptions.isContainerized) + await startLocalChromeOnDocker(pageUrl, startOptions); else - await startLocalChrome(pageUrl, runtimeInfo); + await startLocalChrome(pageUrl, startOptions); }, async openBrowser (browserId, pageUrl, config, additionalOptions) { @@ -85,29 +83,29 @@ export default { }; //NOTE: A not-working tab is opened when the browser start in the docker so we should create a new tab. - await this._startChrome(runtimeInfo, pageUrl); + await this._startChrome(Object.assign({ isNativeAutomation: additionalOptions.nativeAutomation }, runtimeInfo), pageUrl); await this.waitForConnectionReady(browserId); runtimeInfo.viewportSize = await this.runInitScript(browserId, GET_WINDOW_DIMENSIONS_INFO_SCRIPT); runtimeInfo.activeWindowId = null; runtimeInfo.windowDescriptors = {}; - if (!additionalOptions.disableMultipleWindows) - runtimeInfo.activeWindowId = this.calculateWindowId(); - const browserClient = new BrowserClient(runtimeInfo); this.openedBrowsers[browserId] = runtimeInfo; - await browserClient.init(); + if (additionalOptions.nativeAutomation) + await this._setupNativeAutomation({ browserId, browserClient, runtimeInfo, nativeAutomationOptions: toNativeAutomationSetupOptions(additionalOptions, config.headless) }); + + if (!additionalOptions.disableMultipleWindows) + runtimeInfo.activeWindowId = runtimeInfo?.nativeAutomation?.windowId || this.calculateWindowId(); + + await browserClient.initMainWindowCdpClient(); await this._ensureWindowIsExpanded(browserId, runtimeInfo.viewportSize); this._setUserAgentMetaInfoForEmulatingDevice(browserId, runtimeInfo.config); - if (additionalOptions.nativeAutomation) - await this._setupNativeAutomation({ browserId, browserClient, runtimeInfo, nativeAutomationOptions: toNativeAutomationSetupOptions(additionalOptions, config.headless) }); - chromeBrowserProviderLogger('browser opened %s', browserId); }, @@ -220,4 +218,10 @@ export default { return runtimeInfo.nativeAutomation; }, + + async getNewWindowIdInNativeAutomation (browserId) { + const runtimeInfo = this.openedBrowsers[browserId]; + + return runtimeInfo.nativeAutomation.getNewWindowIdInNativeAutomation(); + }, }; diff --git a/src/browser/provider/built-in/dedicated/chrome/local-chrome.js b/src/browser/provider/built-in/dedicated/chrome/local-chrome.js index 677f6f400de..15ee404c627 100644 --- a/src/browser/provider/built-in/dedicated/chrome/local-chrome.js +++ b/src/browser/provider/built-in/dedicated/chrome/local-chrome.js @@ -11,11 +11,11 @@ const browserStarter = new BrowserStarter(); const LIST_TABS_TIMEOUT = 10000; const LIST_TABS_DELAY = 500; -export async function start (pageUrl, { browserName, config, cdpPort, tempProfileDir, isContainerized }) { +export async function start (pageUrl, { browserName, config, cdpPort, tempProfileDir, isContainerized, isNativeAutomation }) { const chromeInfo = await browserTools.getBrowserInfo(config.path || browserName); const chromeOpenParameters = Object.assign({}, chromeInfo); - chromeOpenParameters.cmd = buildChromeArgs({ config, cdpPort, platformArgs: chromeOpenParameters.cmd, tempProfileDir, isContainerized }); + chromeOpenParameters.cmd = buildChromeArgs({ config, cdpPort, platformArgs: chromeOpenParameters.cmd, tempProfileDir, isContainerized, isNativeAutomation }); await browserStarter.startBrowser(chromeOpenParameters, pageUrl); } diff --git a/src/browser/provider/index.ts b/src/browser/provider/index.ts index e7526e163b5..0b71bd38509 100644 --- a/src/browser/provider/index.ts +++ b/src/browser/provider/index.ts @@ -16,7 +16,7 @@ import { WindowDimentionsInfo } from '../interfaces'; import getLocalOSInfo, { OSInfo } from 'get-os-info'; import { OpenBrowserAdditionalOptions } from '../../shared/types'; import { EventType } from '../../native-automation/types'; -import NativeAutomation from '../../native-automation'; +import { NativeAutomationBase } from '../../native-automation'; const DEBUG_LOGGER = debug('testcafe:browser:provider'); @@ -440,6 +440,17 @@ export default class BrowserProvider { return this.plugin.getActiveWindowId(browserId); } + public resetActiveWindowId (browserId: string): string | null { + if (!this.plugin.supportMultipleWindows) + return null; + + return this.plugin.resetActiveWindowId(browserId); + } + + public getNewActiveWindowId (browserId: string): string | null { + return this.plugin.getNewActiveWindowId(browserId); + } + public setActiveWindowId (browserId: string, val: string): void { this.plugin.setActiveWindowId(browserId, val); } @@ -448,8 +459,8 @@ export default class BrowserProvider { await this.plugin.openFileProtocol(browserId, url); } - public async closeBrowserChildWindow (browserId: string): Promise { - await this.plugin.closeBrowserChildWindow(browserId); + public async closeBrowserChildWindow (browserId: string, windowId: string): Promise { + await this.plugin.closeBrowserChildWindow(browserId, windowId); } public async dispatchNativeAutomationEvent (browserId: string, type: EventType, options: any): Promise { @@ -464,7 +475,11 @@ export default class BrowserProvider { return this.plugin.supportNativeAutomation(); } - public getNativeAutomation (browserId: string): NativeAutomation { + public getNativeAutomation (browserId: string): NativeAutomationBase { return this.plugin.getNativeAutomation(browserId); } + + public getNewWindowIdInNativeAutomation (browserId: string, windowId: string): Promise { + return this.plugin.getNewWindowIdInNativeAutomation(browserId, windowId); + } } diff --git a/src/browser/provider/plugin-host.js b/src/browser/provider/plugin-host.js index 66e3f81b4cd..18c5635073e 100644 --- a/src/browser/provider/plugin-host.js +++ b/src/browser/provider/plugin-host.js @@ -159,7 +159,7 @@ export default class BrowserProviderPluginHost { return value; } - async closeBrowserChildWindow (/*browserId*/) { + async closeBrowserChildWindow (/*browserId, windowId*/) { return Promise.resolve(); } @@ -174,4 +174,8 @@ export default class BrowserProviderPluginHost { getNativeAutomation (/*browserId*/) { return null; } + + getNewWindowIdInNativeAutomation (/*browserId, windowId*/) { + return Promise.resolve(); + } } diff --git a/src/cli/argument-parser/index.ts b/src/cli/argument-parser/index.ts index a34daaabfd9..5118d802a34 100644 --- a/src/cli/argument-parser/index.ts +++ b/src/cli/argument-parser/index.ts @@ -194,6 +194,7 @@ export default class CLIArgumentParser { .option('--screenshots-full-page', 'enable full-page screenshots') .option('--compiler-options ', 'specify test compilation settings') .option('--disable-multiple-windows', 'disable the multi-window mode') + .option('--experimental-multiple-windows', 'Enable experimental support for multiple windows in Native Automation mode') .option('--disable-http2', 'force the proxy to issue HTTP/1.1 requests') .option('--cache', 'cache web assets between test runs') .option('--base-url ', 'set the base url for the test run') diff --git a/src/client/browser/index.js b/src/client/browser/index.js index c66de62828d..d9fec45f88a 100644 --- a/src/client/browser/index.js +++ b/src/client/browser/index.js @@ -204,6 +204,13 @@ export function setActiveWindowId (activeWindowIdUrl, createXHR, windowId) { }); } +export function ensureWindowInNativeAutomation (ensureWindowInNativeAutomationUrl, createXHR, windowId) { + return sendXHR(ensureWindowInNativeAutomationUrl, createXHR, { + method: 'POST', + data: JSON.stringify({ windowId }), //eslint-disable-line no-restricted-globals + }); +} + export function closeWindow (closeWindowUrl, createXHR, windowId) { return sendXHR(closeWindowUrl, createXHR, { method: 'POST', diff --git a/src/client/driver/driver.js b/src/client/driver/driver.js index 703f3f5018e..fad4bf07d0a 100644 --- a/src/client/driver/driver.js +++ b/src/client/driver/driver.js @@ -266,7 +266,7 @@ export default class Driver extends serviceUtils.EventEmitter { _getCurrentWindowId () { if (this.options.nativeAutomation) - return this.runInfo.activeWindowId; + return this.runInfo.windowId; const currentUrl = window.location.toString(); const parsedProxyUrl = hammerhead.utils.url.parseProxyUrl(currentUrl); @@ -349,8 +349,8 @@ export default class Driver extends serviceUtils.EventEmitter { this.contextStorage.setItem(PENDING_PAGE_ERROR, error); } - _addChildWindowDriverLink (e) { - const childWindowDriverLink = new ChildWindowDriverLink(e.window, e.windowId); + _addChildWindowDriverLink (wnd, windowId) { + const childWindowDriverLink = new ChildWindowDriverLink(wnd, windowId); this.childWindowDriverLinks.push(childWindowDriverLink); this._ensureClosedChildWindowWatcher(); @@ -417,6 +417,9 @@ export default class Driver extends serviceUtils.EventEmitter { if (!this.options.nativeAutomation) return; + if (this.options.experimentalMultipleWindows) + return; + this._onReady(new DriverStatus({ isCommandResult: true, executionError: new MultipleWindowsModeIsNotSupportedInNativeAutomationModeError(), @@ -425,9 +428,37 @@ export default class Driver extends serviceUtils.EventEmitter { e.isPrevented = true; } - _onChildWindowOpened (e) { - this._addChildWindowDriverLink(e); - this._switchToChildWindow(e.windowId); + async _ensureNewWindowOpenedInNativeAutomation (e) { + const result = await browser.ensureWindowInNativeAutomation( + this.communicationUrls.ensureWindowInNativeAutomationUrl, + hammerhead.createNativeXHR, + ); + + if (!result.windowId) + return null; + + if (e.form) + nativeMethods.formSubmit.apply(e.form); + else + nativeMethods.windowOpen.call(window, e.pageUrl, e.windowName); + + return result.windowId; + } + + async _onChildWindowOpened (e, isOpeningInIframe) { + let windowId = e.windowId; + + if (this.options.nativeAutomation && !isOpeningInIframe) { + this._waitChildWindowOpenedInNativeAutomation = this._ensureNewWindowOpenedInNativeAutomation(e); + + windowId = await this._waitChildWindowOpenedInNativeAutomation; + + if (!windowId) + return; + } + + this._addChildWindowDriverLink(e.window, windowId); + this._switchToChildWindow(windowId); } _sendStartToRestoreCommand () { @@ -594,12 +625,18 @@ export default class Driver extends serviceUtils.EventEmitter { } _getWindowInfo () { - const parsedUrl = hammerhead.utils.url.parseProxyUrl(window.location.toString()); + let url = window.location.toString(); + + if (!this.options.nativeAutomation) { + const { destUrl } = hammerhead.utils.url.parseProxyUrl(url); + + url = destUrl; + } return { id: this.windowId, title: document.title, - url: parsedUrl.destUrl, + url, }; } @@ -833,7 +870,7 @@ export default class Driver extends serviceUtils.EventEmitter { if (this._stopRespondToChildren) return; - this._addChildWindowDriverLink({ window: wnd, windowId: msg.windowId }); + this._addChildWindowDriverLink(wnd, msg.windowId); const allChildWindowLinksRestored = this.childWindowDriverLinks.length === this.contextStorage.getItem(PENDING_CHILD_WINDOW_COUNT); @@ -870,7 +907,7 @@ export default class Driver extends serviceUtils.EventEmitter { const childWindowDriverLinkExists = !!this.childWindowDriverLinks.find(link => link.windowId === msg.windowId); if (!childWindowDriverLinkExists) - this._onChildWindowOpened({ window: wnd, windowId: msg.windowId }); + this._onChildWindowOpened({ window: wnd, windowId: msg.windowId }, true); } _handleStopInternalFromFrame (msg, wnd) { @@ -1242,6 +1279,13 @@ export default class Driver extends serviceUtils.EventEmitter { .then(driverStatus => { this.contextStorage.setItem(this.COMMAND_EXECUTING_FLAG, false); this.contextStorage.setItem(EXECUTING_SKIP_JS_ERRORS_FUNCTION_FLAG, false); + + if (this._waitChildWindowOpenedInNativeAutomation) + return this._waitChildWindowOpenedInNativeAutomation.then(() => driverStatus); + + return driverStatus; + }) + .then(driverStatus => { this._onReady(driverStatus); }); } @@ -1438,7 +1482,9 @@ export default class Driver extends serviceUtils.EventEmitter { } async _restoreChildWindowLinks () { - if (!this.contextStorage.getItem(PENDING_CHILD_WINDOW_COUNT)) + const pendingChildWindowCount = this.contextStorage.getItem(PENDING_CHILD_WINDOW_COUNT); + + if (!pendingChildWindowCount || pendingChildWindowCount === this.childWindowDriverLinks.length) return; const restoreChildWindowsPromise = new Promise(resolve => { @@ -1563,6 +1609,8 @@ export default class Driver extends serviceUtils.EventEmitter { return Promise.resolve(); return Promise.all(this.childWindowDriverLinks.map(childWindowDriverLink => { + childWindowDriverLink.ignoreMasterSwitching = true; + return childWindowDriverLink.closeAllChildWindows(); })) .then(() => { @@ -1876,7 +1924,7 @@ export default class Driver extends serviceUtils.EventEmitter { } async _getDriverRole () { - if (!this.windowId) + if (!this.windowId && !this.options.nativeAutomation) return DriverRole.master; const { activeWindowId } = await browser.getActiveWindowId(this.communicationUrls.activeWindowId, hammerhead.createNativeXHR); diff --git a/src/client/driver/generate-id.js b/src/client/driver/generate-id.js index 42784dd62bb..81b9e6e475f 100644 --- a/src/client/driver/generate-id.js +++ b/src/client/driver/generate-id.js @@ -1,5 +1,7 @@ import { nativeMethods } from './deps/hammerhead'; +// NOTE: We need the additional 'Math.random' part to ensure that the +// method does not generate identical IDs when executed within an array export default function () { - return nativeMethods.performanceNow().toString(); + return `${nativeMethods.performanceNow()}.${Math.floor(Math.random() * 100000)}`; } diff --git a/src/client/driver/iframe-driver.js b/src/client/driver/iframe-driver.js index 39bf07cf4f0..9a839339bc9 100644 --- a/src/client/driver/iframe-driver.js +++ b/src/client/driver/iframe-driver.js @@ -21,8 +21,8 @@ import { const messageSandbox = eventSandbox.message; export default class IframeDriver extends Driver { - constructor (testRunId, options) { - super(testRunId, {}, {}, options); + constructor (testRunId, communicationsUrls, options) { + super(testRunId, communicationsUrls, {}, options); this.lastParentDriverMessageId = null; this.parentDriverLink = new ParentIframeDriverLink(window.parent); @@ -39,9 +39,16 @@ export default class IframeDriver extends Driver { // NOTE: do nothing because hammerhead sends console messages to the top window directly } - // NOTE: when the new page is opened in the iframe we send a message to the top window - // to start waiting for the new page is loaded - _onChildWindowOpened () { + async _onChildWindowOpened (e) { + if (this.options.nativeAutomation) { + const windowId = await this._ensureNewWindowOpenedInNativeAutomation(e); + + if (!windowId) + return; + } + + // NOTE: when the new page is opened in the iframe we send a message to the top window + // to start waiting for the new page is loaded messageSandbox.sendServiceMsg(new ChildWindowIsOpenedInFrameMessage(), window.top); } diff --git a/src/client/test-run/iframe.js.mustache b/src/client/test-run/iframe.js.mustache index 84432d17cb1..2be0d02f826 100644 --- a/src/client/test-run/iframe.js.mustache +++ b/src/client/test-run/iframe.js.mustache @@ -1,12 +1,22 @@ (function () { + var origin = location.origin; + var experimentalMultipleWindows = {{{experimentalMultipleWindows}}} + var nativeAutomation = {{{nativeAutomation}}}; + + if (nativeAutomation) + origin = {{{domain}}}; + var IframeDriver = window['%testCafeIframeDriver%']; var driver = new IframeDriver({{{testRunId}}}, { + ensureWindowInNativeAutomationUrl: origin + {{{browserEnsureWindowInNativeAutomationUrl}}}, + }, { selectorTimeout: {{{selectorTimeout}}}, pageLoadTimeout: {{{pageLoadTimeout}}}, dialogHandler: {{{dialogHandler}}}, retryTestPages: {{{retryTestPages}}}, speed: {{{speed}}}, - nativeAutomation: {{{nativeAutomation}}}, + nativeAutomation, + experimentalMultipleWindows, }); driver.start(); diff --git a/src/client/test-run/index.js.mustache b/src/client/test-run/index.js.mustache index 794b3a83aa3..3cbf0d1d7b1 100644 --- a/src/client/test-run/index.js.mustache +++ b/src/client/test-run/index.js.mustache @@ -11,6 +11,8 @@ var testRunId = {{{testRunId}}}; var browserId = {{{browserId}}}; var activeWindowId = {{{activeWindowId}}}; + var windowId = {{{windowId}}}; + var experimentalMultipleWindows = {{{experimentalMultipleWindows}}}; var selectorTimeout = {{{selectorTimeout}}}; var pageLoadTimeout = {{{pageLoadTimeout}}}; var childWindowReadyTimeout = {{{childWindowReadyTimeout}}}; @@ -22,6 +24,8 @@ var browserIdleUrl = origin + {{{browserIdleRelativeUrl}}}; var browserOpenFileProtocolUrl = origin + {{{browserOpenFileProtocolRelativeUrl}}}; var browserActiveWindowIdUrl = origin + {{{browserActiveWindowIdUrl}}}; + var browserActiveWindowIdUrl = origin + {{{browserActiveWindowIdUrl}}}; + var browserEnsureWindowInNativeAutomationUrl = origin + {{{browserEnsureWindowInNativeAutomationUrl}}}; var browserCloseWindowUrl = origin + {{{browserCloseWindowUrl}}}; var browserDispatchNativeAutomationEventUrl = origin + {{{browserDispatchNativeAutomationEventRelativeUrl}}}; var browserDispatchNativeAutomationEventSequenceUrl = origin + {{{browserDispatchNativeAutomationEventSequenceRelativeUrl}}}; @@ -36,33 +40,36 @@ var ClientDriver = window['%testCafeDriver%']; var driver = new ClientDriver(testRunId, { - heartbeat: browserHeartbeatUrl, - status: browserStatusUrl, - statusDone: browserStatusDoneUrl, - idle: browserIdleUrl, - activeWindowId: browserActiveWindowIdUrl, - closeWindow: browserCloseWindowUrl, - openFileProtocolUrl: browserOpenFileProtocolUrl, - dispatchNativeAutomationEvent: browserDispatchNativeAutomationEventUrl, + heartbeat: browserHeartbeatUrl, + status: browserStatusUrl, + statusDone: browserStatusDoneUrl, + idle: browserIdleUrl, + activeWindowId: browserActiveWindowIdUrl, + ensureWindowInNativeAutomationUrl: browserEnsureWindowInNativeAutomationUrl, + closeWindow: browserCloseWindowUrl, + openFileProtocolUrl: browserOpenFileProtocolUrl, + dispatchNativeAutomationEvent: browserDispatchNativeAutomationEventUrl, dispatchNativeAutomationEventSequence: browserDispatchNativeAutomationEventSequenceUrl, - parseSelector: browserParseSelectorUrl, + parseSelector: browserParseSelectorUrl, }, { userAgent: userAgent, fixtureName: fixtureName, testName: testName, activeWindowId: activeWindowId, + windowId: windowId, }, { - selectorTimeout: selectorTimeout, - pageLoadTimeout: pageLoadTimeout, - childWindowReadyTimeout: childWindowReadyTimeout, - skipJsErrors: skipJsErrors, - dialogHandler: dialogHandler, - retryTestPages: retryTestPages, - speed: speed, - canUseDefaultWindowActions: canUseDefaultWindowActions, - nativeAutomation: nativeAutomation, + selectorTimeout: selectorTimeout, + pageLoadTimeout: pageLoadTimeout, + childWindowReadyTimeout: childWindowReadyTimeout, + skipJsErrors: skipJsErrors, + dialogHandler: dialogHandler, + retryTestPages: retryTestPages, + speed: speed, + canUseDefaultWindowActions: canUseDefaultWindowActions, + nativeAutomation: nativeAutomation, + experimentalMultipleWindows: experimentalMultipleWindows, } ); diff --git a/src/configuration/default-values.ts b/src/configuration/default-values.ts index 454aaa0c8da..f6f5963e3f7 100644 --- a/src/configuration/default-values.ts +++ b/src/configuration/default-values.ts @@ -15,13 +15,14 @@ export const DEFAULT_CONCURRENCY_VALUE = 1; export const DEFAULT_SOURCE_DIRECTORIES = ['tests', 'test']; -export const DEFAULT_DEVELOPMENT_MODE = false; -export const DEFAULT_RETRY_TEST_PAGES = false; -export const DEFAULT_DISABLE_HTTP2 = false; -export const DEFAULT_DISABLE_NATIVE_AUTOMATION = false; -export const DEFAULT_SCREENSHOT_THUMBNAILS = true; -export const DEFAULT_FILTER_FN = null; -export const DEFAULT_DISABLE_CROSS_DOMAIN = false; +export const DEFAULT_DEVELOPMENT_MODE = false; +export const DEFAULT_RETRY_TEST_PAGES = false; +export const DEFAULT_DISABLE_HTTP2 = false; +export const DEFAULT_DISABLE_NATIVE_AUTOMATION = false; +export const DEFAULT_EXPERIMENTAL_MULTIPLE_WINDOWS = false; +export const DEFAULT_SCREENSHOT_THUMBNAILS = true; +export const DEFAULT_FILTER_FN = null; +export const DEFAULT_DISABLE_CROSS_DOMAIN = false; export const DEFAULT_TYPESCRIPT_COMPILER_OPTIONS: Dictionary = { experimentalDecorators: true, diff --git a/src/configuration/option-names.ts b/src/configuration/option-names.ts index dc62b991810..08bf9401fee 100644 --- a/src/configuration/option-names.ts +++ b/src/configuration/option-names.ts @@ -43,6 +43,7 @@ enum OptionNames { disableScreenshots = 'disableScreenshots', debugLogger = 'debugLogger', disableMultipleWindows = 'disableMultipleWindows', + experimentalMultipleWindows = 'experimentalMultipleWindows', disableHttp2 = 'disableHttp2', disableNativeAutomation = 'disableNativeAutomation', compilerOptions = 'compilerOptions', diff --git a/src/configuration/run-option-names.ts b/src/configuration/run-option-names.ts index 61e075e433e..a4779d37bf2 100644 --- a/src/configuration/run-option-names.ts +++ b/src/configuration/run-option-names.ts @@ -22,5 +22,6 @@ export default [ OPTION_NAMES.ajaxRequestTimeout, OPTION_NAMES.retryTestPages, OPTION_NAMES.disableNativeAutomation, + OPTION_NAMES.experimentalMultipleWindows, OPTION_NAMES.baseUrl, ]; diff --git a/src/configuration/testcafe-configuration.ts b/src/configuration/testcafe-configuration.ts index 46a0d41a7b0..3c8fbdf7688 100644 --- a/src/configuration/testcafe-configuration.ts +++ b/src/configuration/testcafe-configuration.ts @@ -22,6 +22,7 @@ import { DEFAULT_SOURCE_DIRECTORIES, DEFAULT_SPEED_VALUE, DEFAULT_TIMEOUT, + DEFAULT_EXPERIMENTAL_MULTIPLE_WINDOWS, getDefaultCompilerOptions, } from './default-values'; @@ -69,6 +70,7 @@ const OPTION_INIT_FLAG_NAMES = [ OPTION_NAMES.disableHttp2, OPTION_NAMES.disableNativeAutomation, OPTION_NAMES.disableCrossDomain, + OPTION_NAMES.experimentalMultipleWindows, ]; export interface TestCafeStartOptions { @@ -270,6 +272,7 @@ export default class TestCafeConfiguration extends Configuration { this._ensureOptionWithValue(OPTION_NAMES.retryTestPages, DEFAULT_RETRY_TEST_PAGES, OptionSource.Configuration); this._ensureOptionWithValue(OPTION_NAMES.disableHttp2, DEFAULT_DISABLE_HTTP2, OptionSource.Configuration); this._ensureOptionWithValue(OPTION_NAMES.disableNativeAutomation, DEFAULT_DISABLE_NATIVE_AUTOMATION, OptionSource.Configuration); + this._ensureOptionWithValue(OPTION_NAMES.experimentalMultipleWindows, DEFAULT_EXPERIMENTAL_MULTIPLE_WINDOWS, OptionSource.Configuration); this._ensureOptionWithValue(OPTION_NAMES.disableCrossDomain, DEFAULT_DISABLE_CROSS_DOMAIN, OptionSource.Configuration); this._ensureScreenshotOptions(); diff --git a/src/native-automation/index.ts b/src/native-automation/index.ts index 6dcb1a4c752..adb991c7af4 100644 --- a/src/native-automation/index.ts +++ b/src/native-automation/index.ts @@ -5,17 +5,23 @@ import { NativeAutomationInitOptions } from '../shared/types'; import { nativeAutomationLogger } from '../utils/debug-loggers'; import SessionStorage from './session-storage'; import NativeAutomationApiBase from './api-base'; +import AsyncEventEmitter from '../utils/async-event-emitter'; +import { NEW_WINDOW_OPENED_IN_NATIVE_AUTOMATION } from '../browser/provider/built-in/dedicated/chrome/cdp-client'; -export default class NativeAutomation { - private readonly _client: ProtocolApi; +export class NativeAutomationBase extends AsyncEventEmitter { + protected readonly _client: ProtocolApi; public readonly requestPipeline; public readonly sessionStorage: SessionStorage; private readonly options: NativeAutomationInitOptions; + protected readonly windowId: string; - public constructor (browserId: string, client: ProtocolApi, options: NativeAutomationInitOptions) { + public constructor (browserId: string, windowId: string, client: ProtocolApi, options: NativeAutomationInitOptions, isMainWindow: boolean) { + super(); + + this.windowId = windowId; this._client = client; this.options = options; - this.requestPipeline = new NativeAutomationRequestPipeline(browserId, client, options); + this.requestPipeline = new NativeAutomationRequestPipeline(browserId, windowId, client, isMainWindow, options); this.sessionStorage = new SessionStorage(browserId, client, options); addCustomDebugFormatters(); @@ -65,3 +71,43 @@ export default class NativeAutomation { ]; } } + +export class NativeAutomationMainWindow extends NativeAutomationBase { + private _resolveNewWindowOpeningPromise: Promise | undefined; + + public constructor (browserId: string, windowId: string, client: ProtocolApi, options: NativeAutomationInitOptions) { + super(browserId, windowId, client, options, true); + } + + async start (): Promise { + await super.start(); + + await this._client.Target.setDiscoverTargets({ discover: true }); + + this._client.Target.on('targetCreated', async ({ targetInfo }) => { + if (targetInfo.type !== 'page' || targetInfo.targetId === this.windowId) + return; + + this._resolveNewWindowOpeningPromise = this.emit(NEW_WINDOW_OPENED_IN_NATIVE_AUTOMATION, targetInfo); + }); + } + + public async getNewWindowIdInNativeAutomation (): Promise { + if (!this._resolveNewWindowOpeningPromise) + throw new Error('Cannot get new window id'); + + return this._resolveNewWindowOpeningPromise + .then(res => { + const windowId = res[0]; + + return windowId; + }); + } +} + +export class NativeAutomationChildWindow extends NativeAutomationBase { + public constructor (browserId: string, windowId: string, client: ProtocolApi, options: NativeAutomationInitOptions) { + super(browserId, windowId, client, options, false); + } +} + diff --git a/src/native-automation/request-pipeline/index.ts b/src/native-automation/request-pipeline/index.ts index b30ed06a87b..2bd5d5a57f0 100644 --- a/src/native-automation/request-pipeline/index.ts +++ b/src/native-automation/request-pipeline/index.ts @@ -59,7 +59,6 @@ import TestRunBridge from './test-run-bridge'; import NativeAutomationRequestContextInfo from './context-info'; import { failedToFindDNSError, sslCertificateError } from '../errors'; - const ALL_REQUEST_RESPONSES = { requestStage: 'Request' } as RequestPattern; const ALL_REQUEST_REQUESTS = { requestStage: 'Response' } as RequestPattern; @@ -72,6 +71,8 @@ const TARGET_INFO_TYPE = { }; export default class NativeAutomationRequestPipeline extends NativeAutomationApiBase { + private readonly _windowId: string; + private readonly _isMainWindow: boolean; private readonly _testRunBridge: TestRunBridge; private readonly _contextInfo: NativeAutomationRequestContextInfo; public readonly requestHookEventProvider: NativeAutomationRequestHookEventProvider; @@ -84,10 +85,12 @@ export default class NativeAutomationRequestPipeline extends NativeAutomationApi private readonly _failedRequestIds: string[]; private _pendingCertificateError: CertificateErrorEvent | null; - public constructor (browserId: string, client: ProtocolApi, options: NativeAutomationInitOptions) { + public constructor (browserId: string, windowId: string, client: ProtocolApi, isMainWindow: boolean, options: NativeAutomationInitOptions) { super(browserId, client, options); - this._testRunBridge = new TestRunBridge(browserId); + this._testRunBridge = new TestRunBridge(browserId, windowId); + this._windowId = windowId; + this._isMainWindow = isMainWindow; this._contextInfo = new NativeAutomationRequestContextInfo(this._testRunBridge); this._specialServiceRoutes = this._getSpecialServiceRoutes(); this.requestHookEventProvider = new NativeAutomationRequestHookEventProvider(); @@ -430,7 +433,7 @@ export default class NativeAutomationRequestPipeline extends NativeAutomationApi const specialRequestHandler = getSpecialRequestHandler(event, this.options, this._specialServiceRoutes); if (specialRequestHandler) - await specialRequestHandler(event, this._client, this.options, sessionId); + await specialRequestHandler(event, this._client, this._isMainWindow, this.options, sessionId); else await this._handleOtherRequests(event, sessionId); }); diff --git a/src/native-automation/request-pipeline/special-handlers.ts b/src/native-automation/request-pipeline/special-handlers.ts index 00b7f7921a4..b20eafcb11b 100644 --- a/src/native-automation/request-pipeline/special-handlers.ts +++ b/src/native-automation/request-pipeline/special-handlers.ts @@ -34,7 +34,7 @@ async function handleRequestPauseEvent (event: RequestPausedEvent, client: Proto const internalRequest = { condition: (event: RequestPausedEvent): boolean => !event.networkId && event.resourceType !== 'Document' && !event.request.url, - handler: async (event: RequestPausedEvent, client: ProtocolApi, options: NativeAutomationInitOptions, sessionId: SessionId): Promise => { + handler: async (event: RequestPausedEvent, client: ProtocolApi, isMainWindow: boolean, options: NativeAutomationInitOptions, sessionId: SessionId): Promise => { requestPipelineInternalRequestLogger('%r', event); await handleRequestPauseEvent(event, client, sessionId); @@ -52,10 +52,25 @@ const serviceRequest = { return options.serviceDomains.some(domain => url.startsWith(domain)); }, - handler: async (event: RequestPausedEvent, client: ProtocolApi, options: NativeAutomationInitOptions, sessionId: SessionId): Promise => { + handler: async (event: RequestPausedEvent, client: ProtocolApi, isMainWindow: boolean, options: NativeAutomationInitOptions, sessionId: SessionId): Promise => { requestPipelineServiceRequestLogger('%r', event); - await handleRequestPauseEvent(event, client, sessionId); + try { + await handleRequestPauseEvent(event, client, sessionId); + } + catch (err) { + if (isMainWindow) { + requestPipelineServiceRequestLogger('Failed to process request in main window: %s', event.request.url); + + throw err; + } + else { + // NOTE: Sometimes, a child window sends a heartbeat request and then immediately closes. + // In these situations, we need to catch errors because we can't handle this request correctly + // when the cdpClient has already closed. + requestPipelineServiceRequestLogger('Failed to process request in child window: %s', event.request.url); + } + } }, } as RequestHandler; @@ -65,7 +80,7 @@ const defaultFaviconRequest = { return parsedUrl.pathname === DEFAULT_FAVICON_PATH; }, - handler: async (event: RequestPausedEvent, client: ProtocolApi, options: NativeAutomationInitOptions, sessionId: SessionId): Promise => { + handler: async (event: RequestPausedEvent, client: ProtocolApi, isMainWindow: boolean, options: NativeAutomationInitOptions, sessionId: SessionId): Promise => { requestPipelineLogger('%r', event); if (isRequest(event)) @@ -93,7 +108,7 @@ const SPECIAL_REQUEST_HANDLERS = [ defaultFaviconRequest, ]; -export default function getSpecialRequestHandler (event: RequestPausedEvent, options?: NativeAutomationInitOptions, serviceRoutes?: SpecialServiceRoutes): any { +export default function getSpecialRequestHandler (event: RequestPausedEvent, options?: NativeAutomationInitOptions, serviceRoutes?: SpecialServiceRoutes): RequestHandler['handler'] | null { const specialRequestHandler = SPECIAL_REQUEST_HANDLERS.find(h => h.condition(event, options, serviceRoutes)); return specialRequestHandler ? specialRequestHandler.handler : null; diff --git a/src/native-automation/request-pipeline/test-run-bridge.ts b/src/native-automation/request-pipeline/test-run-bridge.ts index b6d49a2f203..b96d03185f7 100644 --- a/src/native-automation/request-pipeline/test-run-bridge.ts +++ b/src/native-automation/request-pipeline/test-run-bridge.ts @@ -5,8 +5,10 @@ import { InjectableResourcesOptions } from '../types'; export default class TestRunBridge { private readonly _browserId: string; - public constructor (browserId: string) { + private readonly _windowId: string; + public constructor (browserId: string, windowId: string) { this._browserId = browserId; + this._windowId = windowId; } public getBrowserConnection (): BrowserConnection { @@ -53,7 +55,7 @@ export default class TestRunBridge { public async getTaskScript ({ isIframe }: InjectableResourcesOptions): Promise { const browserConnection = this.getBrowserConnection(); const proxy = browserConnection.browserConnectionGateway.proxy; - const windowId = browserConnection.activeWindowId; + const windowId = this._windowId; // @ts-ignore return await this.getCurrentTestRun().session.getTaskScript({ diff --git a/src/native-automation/types.ts b/src/native-automation/types.ts index f5d864ea396..2aa0fffb9e2 100644 --- a/src/native-automation/types.ts +++ b/src/native-automation/types.ts @@ -19,7 +19,7 @@ export interface DocumentResourceInfo { export interface RequestHandler { condition: (event: RequestPausedEvent, options?: NativeAutomationInitOptions, serviceRoutes?: SpecialServiceRoutes) => boolean; - handler: (event: RequestPausedEvent, client: ProtocolApi, options?: NativeAutomationInitOptions) => Promise; + handler: (event: RequestPausedEvent, client: ProtocolApi, isMainWindow: boolean, options?: NativeAutomationInitOptions, sessionId?: SessionId) => Promise; } export interface InjectableResourcesOptions { diff --git a/src/runner/test-run-controller.ts b/src/runner/test-run-controller.ts index 7dbff378a0a..c18d1c53937 100644 --- a/src/runner/test-run-controller.ts +++ b/src/runner/test-run-controller.ts @@ -102,6 +102,9 @@ export default class TestRunController extends AsyncEventEmitter { folderName: this.testRun.id, }); + if (this.isNativeAutomation) + connection.resetActiveWindowId(); + await this.testRun.initialize(); this._screenshots.addTestRun(this.test, this.testRun); diff --git a/src/test-run/index.ts b/src/test-run/index.ts index 24b80893aa5..45bd0d0b62e 100644 --- a/src/test-run/index.ts +++ b/src/test-run/index.ts @@ -133,7 +133,7 @@ import { } from './role-provider'; import NativeAutomationRequestPipeline from '../native-automation/request-pipeline'; -import NativeAutomation from '../native-automation'; +import { NativeAutomationBase } from '../native-automation'; import ReportDataLog from '../reporter/report-data-log'; const lazyRequire = require('import-lazy')(require); @@ -269,6 +269,7 @@ export default class TestRun extends AsyncEventEmitter { private readonly _requestHookEventProvider: RequestHookEventProvider; private readonly _roleProvider: RoleProvider; public readonly isNativeAutomation: boolean; + public readonly isExperimentalMultipleWindows: boolean; public constructor ({ test, browserConnection, screenshotCapturer, globalWarningLog, opts, messageBus, startRunExecutionTime, nativeAutomation }: TestRunInit) { super(); @@ -298,7 +299,8 @@ export default class TestRun extends AsyncEventEmitter { this.disablePageCaching = test.disablePageCaching || opts.disablePageCaching as boolean; this.isNativeAutomation = nativeAutomation; - this.disableMultipleWindows = opts.disableMultipleWindows as boolean; + this.disableMultipleWindows = opts.disableMultipleWindows as boolean; + this.isExperimentalMultipleWindows = opts.experimentalMultipleWindows as boolean; this.requestTimeout = this._getRequestTimeout(test, opts); @@ -377,7 +379,7 @@ export default class TestRun extends AsyncEventEmitter { return this._nativeAutomation.requestPipeline; } - private get _nativeAutomation (): NativeAutomation { + private get _nativeAutomation (): NativeAutomationBase { return this.browserConnection.getNativeAutomation(); } @@ -549,7 +551,7 @@ export default class TestRun extends AsyncEventEmitter { } // Hammerhead payload - public async getPayloadScript (): Promise { + public async getPayloadScript (windowId?: string): Promise { this.fileDownloadingHandled = false; this.resolveWaitForFileDownloadingPromise = null; @@ -559,11 +561,13 @@ export default class TestRun extends AsyncEventEmitter { testRunId: JSON.stringify(this.session.id), browserId: JSON.stringify(this.browserConnection.id), activeWindowId: JSON.stringify(this.activeWindowId), + windowId: JSON.stringify(windowId || ''), browserHeartbeatRelativeUrl: JSON.stringify(this.browserConnection.heartbeatRelativeUrl), browserStatusRelativeUrl: JSON.stringify(this.browserConnection.statusRelativeUrl), browserStatusDoneRelativeUrl: JSON.stringify(this.browserConnection.statusDoneRelativeUrl), browserIdleRelativeUrl: JSON.stringify(this.browserConnection.idleRelativeUrl), browserActiveWindowIdUrl: JSON.stringify(this.browserConnection.activeWindowIdUrl), + browserEnsureWindowInNativeAutomationUrl: JSON.stringify(this.browserConnection.ensureWindowInNativeAutomationUrl), browserCloseWindowUrl: JSON.stringify(this.browserConnection.closeWindowUrl), browserOpenFileProtocolRelativeUrl: JSON.stringify(this.browserConnection.openFileProtocolRelativeUrl), browserDispatchNativeAutomationEventRelativeUrl: JSON.stringify(this.browserConnection.dispatchNativeAutomationEventRelativeUrl), @@ -581,11 +585,15 @@ export default class TestRun extends AsyncEventEmitter { dialogHandler: JSON.stringify(this.activeDialogHandler), canUseDefaultWindowActions: JSON.stringify(await this.browserConnection.canUseDefaultWindowActions()), nativeAutomation: this.isNativeAutomation, + experimentalMultipleWindows: this.isExperimentalMultipleWindows, domain: JSON.stringify(this.browserConnection.browserConnectionGateway.proxy.server1Info.domain), }); } public async getIframePayloadScript (): Promise { + const browserEnsureWindowInNativeAutomationUrl = JSON.stringify(this.browserConnection.ensureWindowInNativeAutomationUrl); + const experimentalMultipleWindows = this.isExperimentalMultipleWindows; + return Mustache.render(IFRAME_TEST_RUN_TEMPLATE, { testRunId: JSON.stringify(this.session.id), selectorTimeout: this.opts.selectorTimeout, @@ -594,6 +602,9 @@ export default class TestRun extends AsyncEventEmitter { speed: this.speed, dialogHandler: JSON.stringify(this.activeDialogHandler), nativeAutomation: JSON.stringify(this.isNativeAutomation), + domain: JSON.stringify(this.browserConnection.browserConnectionGateway.proxy.server1Info.domain), + browserEnsureWindowInNativeAutomationUrl, + experimentalMultipleWindows, }); } diff --git a/src/test-run/session-controller.js b/src/test-run/session-controller.js index 9734b4d14b4..37dad02b00d 100644 --- a/src/test-run/session-controller.js +++ b/src/test-run/session-controller.js @@ -26,8 +26,8 @@ export default class SessionController extends Session { } // Hammerhead payload - async getPayloadScript () { - return this.currentTestRun.getPayloadScript(); + async getPayloadScript (windowId) { + return this.currentTestRun.getPayloadScript(windowId); } async getIframePayloadScript () { diff --git a/test/functional/fixtures/multiple-windows/pages/i4760/child.html b/test/functional/fixtures/multiple-windows/pages/i4760/child.html index 949d661904b..c178241a34d 100644 --- a/test/functional/fixtures/multiple-windows/pages/i4760/child.html +++ b/test/functional/fixtures/multiple-windows/pages/i4760/child.html @@ -13,7 +13,7 @@ window.opener.postMessage('message', '*'); window.setTimeout(function () { window.close(); - }, 100); + }, 10); }); diff --git a/test/functional/fixtures/multiple-windows/test.js b/test/functional/fixtures/multiple-windows/test.js index 118939b350d..524c009b0f8 100644 --- a/test/functional/fixtures/multiple-windows/test.js +++ b/test/functional/fixtures/multiple-windows/test.js @@ -6,6 +6,7 @@ const { GREEN_PIXEL, RED_PIXEL } = require('../../assertion-helper'); const { readPngFile } = require('../../../../lib/utils/promisified-functions'); const config = require('../../config'); const { createWarningReporter } = require('../../utils/warning-reporter'); +const { skipInNativeAutomation } = require('../../utils/skip-in'); const SCREENSHOTS_PATH = config.testScreenshotsDir; @@ -21,6 +22,22 @@ async function assertScreenshotColor (fileName, pixel) { } describe('Multiple windows', () => { + let origRunTests = null; + + before(() => { + origRunTests = global.runTests; + + global.runTests = (fixture, testName, opts = {}) => { + opts.experimentalMultipleWindows = true; + + return origRunTests.call(this, fixture, testName, opts); + }; + }); + + after(() => { + global.runTests = origRunTests; + }); + describe('Switch to the child window', () => { it('Click on link', () => { return runTests('testcafe-fixtures/switching-to-child/click-on-link.js', null, { only: 'chrome' }); @@ -81,7 +98,7 @@ describe('Multiple windows', () => { return testCafeInstance.createRunner() .browsers(`chrome:headless`) .src(fullTestPath) - .run({ disableNativeAutomation: true }); + .run({ disableNativeAutomation: !config.nativeAutomation, experimentalMultipleWindows: true }); }) .then(() => { return testCafeInstance.close(); @@ -110,7 +127,7 @@ describe('Multiple windows', () => { return runTests('testcafe-fixtures/debug-synchronization.js', null, { only: 'chrome' }); }); - it('Should make screenshots of different windows', () => { + skipInNativeAutomation('Should make screenshots of different windows', () => { return runTests('testcafe-fixtures/features/screenshots.js', null, { setScreenshotPath: true }) .then(() => { return assertScreenshotColor('0.png', RED_PIXEL); @@ -140,7 +157,7 @@ describe('Multiple windows', () => { }); }); - it('Should switch to parent and close window if the file was downloaded in separate window', () => { + skipInNativeAutomation('Should switch to parent and close window if the file was downloaded in separate window', () => { return runTests('testcafe-fixtures/i6242.js', null, { only: 'chrome' }); }); @@ -152,7 +169,7 @@ describe('Multiple windows', () => { return runTests('testcafe-fixtures/i6085.js'); }); - it('Should not hang on close window whide video is recording', () => { + skipInNativeAutomation('Should not hang on close window while video is recording', () => { return runTests('testcafe-fixtures/i6037.js', '', { setVideoPath: true, }); @@ -341,7 +358,7 @@ describe('Multiple windows', () => { }); describe('Emulation', () => { - it('Should resize window when emulating device', async () => { + skipInNativeAutomation('Should resize window when emulating device', async () => { return createTestCafe('127.0.0.1', 1335, 1336) .then(tc => { testCafeInstance = tc; @@ -382,15 +399,15 @@ describe('Multiple windows', () => { }); } - it('Resize multiple windows', () => { + skipInNativeAutomation('Resize multiple windows', () => { return runTests('testcafe-fixtures/api/api-test.js', 'Resize multiple windows'); }); - it('Maximize multiple windows', () => { + skipInNativeAutomation('Maximize multiple windows', () => { return runTests('testcafe-fixtures/api/api-test.js', 'Maximize multiple windows'); }); - it('Resize headless', () => { + skipInNativeAutomation('Resize headless', () => { return runTestsResize('firefox:headless').then(() => { return runTestsResize('chrome:headless'); }); diff --git a/test/functional/setup.js b/test/functional/setup.js index 2738fed7657..45d973c42c4 100644 --- a/test/functional/setup.js +++ b/test/functional/setup.js @@ -286,6 +286,7 @@ before(function () { disablePageReloads, disableScreenshots, disableMultipleWindows, + experimentalMultipleWindows, pageRequestTimeout, ajaxRequestTimeout, userVariables, @@ -365,6 +366,7 @@ before(function () { disablePageReloads, disableScreenshots, disableMultipleWindows, + experimentalMultipleWindows, pageRequestTimeout, ajaxRequestTimeout, userVariables, diff --git a/test/server/cli-argument-parser-test.js b/test/server/cli-argument-parser-test.js index 3fa018a2eb2..0879a28085b 100644 --- a/test/server/cli-argument-parser-test.js +++ b/test/server/cli-argument-parser-test.js @@ -6,7 +6,7 @@ const { find } = require('lodash'); const CliArgumentParser = require('../../lib/cli/argument-parser'); const { nanoid } = require('nanoid'); const runOptionNames = require('../../lib/configuration/run-option-names'); -const shouldMoveOptionToEnd = require('../../lib/cli/utils/should-move-option-to-end'); +const shouldMoveOptionToEnd = require('../../lib/cli/utils/should-move-option-to-end'); const QUARANTINE_OPTION_NAMES = require('../../lib/configuration/quarantine-option-names'); const { SKIP_JS_ERRORS_OPTIONS_OBJECT_OPTION_NAMES } = require('../../lib/configuration/skip-js-errors-option-names'); @@ -889,6 +889,7 @@ describe('CLI argument parser', function () { { long: '--base-url' }, { long: '--disable-cross-domain' }, { long: '--esm' }, + { long: '--experimental-multiple-windows' }, ]; const parser = new CliArgumentParser(''); @@ -904,7 +905,7 @@ describe('CLI argument parser', function () { expect(option.short).eql(EXPECTED_OPTIONS[i].short, CHANGE_CLI_WARNING); } - const expectedRunOptionsCount = 22; + const expectedRunOptionsCount = 23; const expectedOtherOptionsCount = 37; const otherOptionsCount = options.length - expectedRunOptionsCount;