diff --git a/.github/workflows/deploy-to-artifacts.yml b/.github/workflows/deploy-to-artifacts.yml index c36f05a8860..ef01eec074a 100644 --- a/.github/workflows/deploy-to-artifacts.yml +++ b/.github/workflows/deploy-to-artifacts.yml @@ -169,6 +169,7 @@ jobs: tasks.push('test-functional-local-firefox.yml'); tasks.push('test-functional-local-ie.yml'); tasks.push('test-functional-local-multiple-windows.yml'); + tasks.push('test-functional-local-proxyless.yml'); tasks.push('test-functional-local-legacy.yml'); diff --git a/.github/workflows/test-functional-local-proxyless.yml b/.github/workflows/test-functional-local-proxyless.yml new file mode 100644 index 00000000000..28d32668d77 --- /dev/null +++ b/.github/workflows/test-functional-local-proxyless.yml @@ -0,0 +1,102 @@ +name: Test Functional (Local Chrome proxyless) + +on: + workflow_dispatch: + inputs: + sha: + desciption: '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: + runs-on: ubuntu-latest + environment: test-functional + env: + RETRY_FAILED_TESTS: true + steps: + - uses: actions/github-script@v3 + with: + script: | + await github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.payload.inputs.sha, + context: context.workflow, + state: 'pending', + target_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + }); + - uses: actions/checkout@v2 + with: + ref: ${{github.event.inputs.merged_sha || github.event.inputs.sha}} + + - uses: actions/setup-node@v2 + with: + node-version: 12 + + - uses: actions/github-script@v3 + with: + script: | + const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + + let artifacts = {}; + + for(let i = 0;i<36&&!artifacts.total_count;i++,await delay(5000)) { + try { + ({ data: artifacts } = await github.actions.listWorkflowRunArtifacts({ + repo: context.repo.repo, + owner: context.repo.owner, + run_id: context.payload.inputs.deploy_run_id + })); + } + catch (e) { + console.log(e); + } + } + + const { data: artifact } = await github.request(artifacts.artifacts.find(artifact=> artifact.name === 'npm').archive_download_url); + require('fs').writeFileSync(require('path').join(process.env.GITHUB_WORKSPACE, 'package.zip'), Buffer.from(artifact)) + + - run: | + unzip package.zip + tar --strip-components=1 -xzf testcafe-*.tgz + + - name: Get npm cache directory + id: npm-cache-dir + run: | + echo "::set-output name=dir::$(npm config get cache)" + - uses: actions/cache@v2 + with: + path: ${{ steps.npm-cache-dir.outputs.dir }} + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + - run: npm ci + - run: npx gulp test-functional-local-proxyless-run --steps-as-tasks + timeout-minutes: 60 + - uses: actions/github-script@v3 + with: + script: | + await github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.payload.inputs.sha, + context: context.workflow, + state: 'success', + target_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + }); + - uses: actions/github-script@v3 + if: failure() || cancelled() + with: + script: | + await github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.payload.inputs.sha, + context: context.workflow, + state: 'failure', + target_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + }); diff --git a/@types/chrome-remote-interface/index.d.ts b/@types/chrome-remote-interface/index.d.ts index a1874224c2f..88084dbd58f 100644 --- a/@types/chrome-remote-interface/index.d.ts +++ b/@types/chrome-remote-interface/index.d.ts @@ -23,8 +23,8 @@ declare module 'chrome-remote-interface' { interface ChromeRemoteInterface { (options: chromeRemoteInterface.ConstructorOptions): Promise; - listTabs (options: chromeRemoteInterface.GenericConnectionOptions): Promise; - closeTab (options: chromeRemoteInterface.CloseTabOptions): Promise; + List (options: chromeRemoteInterface.GenericConnectionOptions): Promise; + Close (options: chromeRemoteInterface.CloseTabOptions): Promise; } const chromeRemoteInterface: ChromeRemoteInterface; diff --git a/@types/replicator/index.d.ts b/@types/replicator/index.d.ts index 311b9968370..b348c4f3385 100644 --- a/@types/replicator/index.d.ts +++ b/@types/replicator/index.d.ts @@ -15,7 +15,7 @@ declare module 'replicator' { } interface ReplicatorConstructor { - new (): Replicator; + new (serializer?: { serialize: (val: unknown) => unknown, deserialize: (val: unknown) => unknown }): Replicator; } const Replicator: ReplicatorConstructor; diff --git a/Gulpfile.js b/Gulpfile.js index 6ef8347a8b2..e5b72bebfee 100644 --- a/Gulpfile.js +++ b/Gulpfile.js @@ -412,6 +412,12 @@ gulp.step('test-functional-local-compiler-service-run', () => { gulp.task('test-functional-local-compiler-service', gulp.series('prepare-tests', 'test-functional-local-compiler-service-run')); +gulp.step('test-functional-local-proxyless-run', () => { + return testFunctional(TESTS_GLOB, functionalTestConfig.testingEnvironmentNames.localHeadlessChrome, { isProxyless: true }); +}); + +gulp.task('test-functional-local-proxyless', gulp.series('prepare-tests', 'test-functional-local-proxyless-run')); + gulp.task('docker-build', done => { childProcess.execSync('npm pack', { env: process.env }).toString(); diff --git a/gulp/helpers/test-functional.js b/gulp/helpers/test-functional.js index 87ce468ea43..57c6cd960e4 100644 --- a/gulp/helpers/test-functional.js +++ b/gulp/helpers/test-functional.js @@ -15,13 +15,16 @@ const SCREENSHOT_TESTS_GLOB = [ 'test/functional/fixtures/screenshots-on-fails/test.js' ]; -module.exports = function testFunctional (src, testingEnvironmentName, { experimentalCompilerService } = {}) { +module.exports = function testFunctional (src, testingEnvironmentName, { experimentalCompilerService, isProxyless } = {}) { process.env.TESTING_ENVIRONMENT = testingEnvironmentName; process.env.BROWSERSTACK_USE_AUTOMATE = 1; if (experimentalCompilerService) process.env.EXPERIMENTAL_COMPILER_SERVICE = 'true'; + if (isProxyless) + process.env.PROXYLESS = 'true'; + if (!process.env.BROWSERSTACK_NO_LOCAL) process.env.BROWSERSTACK_NO_LOCAL = 1; diff --git a/package.json b/package.json index 34a9b7175a4..1e1ddd622d6 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "callsite-record": "^4.0.0", "chai": "^4.1.2", "chalk": "^2.3.0", - "chrome-remote-interface": "^0.25.3", + "chrome-remote-interface": "^0.30.0", "coffeescript": "^2.3.1", "commander": "^2.8.1", "debug": "^4.3.1", diff --git a/src/browser/connection/index.ts b/src/browser/connection/index.ts index 3bce7e55e0b..a8b4f58ccbc 100644 --- a/src/browser/connection/index.ts +++ b/src/browser/connection/index.ts @@ -71,6 +71,7 @@ export default class BrowserConnection extends EventEmitter { public permanent: boolean; public previousActiveWindowId: string | null; private readonly disableMultipleWindows: boolean; + private readonly isProxyless: boolean; private readonly HEARTBEAT_TIMEOUT: number; private readonly BROWSER_CLOSE_TIMEOUT: number; private readonly BROWSER_RESTART_TIMEOUT: number; @@ -107,7 +108,8 @@ export default class BrowserConnection extends EventEmitter { gateway: BrowserConnectionGateway, browserInfo: BrowserInfo, permanent: boolean, - disableMultipleWindows = false) { + disableMultipleWindows = false, + isProxyless = false) { super(); this.HEARTBEAT_TIMEOUT = HEARTBEAT_TIMEOUT; @@ -134,6 +136,7 @@ export default class BrowserConnection extends EventEmitter { this.heartbeatTimeout = null; this.pendingTestRunUrl = null; this.disableMultipleWindows = disableMultipleWindows; + this.isProxyless = isProxyless; this.url = `${gateway.domain}/browser/connect/${this.id}`; this.idleUrl = `${gateway.domain}/browser/idle/${this.id}`; @@ -183,7 +186,7 @@ export default class BrowserConnection extends EventEmitter { private async _runBrowser (): Promise { try { - await this.provider.openBrowser(this.id, this.url, this.browserInfo.browserName, this.disableMultipleWindows); + await this.provider.openBrowser(this.id, this.url, this.browserInfo.browserName, this.disableMultipleWindows, this.isProxyless); if (this.status !== BrowserConnectionStatus.ready) await promisifyEvent(this, 'ready'); diff --git a/src/browser/provider/built-in/dedicated/base.js b/src/browser/provider/built-in/dedicated/base.js index 1a5f3facab1..bc58ac7449b 100644 --- a/src/browser/provider/built-in/dedicated/base.js +++ b/src/browser/provider/built-in/dedicated/base.js @@ -99,5 +99,26 @@ export default { const maximumSize = getMaximizedHeadlessWindowSize(); await this.resizeWindow(browserId, maximumSize.width, maximumSize.height, maximumSize.width, maximumSize.height); + }, + + async executeClientFunction (browserId, command, callsite) { + const runtimeInfo = this.openedBrowsers[browserId]; + const browserClient = this._getBrowserProtocolClient(runtimeInfo); + + return browserClient.executeClientFunction(command, callsite); + }, + + async switchToIframe (browserId) { + const runtimeInfo = this.openedBrowsers[browserId]; + const browserClient = this._getBrowserProtocolClient(runtimeInfo); + + return browserClient.switchToIframe(); + }, + + async switchToMainWindow (browserId) { + const runtimeInfo = this.openedBrowsers[browserId]; + const browserClient = this._getBrowserProtocolClient(runtimeInfo); + + return browserClient.switchToMainWindow(); } }; diff --git a/src/browser/provider/built-in/dedicated/chrome/browser-client.ts b/src/browser/provider/built-in/dedicated/chrome/cdp-client/index.ts similarity index 66% rename from src/browser/provider/built-in/dedicated/chrome/browser-client.ts rename to src/browser/provider/built-in/dedicated/chrome/cdp-client/index.ts index 78c647629ce..401dd140371 100644 --- a/src/browser/provider/built-in/dedicated/chrome/browser-client.ts +++ b/src/browser/provider/built-in/dedicated/chrome/cdp-client/index.ts @@ -1,36 +1,48 @@ -import { Dictionary } from '../../../../../configuration/interfaces'; +import { readSync as read } from 'read-file-relative'; +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 debug from 'debug'; -import { GET_WINDOW_DIMENSIONS_INFO_SCRIPT } from '../../../utils/client-functions'; -import WARNING_MESSAGE from '../../../../../notifications/warning-message'; +import { GET_WINDOW_DIMENSIONS_INFO_SCRIPT } from '../../../../utils/client-functions'; +import WARNING_MESSAGE from '../../../../../../notifications/warning-message'; import { Config, RuntimeInfo, TouchConfigOptions, Size -} from './interfaces'; +} from '../interfaces'; import prettyTime from 'pretty-hrtime'; -import { CheckedCDPMethod, ELAPSED_TIME_UPPERBOUNDS } from './elapsed-upperbounds'; -import guardTimeExecution from '../../../../../utils/guard-time-execution'; +import { CheckedCDPMethod, ELAPSED_TIME_UPPERBOUNDS } from '../elapsed-upperbounds'; +import guardTimeExecution from '../../../../../../utils/guard-time-execution'; +import { + ClientFunctionExecutionInterruptionError, + UncaughtErrorInClientFunctionCode, + DomNodeClientFunctionResultError +} from '../../../../../../shared/errors'; const DEBUG_SCOPE = (id: string): string => `testcafe:browser:provider:built-in:chrome:browser-client:${id}`; const DOWNLOADS_DIR = path.join(os.homedir(), 'Downloads'); +const EXECUTION_CTX_WAS_DESTROYED_CODE = -32000; const debugLog = debug('testcafe:browser:provider:built-in:dedicated:chrome'); export class BrowserClient { private _clients: Dictionary = {}; private _runtimeInfo: RuntimeInfo; + private readonly _isProxyless: boolean; private _parentTarget?: remoteChrome.TargetInfo; private readonly debugLogger: debug.Debugger; + // new Map + private readonly _frameExecutionContexts = new Map(); + private _currentFrameId: string = ''; - public constructor (runtimeInfo: RuntimeInfo) { + public constructor (runtimeInfo: RuntimeInfo, isProxyless: boolean) { this._runtimeInfo = runtimeInfo; this.debugLogger = debug(DEBUG_SCOPE(runtimeInfo.browserId)); + this._isProxyless = isProxyless; runtimeInfo.browserClient = this; } @@ -44,7 +56,7 @@ export class BrowserClient { } private async _getTabs (): Promise { - const tabs = await remoteChrome.listTabs({ port: this._runtimeInfo.cdpPort }); + const tabs = await remoteChrome.List({ port: this._runtimeInfo.cdpPort }); return tabs.filter(t => t.type === 'page'); } @@ -168,6 +180,33 @@ export class BrowserClient { this._runtimeInfo.emulatedDevicePixelRatio = this._config.scaleFactor || this._runtimeInfo.originalDevicePixelRatio; } + private async _injectProxylessStuff (client: remoteChrome.ProtocolApi): Promise { + await client.Page.addScriptToEvaluateOnNewDocument({ + source: read('../../../../../../../lib/client/proxyless/index.js') as string + }); + } + + private _setupFramesWatching (client: remoteChrome.ProtocolApi): void { + client.Runtime.on('executionContextCreated', (event: Protocol.Runtime.ExecutionContextCreatedEvent) => { + if (!event.context.auxData?.frameId) + return; + + this._frameExecutionContexts.set(event.context.auxData.frameId, event.context.id); + }); + + client.Runtime.on('executionContextDestroyed', (event: Protocol.Runtime.ExecutionContextDestroyedEvent) => { + for (const [frameId, executionContextId] of this._frameExecutionContexts.entries()) { + if (executionContextId === event.executionContextId) + this._frameExecutionContexts.delete(frameId); + } + }); + + client.Runtime.on('executionContextsCleared', () => { + this._currentFrameId = ''; + this._frameExecutionContexts.clear(); + }); + } + public async resizeWindow (newDimensions: Size): Promise { const { browserId, config, viewportSize, providerMethods, emulatedDevicePixelRatio } = this._runtimeInfo; @@ -228,6 +267,11 @@ export class BrowserClient { if (client) { await this._calculateEmulatedDevicePixelRatio(client); await this._setupClient(client); + + if (this._isProxyless) { + await this._injectProxylessStuff(client); + this._setupFramesWatching(client); + } } } catch (e) { @@ -280,7 +324,7 @@ export class BrowserClient { public async closeTab (): Promise { if (this._parentTarget) - await remoteChrome.closeTab({ id: this._parentTarget.id, port: this._runtimeInfo.cdpPort }); + await remoteChrome.Close({ id: this._parentTarget.id, port: this._runtimeInfo.cdpPort }); } public async updateMobileViewportSize (): Promise { @@ -296,4 +340,76 @@ export class BrowserClient { this._runtimeInfo.viewportSize.width = windowDimensions.outerWidth; this._runtimeInfo.viewportSize.height = windowDimensions.outerHeight; } + + public async executeClientFunction (command: any, callsite: any): Promise { + const client = await this.getActiveClient(); + + if (!client) + throw new Error('Cannot get the active browser client'); + + const expression = ` + (function () {debugger; + const proxyless = window['%proxyless%']; + const ClientFunctionExecutor = proxyless.ClientFunctionExecutor; + const executor = new ClientFunctionExecutor(${JSON.stringify(command)}); + + return executor.getResult().then(result => JSON.stringify(result)); + })(); + `; + + let result; + let exceptionDetails; + + try { + const script = { expression, awaitPromise: true } as Protocol.Runtime.EvaluateRequest; + + if (this._currentFrameId && this._frameExecutionContexts.has(this._currentFrameId)) + script.contextId = this._frameExecutionContexts.get(this._currentFrameId); + + ({ result, exceptionDetails } = await client.Runtime.evaluate(script)); + } + catch (e) { + if (e.response?.code === EXECUTION_CTX_WAS_DESTROYED_CODE) + throw new ClientFunctionExecutionInterruptionError(command.instantiationCallsiteName, callsite); + + throw e; + } + + if (exceptionDetails) { + if (exceptionDetails.exception?.value === DomNodeClientFunctionResultError.name) + throw new DomNodeClientFunctionResultError(command.instantiationCallsiteName, callsite); + + throw new UncaughtErrorInClientFunctionCode(command.instantiationCallsiteName, exceptionDetails.text, callsite); + } + + return JSON.parse(result.value); + } + + private async switchToIframe (): Promise { + const client = await this.getActiveClient(); + + if (!client) + return; + + const script = { expression: 'window["%switchedIframe%"]' } as Protocol.Runtime.EvaluateRequest; + + if (this._currentFrameId && this._frameExecutionContexts.has(this._currentFrameId)) + script.contextId = this._frameExecutionContexts.get(this._currentFrameId); + + const { result } = await client.Runtime.evaluate(script); + + if (result.subtype !== 'node') + return; + + const { node } = await client.DOM.describeNode({ objectId: result.objectId }); + + if (!node.frameId) + return; + + this._currentFrameId = node.frameId; + } + + private switchToMainWindow (): void { + this._currentFrameId = ''; + } } diff --git a/src/browser/provider/built-in/dedicated/chrome/index.js b/src/browser/provider/built-in/dedicated/chrome/index.js index cae79f4aee3..f198e5ea24e 100644 --- a/src/browser/provider/built-in/dedicated/chrome/index.js +++ b/src/browser/provider/built-in/dedicated/chrome/index.js @@ -5,7 +5,7 @@ import ChromeRunTimeInfo from './runtime-info'; import getConfig from './config'; import { start as startLocalChrome, stop as stopLocalChrome } from './local-chrome'; import { GET_WINDOW_DIMENSIONS_INFO_SCRIPT } from '../../../utils/client-functions'; -import { BrowserClient } from './browser-client'; +import { BrowserClient } from './cdp-client'; const MIN_AVAILABLE_DIMENSION = 50; @@ -39,7 +39,7 @@ export default { this.setUserAgentMetaInfo(browserId, metaInfo, options); }, - async openBrowser (browserId, pageUrl, configString, disableMultipleWindows) { + async openBrowser (browserId, pageUrl, configString, disableMultipleWindows, isProxyless) { const parsedPageUrl = parseUrl(pageUrl); const runtimeInfo = await this._createRunTimeInfo(parsedPageUrl.hostname, configString, disableMultipleWindows); @@ -62,7 +62,7 @@ export default { if (!disableMultipleWindows) runtimeInfo.activeWindowId = this.calculateWindowId(); - const browserClient = new BrowserClient(runtimeInfo); + const browserClient = new BrowserClient(runtimeInfo, isProxyless); this.openedBrowsers[browserId] = runtimeInfo; @@ -120,7 +120,10 @@ export default { hasTakeScreenshot: !!client, hasChromelessScreenshots: !!client, hasGetVideoFrameData: !!client, - hasCanResizeWindowToDimensions: false + hasCanResizeWindowToDimensions: false, + hasExecuteClientFunction: !!client, + hasSwitchToIframe: !!client, + hasSwitchToMainWindow: !!client }; }, diff --git a/src/browser/provider/built-in/dedicated/chrome/interfaces.ts b/src/browser/provider/built-in/dedicated/chrome/interfaces.ts index 03e928259b6..a8b5be0ff0b 100644 --- a/src/browser/provider/built-in/dedicated/chrome/interfaces.ts +++ b/src/browser/provider/built-in/dedicated/chrome/interfaces.ts @@ -1,4 +1,4 @@ -import { BrowserClient } from './browser-client'; +import { BrowserClient } from './cdp-client'; export interface Size { width: number; diff --git a/src/browser/provider/index.ts b/src/browser/provider/index.ts index e386fbb87c6..16999a36519 100644 --- a/src/browser/provider/index.ts +++ b/src/browser/provider/index.ts @@ -310,8 +310,8 @@ export default class BrowserProvider { return this.plugin.isHeadlessBrowser(browserId, browserName); } - public async openBrowser (browserId: string, pageUrl: string, browserName: string, disableMultipleWindows: boolean): Promise { - await this.plugin.openBrowser(browserId, pageUrl, browserName, disableMultipleWindows); + public async openBrowser (browserId: string, pageUrl: string, browserName: string, disableMultipleWindows: boolean, isProxyless: boolean): Promise { + await this.plugin.openBrowser(browserId, pageUrl, browserName, disableMultipleWindows, isProxyless); await this._ensureRetryTestPagesWarning(browserId); @@ -401,6 +401,18 @@ export default class BrowserProvider { await this.plugin.takeScreenshot(browserId, screenshotPath, pageWidth, pageHeight, fullPage); } + public async executeClientFunction (browserId: string, command: any, callsite: any): Promise { + return this.plugin.executeClientFunction(browserId, command, callsite); + } + + public async switchToIframe (browserId: string): Promise { + return this.plugin.switchToIframe(browserId); + } + + public switchToMainWindow (browserId: string): Promise { + return this.plugin.switchToMainWindow(browserId); + } + public async getVideoFrameData (browserId: string): Promise { return this.plugin.getVideoFrameData(browserId); } diff --git a/src/cli/argument-parser.ts b/src/cli/argument-parser.ts index b7c4ea7b960..c829da79c76 100644 --- a/src/cli/argument-parser.ts +++ b/src/cli/argument-parser.ts @@ -74,6 +74,7 @@ interface CommandLineOptions { videoEncodingOptions?: string | Dictionary; compilerOptions?: string | Dictionary; configFile?: string; + isProxyless?: boolean; } export default class CLIArgumentParser { diff --git a/src/client/driver/command-executors/client-functions/client-function-executor.js b/src/client/driver/command-executors/client-functions/client-function-executor.js index 06f2c927bdf..9b0bc90ffcb 100644 --- a/src/client/driver/command-executors/client-functions/client-function-executor.js +++ b/src/client/driver/command-executors/client-functions/client-function-executor.js @@ -40,12 +40,10 @@ export default class ClientFunctionExecutor { isCommandResult: true, result: this.replicator.encode(result) })) - .catch(err => { - return new DriverStatus({ - isCommandResult: true, - executionError: err - }); - }); + .catch(err => new DriverStatus({ + isCommandResult: true, + executionError: err + })); } //Overridable methods diff --git a/src/client/driver/driver.js b/src/client/driver/driver.js index 4ff8a04797c..0080d6fa59b 100644 --- a/src/client/driver/driver.js +++ b/src/client/driver/driver.js @@ -916,6 +916,8 @@ export default class Driver extends serviceUtils.EventEmitter { if (!domUtils.isIframeElement(iframe)) throw new ActionElementNotIframeError(); + window['%switchedIframe%'] = iframe; + return this._ensureChildIframeDriverLink(nativeMethods.contentWindowGetter.call(iframe), iframeErrorCtors.NotLoadedError, commandSelectorTimeout); }) diff --git a/src/client/proxyless/client-function-executor.ts b/src/client/proxyless/client-function-executor.ts new file mode 100644 index 00000000000..f706517ef4d --- /dev/null +++ b/src/client/proxyless/client-function-executor.ts @@ -0,0 +1,45 @@ +import { + createReplicator, + FunctionTransform, + ClientFunctionNodeTransform +} from './replicator'; +import evalFunction from './eval-function'; +import Replicator from 'replicator'; +import { ExecuteClientFunctionCommandBase } from '../../test-run/commands/observation'; + +export default class ClientFunctionExecutor { + public readonly fn: Function; + public readonly replicator: Replicator; + public readonly dependencies: unknown; + public readonly command: ExecuteClientFunctionCommandBase + + public constructor (command: ExecuteClientFunctionCommandBase) { + this.command = command; + this.replicator = this._createReplicator(); + this.dependencies = this.replicator.decode(this.command.dependencies); + + this.fn = evalFunction(this.command.fnCode, this.dependencies); + } + + public getResult (): Promise { + // eslint-disable-next-line hammerhead/use-hh-promise + return Promise.resolve() + .then(() => { + const args = this.replicator.decode(this.command.args) as any[]; + + return this._executeFn(args); + }) + .then(result => this.replicator.encode(result)); + } + + protected _createReplicator (): Replicator { + return createReplicator([ + new ClientFunctionNodeTransform(this.command.instantiationCallsiteName), + new FunctionTransform() + ]); + } + + protected _executeFn (args: any[]): unknown { + return this.fn.apply(window, args); + } +} diff --git a/src/client/proxyless/eval-function.ts b/src/client/proxyless/eval-function.ts new file mode 100644 index 00000000000..e89258cc380 --- /dev/null +++ b/src/client/proxyless/eval-function.ts @@ -0,0 +1,14 @@ +const FunctionCtor = window.Function; + +// NOTE: evalFunction is isolated into a separate module to +// restrict access to TestCafe intrinsics for the evaluated code. +// It also accepts `__dependencies$` argument which may be used by evaluated code. +export default function evalFunction (fnCode: string, __dependencies$: unknown): Function { + const evaluator = new FunctionCtor( + 'fnCode', + '__dependencies$', + '"use strict"; return eval(fnCode)' + ); + + return evaluator(fnCode, __dependencies$); +} diff --git a/src/client/proxyless/index.ts b/src/client/proxyless/index.ts new file mode 100644 index 00000000000..39c965b182a --- /dev/null +++ b/src/client/proxyless/index.ts @@ -0,0 +1,8 @@ +import ClientFunctionExecutor from './client-function-executor'; + +// eslint-disable-next-line no-restricted-globals +Object.defineProperty(window, '%proxyless%', { + value: { ClientFunctionExecutor }, + configurable: true +}); + diff --git a/src/client/proxyless/replicator.ts b/src/client/proxyless/replicator.ts new file mode 100644 index 00000000000..b5b175779f3 --- /dev/null +++ b/src/client/proxyless/replicator.ts @@ -0,0 +1,63 @@ +import Replicator, { Transform } from 'replicator'; +import evalFunction from './eval-function'; +import { DomNodeClientFunctionResultError } from '../../shared/errors'; +import { ExecuteClientFunctionCommandBase } from '../../test-run/commands/observation'; + +const identity = (val: unknown): unknown => val; + + +export function createReplicator (transforms: Transform[]): Replicator { + // NOTE: we will serialize replicator results + // to JSON with a command or command result. + // Therefore there is no need to do additional job here, + // so we use identity functions for serialization. + const replicator = new Replicator({ + serialize: identity, + deserialize: identity + }); + + return replicator.addTransforms(transforms); +} + +export class FunctionTransform { + public readonly type = 'Function'; + + public shouldTransform (type: string): boolean { + return type === 'function'; + } + + public toSerializable (): string { + return ''; + } + + // HACK: UglifyJS + TypeScript + argument destructuring can generate incorrect code. + // So we have to use plain assignments here. + public fromSerializable (opts: ExecuteClientFunctionCommandBase): Function { + const fnCode = opts.fnCode; + const dependencies = opts.dependencies; + + return evalFunction(fnCode, dependencies); + } +} + +export class ClientFunctionNodeTransform { + public readonly type = 'Node'; + public readonly instantiationCallsiteName: string; + + public constructor (instantiationCallsiteName: string) { + this.instantiationCallsiteName = instantiationCallsiteName; + } + + public shouldTransform (type: string, val: any): boolean { + if (val instanceof Node) + throw DomNodeClientFunctionResultError.name; + + return false; + } + + public toSerializable (): void { + } + + public fromSerializable (): void { + } +} diff --git a/src/client/rollup.config.js b/src/client/rollup.config.js index 78642fb34d8..d330b3620e1 100644 --- a/src/client/rollup.config.js +++ b/src/client/rollup.config.js @@ -11,7 +11,10 @@ import alias from '@rollup/plugin-alias'; const NO_HAMMERHEAD_CHUNKS = [ 'browser/idle-page/index.js', - 'browser/service-worker.js' + 'browser/service-worker.js', + + // TODO: should not inject pinkie + 'proxyless/index.ts' ]; const CHUNK_NAMES = [ @@ -46,7 +49,7 @@ const CONFIG = CHUNK_NAMES.map(chunk => ({ context: '(void 0)', output: { - file: path.join(TARGET_DIR, chunk), + file: path.join(TARGET_DIR, chunk.replace(/\.ts$/, '.js')), format: 'iife', globals: GLOBALS(chunk), // NOTE: 'use strict' in our scripts can break user code diff --git a/src/client/tsconfig.json b/src/client/tsconfig.json index 2bec8b7d716..a380ae3f189 100644 --- a/src/client/tsconfig.json +++ b/src/client/tsconfig.json @@ -7,5 +7,6 @@ "allowJs": true, "checkJs": false, "types": [] - } + }, + "include": ["../../@types", "./", "../shared"] } diff --git a/src/shared/errors/index.js b/src/shared/errors/index.js index f1c9d7d21ca..0a4dfed9c17 100644 --- a/src/shared/errors/index.js +++ b/src/shared/errors/index.js @@ -7,10 +7,10 @@ import { TEST_RUN_ERRORS } from '../../errors/types'; // Base //-------------------------------------------------------------------- export class TestRunErrorBase { - constructor (code) { + constructor (code, callsite) { this.code = code; this.isTestCafeError = true; - this.callsite = null; + this.callsite = callsite || null; } } @@ -27,16 +27,16 @@ class ActionOptionErrorBase extends TestRunErrorBase { // Client function errors //-------------------------------------------------------------------- export class ClientFunctionExecutionInterruptionError extends TestRunErrorBase { - constructor (instantiationCallsiteName) { - super(TEST_RUN_ERRORS.clientFunctionExecutionInterruptionError); + constructor (instantiationCallsiteName, callsite) { + super(TEST_RUN_ERRORS.clientFunctionExecutionInterruptionError, callsite); this.instantiationCallsiteName = instantiationCallsiteName; } } export class DomNodeClientFunctionResultError extends TestRunErrorBase { - constructor (instantiationCallsiteName) { - super(TEST_RUN_ERRORS.domNodeClientFunctionResultError); + constructor (instantiationCallsiteName, callsite) { + super(TEST_RUN_ERRORS.domNodeClientFunctionResultError, callsite); this.instantiationCallsiteName = instantiationCallsiteName; } @@ -81,8 +81,8 @@ export class UncaughtErrorOnPage extends TestRunErrorBase { } export class UncaughtErrorInClientFunctionCode extends TestRunErrorBase { - constructor (instantiationCallsiteName, err) { - super(TEST_RUN_ERRORS.uncaughtErrorInClientFunctionCode); + constructor (instantiationCallsiteName, err, callsite) { + super(TEST_RUN_ERRORS.uncaughtErrorInClientFunctionCode, callsite); this.errMsg = String(err); this.instantiationCallsiteName = instantiationCallsiteName; @@ -91,7 +91,7 @@ export class UncaughtErrorInClientFunctionCode extends TestRunErrorBase { export class UncaughtErrorInCustomDOMPropertyCode extends TestRunErrorBase { constructor (instantiationCallsiteName, err, prop) { - super(TEST_RUN_ERRORS.uncaughtErrorInCustomDOMPropertyCode, err, prop); + super(TEST_RUN_ERRORS.uncaughtErrorInCustomDOMPropertyCode); this.errMsg = String(err); this.property = prop; diff --git a/src/test-run/index.ts b/src/test-run/index.ts index f76b671a2bf..310f1705d94 100644 --- a/src/test-run/index.ts +++ b/src/test-run/index.ts @@ -121,6 +121,12 @@ const COMPILER_SERVICE_EVENTS = [ 'removeHeaderOnConfigureResponseEvent' ]; +const PROXYLESS_COMMANDS = new Map(); + +PROXYLESS_COMMANDS.set(COMMAND_TYPE.executeClientFunction, 'hasExecuteClientFunction'); +PROXYLESS_COMMANDS.set(COMMAND_TYPE.switchToIframe, 'hasSwitchToIframe'); +PROXYLESS_COMMANDS.set(COMMAND_TYPE.switchToMainWindow, 'hasSwitchToMainWindow'); + interface TestRunInit { test: Test; browserConnection: BrowserConnection; @@ -592,7 +598,7 @@ export default class TestRun extends AsyncEventEmitter { } public addError (err: Error | TestCafeErrorList | TestRunErrorBase): void { - const errList = err instanceof TestCafeErrorList ? err.items : [err]; + const errList = (err instanceof TestCafeErrorList ? err.items : [err]) as Error[]; errList.forEach(item => { const adapter = this._createErrorAdapter(item); @@ -925,9 +931,22 @@ export default class TestRun extends AsyncEventEmitter { return result; } + private async _canExecuteCommandThroughCDP (command: CommandBase): Promise { + if (!this.opts.isProxyless || !PROXYLESS_COMMANDS.has(command.type)) + return false; + + const browserId = this.browserConnection.id; + const customActionsInfo = await this.browserConnection.provider.hasCustomActionForBrowser(browserId); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return customActionsInfo[PROXYLESS_COMMANDS.get(command.type)!]; + } + public async executeCommand (command: CommandBase, callsite?: CallsiteRecord): Promise { this.debugLog.command(command); + let postAction = null as (() => Promise) | null; + if (this.pendingPageError && isCommandRejectableByPageError(command)) return this._rejectCommandWithPageError(callsite); @@ -938,6 +957,17 @@ export default class TestRun extends AsyncEventEmitter { await this._setBreakpointIfNecessary(command, callsite); + if (await this._canExecuteCommandThroughCDP(command)) { + const browserId = this.browserConnection.id; + + if (command.type === COMMAND_TYPE.executeClientFunction) + return this.browserConnection.provider.executeClientFunction(browserId, command, callsite); + else if (command.type === COMMAND_TYPE.switchToIframe) + postAction = async () => this.browserConnection.provider.switchToIframe(browserId); + else if (command.type === COMMAND_TYPE.switchToMainWindow) + postAction = async () => this.browserConnection.provider.switchToMainWindow(browserId); + } + if (isScreenshotCommand(command)) { if (this.opts.disableScreenshots) { this.warningLog.addWarning(WARNING_MESSAGE.screenshotsDisabled); @@ -991,7 +1021,12 @@ export default class TestRun extends AsyncEventEmitter { if (command.type === COMMAND_TYPE.switchToWindowByPredicate) return this._switchToWindowByPredicate(command as SwitchToWindowByPredicateCommand); - return this._enqueueCommand(command, callsite as CallsiteRecord); + const result = await this._enqueueCommand(command, callsite as CallsiteRecord); + + if (postAction) + await postAction(); + + return result; } private _rejectCommandWithPageError (callsite?: CallsiteRecord): Promise { diff --git a/test/functional/config.js b/test/functional/config.js index e47e25b4067..618c9721733 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -221,6 +221,10 @@ module.exports = { return process.env.DEV_MODE === 'true'; }, + get isProxyless () { + return process.env.PROXYLESS === 'true'; + }, + get retryTestPages () { return this.currentEnvironment.retryTestPages; }, diff --git a/test/functional/fixtures/api/es-next/eval/test.js b/test/functional/fixtures/api/es-next/eval/test.js index 66708126cf9..f72cbf09516 100644 --- a/test/functional/fixtures/api/es-next/eval/test.js +++ b/test/functional/fixtures/api/es-next/eval/test.js @@ -33,7 +33,8 @@ describe('[API] t.eval', function () { it('Should have the correct callsite if an error occurs during execution', function () { return runTests('./testcafe-fixtures/eval-test.js', 'Error during execution', { shouldFail: true }) .catch(function (errs) { - expect(errs[0]).contains('An error occurred in eval code: Error: Hi there!'); + expect(errs[0]).contains('An error occurred in eval code:'); + expect(errs[0]).contains('Error: Hi there!'); expect(errs[0]).contains('> 25 | await t.eval(() => {'); }); }); diff --git a/test/functional/fixtures/api/es-next/selector/test.js b/test/functional/fixtures/api/es-next/selector/test.js index 6fbb68b9d89..8e61e7556af 100644 --- a/test/functional/fixtures/api/es-next/selector/test.js +++ b/test/functional/fixtures/api/es-next/selector/test.js @@ -309,9 +309,8 @@ describe('[API] Selector', function () { only: 'chrome' }) .catch(function (errs) { - expect(errs[0]).contains( - 'An error occurred in customMethod code: Error: test' - ); + expect(errs[0]).contains('An error occurred in customMethod code:'); + expect(errs[0]).contains('Error: test'); expect(errs[0]).contains('> 63 | await el.customMethod();'); }); } diff --git a/test/functional/fixtures/browser-provider/browser-reconnect/test.js b/test/functional/fixtures/browser-provider/browser-reconnect/test.js index 7fa076e093d..7a30810c053 100644 --- a/test/functional/fixtures/browser-provider/browser-reconnect/test.js +++ b/test/functional/fixtures/browser-provider/browser-reconnect/test.js @@ -64,7 +64,7 @@ function run (pathToTest, filter, initializeConnection = initializeConnectionLow } describe('Browser reconnect', function () { - if (config.useLocalBrowsers) { + if (config.useLocalBrowsers && !config.isProxyless) { it('Should restart browser when it does not respond', function () { return run('./testcafe-fixtures/index-test.js', 'Should restart browser when it does not respond') .then(() => { diff --git a/test/functional/fixtures/browser-provider/chrome-emulation/test.js b/test/functional/fixtures/browser-provider/chrome-emulation/test.js index 6aa6a2d95ee..a4030f961e3 100644 --- a/test/functional/fixtures/browser-provider/chrome-emulation/test.js +++ b/test/functional/fixtures/browser-provider/chrome-emulation/test.js @@ -23,9 +23,11 @@ if (config.useLocalBrowsers) { expect(failedCount).eql(0); } - it('headless', () => { - return checkTouchEmulation('chrome:headless:emulation:device=iphone 6 --no-sandbox'); - }); + if (!config.isProxyless) { + it('headless', () => { + return checkTouchEmulation('chrome:headless:emulation:device=iphone 6 --no-sandbox'); + }); + } if (!isLinuxWithoutGUI) { it('non-headless', () => { diff --git a/test/functional/fixtures/ui/test.js b/test/functional/fixtures/ui/test.js index c928f31f4d2..b64c6773ac9 100644 --- a/test/functional/fixtures/ui/test.js +++ b/test/functional/fixtures/ui/test.js @@ -1,7 +1,11 @@ +const config = require('../../config'); + describe('TestCafe UI', () => { - it('Should display correct status', () => { - return runTests('./testcafe-fixtures/status-bar-test.js', 'Show status prefix', { assertionTimeout: 3000 }); - }); + if (!config.isProxyless) { + it('Should display correct status', () => { + return runTests('./testcafe-fixtures/status-bar-test.js', 'Show status prefix', { assertionTimeout: 3000 }); + }); + } it('Hide elements when resizing the window', () => { return runTests('./testcafe-fixtures/status-bar-test.js', 'Hide elements when resizing the window', { skip: ['android', 'ipad', 'iphone', 'edge'] }); diff --git a/test/functional/setup.js b/test/functional/setup.js index 429ebc95eda..2ec6499da06 100644 --- a/test/functional/setup.js +++ b/test/functional/setup.js @@ -53,7 +53,7 @@ function getBrowserInfo (settings) { return browserProviderPool .getBrowserInfo(settings.browserName) - .then(browserInfo => new BrowserConnection(testCafe.browserConnectionGateway, browserInfo, true)); + .then(browserInfo => new BrowserConnection(testCafe.browserConnectionGateway, browserInfo, true, false, config.isProxyless)); }) .then(connection => { return { @@ -153,7 +153,8 @@ before(function () { retryTestPages, - experimentalCompilerService: !!process.env.EXPERIMENTAL_COMPILER_SERVICE + experimentalCompilerService: !!process.env.EXPERIMENTAL_COMPILER_SERVICE, + isProxyless: config.isProxyless }; return createTestCafe(testCafeOptions) diff --git a/test/server/execute-async-expression-test.js b/test/server/execute-async-expression-test.js index 93d937cf00a..78cea56aecc 100644 --- a/test/server/execute-async-expression-test.js +++ b/test/server/execute-async-expression-test.js @@ -52,7 +52,11 @@ class TestRunMock extends TestRun { this.browserConnection = { isHeadlessBrowser: () => false, - userAgent: 'Chrome' + userAgent: 'Chrome', + provider: { + hasCustomActionForBrowser () { + } + } }; } } diff --git a/test/server/video-recorder-test.js b/test/server/video-recorder-test.js index 8715d0ed414..c5c816f2200 100644 --- a/test/server/video-recorder-test.js +++ b/test/server/video-recorder-test.js @@ -201,9 +201,7 @@ describe('Video Recorder', () => { return browserJobMock.emit('start') .then(() => browserJobMock.emit('test-run-create', testRunMock)) .then(() => browserJobMock.emit('test-run-before-done', testRunMock)) - .then(() => { - return testRunMock.testRun.executeCommand({ type: COMMAND_TYPE.resizeWindow }); - }) + .then(() => testRunMock.testRun.executeCommand({ type: COMMAND_TYPE.resizeWindow })) .then(() => { expect(videoRecorder.log.includes('The browser window was resized during the "Test" test while TestCafe recorded a video. TestCafe cannot adjust the video resolution during recording. As a result, the video content may appear broken. Do not resize the browser window when TestCafe records a video.')).to.be.true; });