diff --git a/gulp/constants/functional-test-globs.js b/gulp/constants/functional-test-globs.js index b41c49e6932..4abefe26489 100644 --- a/gulp/constants/functional-test-globs.js +++ b/gulp/constants/functional-test-globs.js @@ -1,4 +1,5 @@ const MULTIPLE_WINDOWS_TESTS_GLOB = 'test/functional/fixtures/multiple-windows/test.js'; +const ISOLATED_SESSIONS_TESTS_GLOB = 'test/functional/fixtures/isolated-sessions/test.js'; const HEADED_CHROME_FIREFOX_TESTS_GLOB = ['test/functional/fixtures/live/test.js', 'test/functional/fixtures/ui/test.js']; const COMPILER_SERVICE_TESTS_GLOB = 'test/functional/fixtures/compiler-service/test.js'; const LEGACY_TESTS_GLOB = 'test/functional/legacy-fixtures/**/test.js'; @@ -12,6 +13,7 @@ const SCREENSHOT_TESTS_GLOB = [ const TESTS_GLOB = [ BASIC_TESTS_GLOB, `!${MULTIPLE_WINDOWS_TESTS_GLOB}`, + `!${ISOLATED_SESSIONS_TESTS_GLOB}`, `!${COMPILER_SERVICE_TESTS_GLOB}`, ]; @@ -19,6 +21,7 @@ module.exports = { TESTS_GLOB, LEGACY_TESTS_GLOB, MULTIPLE_WINDOWS_TESTS_GLOB, + ISOLATED_SESSIONS_TESTS_GLOB, BASIC_TESTS_GLOB, COMPILER_SERVICE_TESTS_GLOB, SCREENSHOT_TESTS_GLOB, diff --git a/src/api/test-controller/index.js b/src/api/test-controller/index.js index 7cfd9153f47..acee7061465 100644 --- a/src/api/test-controller/index.js +++ b/src/api/test-controller/index.js @@ -647,6 +647,23 @@ export default class TestController { [delegatedAPI(ReportCommand.methodName)] (...args) { return this.enqueueCommand(ReportCommand, { args }); } + + // Open an isolated browser session via CDP Target.createBrowserContext + _openIsolatedSession$ () { + const callsite = getCallsiteForMethod('openIsolatedSession'); + + return this._enqueueTask('openIsolatedSession', () => { + return async () => { + if (!this.testRun.isNativeAutomation) + throw new Error('openIsolatedSession requires Native Automation mode'); + + const isolatedSession = await this.testRun.createIsolatedSession(); + + return isolatedSession.controller; + }; + }, callsite); + } + shouldStop (command) { // NOTE: should always stop on Debug command return command === 'debug'; diff --git a/src/api/test-controller/isolated.js b/src/api/test-controller/isolated.js new file mode 100644 index 00000000000..175ecf82593 --- /dev/null +++ b/src/api/test-controller/isolated.js @@ -0,0 +1,478 @@ +// IsolatedTestController — test controller for CDP-isolated browser sessions (t2 API) +// +// The t2 object returned by t.openIsolatedSession(). Mirrors TestController's +// delegatedAPI pattern but routes commands to IsolatedSession.executeCommand() +// instead of TestRun.executeCommand(). Supports the same promise-chain pattern +// as the main t controller. +// +// TODO: Fix https://github.com/DevExpress/testcafe/issues/4139 to get rid of Pinkie +import Promise from 'pinkie'; +import { + identity, + flattenDeep, + castArray, +} from 'lodash'; + +import { getCallsiteForMethod } from '../../errors/get-callsite'; +import Assertion from './assertion'; +import { getDelegatedAPIList, delegateAPI } from '../../utils/delegated-api'; +import delegatedAPI from './delegated-api'; +import testRunTracker from '../../api/test-run-tracker'; + +import { + ClickCommand, + RightClickCommand, + DoubleClickCommand, + HoverCommand, + DragCommand, + DragToElementCommand, + TypeTextCommand, + PressKeyCommand, + SelectTextCommand, + ScrollCommand, + ScrollByCommand, + ScrollIntoViewCommand, + DispatchEventCommand, + NavigateToCommand, + UseRoleCommand, + GetCookiesCommand, + SetCookiesCommand, + DeleteCookiesCommand, +} from '../../test-run/commands/actions'; + +import { WaitCommand } from '../../test-run/commands/observation'; +import { AssertionCommand } from '../../test-run/commands/assertion'; + +const originalThen = Promise.resolve().then; + +export class IsolatedTestController { + constructor (isolatedSession) { + this._session = isolatedSession; + this.executionChain = Promise.resolve(); + this.warningLog = isolatedSession.parentTestRun.warningLog; + this._directMode = false; + + this._addTestControllerToExecutionChain(); + } + + _addTestControllerToExecutionChain () { + this.executionChain._testController = this; + } + + _createCommand (CmdCtor, cmdArgs, callsite) { + try { + // Pass the parent test run for validation context + return new CmdCtor(cmdArgs, this._session.parentTestRun); + } + catch (err) { + err.callsite = callsite; + + throw err; + } + } + + _enqueueTask (apiMethodName, createTaskExecutor, callsite) { + const executor = createTaskExecutor(); + + // Direct mode: execute immediately, bypass chain (avoids deadlock inside _run$) + if (this._directMode) + return executor(); + + this.executionChain.then = originalThen; + this.executionChain = this.executionChain.then(executor); + + this.executionChain = this._createExtendedPromise(this.executionChain, callsite); + + this._addTestControllerToExecutionChain(); + + return this.executionChain; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _createExtendedPromise (promise, callsite) { + const extendedPromise = promise.then(identity); + + delegateAPI(extendedPromise, IsolatedTestController.API_LIST, { + handler: this, + proxyMethod: () => {}, + }); + + return extendedPromise; + } + + enqueueCommand (CmdCtor, cmdArgs, validateCommandFn, callsite) { + callsite = callsite || getCallsiteForMethod(CmdCtor.methodName); + + const command = this._createCommand(CmdCtor, cmdArgs, callsite); + + if (typeof validateCommandFn === 'function') + validateCommandFn(this, command, callsite); + + return this._enqueueTask(command.methodName, () => { + return () => { + return this._session.executeCommand(command, callsite) + .catch(err => { + this.executionChain = Promise.resolve(); + + throw err; + }); + }; + }, callsite); + } + + // No-op stub — IsolatedTestController does not track excessive awaits + // Required by Assertion._checkForWarnings + checkForExcessiveAwaits () {} + + // ===================================================================== + // Cookie operations + // ===================================================================== + + _prepareCookieArguments (args, isSetCommand = false) { + const urlsArg = castArray(args[1]); + const urls = Array.isArray(urlsArg) && typeof urlsArg[0] === 'string' ? urlsArg : []; + + const cookiesArg = urls.length ? args[0] : args; + const cookies = []; + + flattenDeep(castArray(cookiesArg)).forEach(cookie => { + if (isSetCommand && !cookie.name && typeof cookie === 'object') + Object.entries(cookie).forEach(([name, value]) => cookies.push({ name, value })); + else if (!isSetCommand && typeof cookie === 'string') + cookies.push({ name: cookie }); + else + cookies.push(cookie); + }); + + return { urls, cookies }; + } + + [delegatedAPI(GetCookiesCommand.methodName)] (...args) { + return this.enqueueCommand(GetCookiesCommand, this._prepareCookieArguments(args)); + } + + [delegatedAPI(SetCookiesCommand.methodName)] (...args) { + const { urls, cookies } = this._prepareCookieArguments(args, true); + + return this.enqueueCommand(SetCookiesCommand, { cookies, url: urls[0] }); + } + + [delegatedAPI(DeleteCookiesCommand.methodName)] (...args) { + return this.enqueueCommand(DeleteCookiesCommand, this._prepareCookieArguments(args)); + } + + // ===================================================================== + // Mouse interactions + // ===================================================================== + + [delegatedAPI(ClickCommand.methodName)] (selector, options) { + return this.enqueueCommand(ClickCommand, { selector, options }); + } + + [delegatedAPI(RightClickCommand.methodName)] (selector, options) { + return this.enqueueCommand(RightClickCommand, { selector, options }); + } + + [delegatedAPI(DoubleClickCommand.methodName)] (selector, options) { + return this.enqueueCommand(DoubleClickCommand, { selector, options }); + } + + [delegatedAPI(HoverCommand.methodName)] (selector, options) { + return this.enqueueCommand(HoverCommand, { selector, options }); + } + + [delegatedAPI(DragCommand.methodName)] (selector, dragOffsetX, dragOffsetY, options) { + return this.enqueueCommand(DragCommand, { selector, dragOffsetX, dragOffsetY, options }); + } + + [delegatedAPI(DragToElementCommand.methodName)] (selector, destinationSelector, options) { + return this.enqueueCommand(DragToElementCommand, { selector, destinationSelector, options }); + } + + // ===================================================================== + // Keyboard interactions + // ===================================================================== + + [delegatedAPI(TypeTextCommand.methodName)] (selector, text, options) { + return this.enqueueCommand(TypeTextCommand, { selector, text, options }); + } + + [delegatedAPI(PressKeyCommand.methodName)] (keys, options) { + return this.enqueueCommand(PressKeyCommand, { keys, options }); + } + + [delegatedAPI(SelectTextCommand.methodName)] (selector, startPos, endPos, options) { + return this.enqueueCommand(SelectTextCommand, { selector, startPos, endPos, options }); + } + + // ===================================================================== + // Scroll + // ===================================================================== + + [delegatedAPI(ScrollCommand.methodName)] (selectorOrX, positionOrY, options) { + // Overloaded: scroll(selector, position) or scroll(x, y) + if (typeof selectorOrX === 'number') + return this.enqueueCommand(ScrollCommand, { x: selectorOrX, y: positionOrY, options }); + + return this.enqueueCommand(ScrollCommand, { selector: selectorOrX, position: positionOrY, options }); + } + + [delegatedAPI(ScrollByCommand.methodName)] (selectorOrByX, byXOrByY, byYOrOptions, options) { + // Overloaded: scrollBy(selector, byX, byY, opts) or scrollBy(byX, byY, opts) + if (typeof selectorOrByX === 'number') + return this.enqueueCommand(ScrollByCommand, { byX: selectorOrByX, byY: byXOrByY, options: byYOrOptions }); + + return this.enqueueCommand(ScrollByCommand, { selector: selectorOrByX, byX: byXOrByY, byY: byYOrOptions, options }); + } + + [delegatedAPI(ScrollIntoViewCommand.methodName)] (selector, options) { + return this.enqueueCommand(ScrollIntoViewCommand, { selector, options }); + } + + // ===================================================================== + // Events + // ===================================================================== + + [delegatedAPI(DispatchEventCommand.methodName)] (selector, eventName, options) { + return this.enqueueCommand(DispatchEventCommand, { selector, eventName, options }); + } + + // ===================================================================== + // Navigation & timing + // ===================================================================== + + [delegatedAPI(WaitCommand.methodName)] (timeout) { + return this.enqueueCommand(WaitCommand, { timeout }); + } + + [delegatedAPI(NavigateToCommand.methodName)] (url) { + return this.enqueueCommand(NavigateToCommand, { url }); + } + + // ===================================================================== + // Assertions + // ===================================================================== + + [delegatedAPI(AssertionCommand.methodName)] (actual) { + const callsite = getCallsiteForMethod(AssertionCommand.methodName); + + return new Assertion(actual, this, callsite); + } + + // ===================================================================== + // Roles + // ===================================================================== + + [delegatedAPI(UseRoleCommand.methodName)] (role) { + return this.enqueueCommand(UseRoleCommand, { role }); + } + + // ===================================================================== + // Direct session methods (bypass command system) + // These use _name$ convention: exposed as t2.name() via delegateAPI + // ===================================================================== + + _setWindowBounds$ (bounds) { + const session = this._session; + const callsite = getCallsiteForMethod('setWindowBounds'); + + return this._enqueueTask('setWindowBounds', () => { + return async () => { + await session.setWindowBounds(bounds); + }; + }, callsite); + } + + _setHttpAuth$ (username, password) { + const session = this._session; + const callsite = getCallsiteForMethod('setHttpAuth'); + + return this._enqueueTask('setHttpAuth', () => { + return async () => { + await session.setHttpAuth(username, password); + }; + }, callsite); + } + + _eval$ (fn) { + // Execute via CDP directly in the isolated tab + const session = this._session; + const callsite = getCallsiteForMethod('eval'); + + return this._enqueueTask('eval', () => { + return async () => { + const expression = `(${fn.toString()})()`; + + return session._evaluateExpression(expression); + }; + }, callsite); + } + + _takeScreenshot$ (filePath) { + const session = this._session; + const callsite = getCallsiteForMethod('takeScreenshot'); + + return this._enqueueTask('takeScreenshot', () => { + return async () => { + return session.takeScreenshot(filePath); + }; + }, callsite); + } + + _maximizeWindow$ () { + const session = this._session; + const callsite = getCallsiteForMethod('maximizeWindow'); + + return this._enqueueTask('maximizeWindow', () => { + return async () => { + await session.maximizeWindow(); + }; + }, callsite); + } + + _resizeWindow$ (width, height) { + const session = this._session; + const callsite = getCallsiteForMethod('resizeWindow'); + + return this._enqueueTask('resizeWindow', () => { + return async () => { + await session.resizeWindow(width, height); + }; + }, callsite); + } + + _switchToIframe$ (selector) { + const session = this._session; + const callsite = getCallsiteForMethod('switchToIframe'); + + return this._enqueueTask('switchToIframe', () => { + return async () => { + await session.switchToIframe(selector); + }; + }, callsite); + } + + _switchToMainWindow$ () { + const session = this._session; + const callsite = getCallsiteForMethod('switchToMainWindow'); + + return this._enqueueTask('switchToMainWindow', () => { + return async () => { + await session.switchToMainWindow(); + }; + }, callsite); + } + + _takeElementScreenshot$ (selector, filePath) { + const session = this._session; + const callsite = getCallsiteForMethod('takeElementScreenshot'); + + return this._enqueueTask('takeElementScreenshot', () => { + return async () => { + return session.takeElementScreenshot(selector, filePath); + }; + }, callsite); + } + + _setFilesToUpload$ (selector, filePaths) { + const session = this._session; + const callsite = getCallsiteForMethod('setFilesToUpload'); + + return this._enqueueTask('setFilesToUpload', () => { + return async () => { + await session.setFilesToUpload(selector, filePaths); + }; + }, callsite); + } + + _clearUpload$ (selector) { + const session = this._session; + const callsite = getCallsiteForMethod('clearUpload'); + + return this._enqueueTask('clearUpload', () => { + return async () => { + await session.clearUpload(selector); + }; + }, callsite); + } + + _setPageLoadTimeout$ (timeout) { + const session = this._session; + const callsite = getCallsiteForMethod('setPageLoadTimeout'); + + return this._enqueueTask('setPageLoadTimeout', () => { + return async () => { + session.setPageLoadTimeout(timeout); + }; + }, callsite); + } + + // ===================================================================== + // t2.run() — Execute a callback where the global `t` and selector + // evaluation target the isolated session's CDP tab. + // + // Usage: + // await t2.run(async () => { + // await t.click(selector) // → clicks in isolated tab + // await t.expect(sel.exists).ok() // → checks DOM in isolated tab + // await waitForElementVisible(sel) // → polls DOM in isolated tab + // }) + // ===================================================================== + + _run$ (fn) { + const session = this._session; + const testRun = session.parentTestRun; + const self = this; + const callsite = getCallsiteForMethod('run'); + + return this._enqueueTask('run', () => { + return async () => { + // Save originals + const originalController = testRun.controller; + const originalExecuteCommand = testRun.executeCommand.bind(testRun); + + try { + // 1) Swap controller so global `t` resolves to this IsolatedTestController + testRun.controller = self; + + // 2) Enable direct mode so commands inside the callback execute + // immediately instead of appending to the executionChain (which + // would deadlock since we're already inside a chain task) + self._directMode = true; + + // 3) Patch executeCommand to intercept selector/ClientFunction commands + testRun.executeCommand = async (command, cmdCallsite) => { + if (command.type === 'execute-selector') + return session._executeSelectorViaCDP(command); + + if (command.type === 'execute-client-function') + return session._executeClientFunctionViaCDP(command); + + // All other commands: use the original (or route through isolated session) + return originalExecuteCommand(command, cmdCallsite); + }; + + // 4) Run the user callback with testRunTracker context + // so that the global `t` proxy can resolve the test run + const wrappedFn = testRunTracker.addTrackingMarkerToFunction(testRun.id, fn); + + await wrappedFn(); + } + finally { + // 5) Restore originals + self._directMode = false; + testRun.controller = originalController; + testRun.executeCommand = originalExecuteCommand; + } + }; + }, callsite); + } + + shouldStop (command) { + return command === 'debug'; + } +} + +IsolatedTestController.API_LIST = getDelegatedAPIList(IsolatedTestController.prototype); + +delegateAPI(IsolatedTestController.prototype, IsolatedTestController.API_LIST, { useCurrentCtxAsHandler: true }); 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 269ff298038..a5984e3f437 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 @@ -71,6 +71,7 @@ export class BrowserClient { private _videoFramesBuffer: VideoFrameData[]; private _lastFrame: VideoFrameData | null; private _screencastFrameListenerAttached = false; + private _browserLevelClient: remoteChrome.ProtocolApi | null = null; public constructor (runtimeInfo: RuntimeInfo) { this._runtimeInfo = runtimeInfo; @@ -460,6 +461,70 @@ export class BrowserClient { } } + + private async _getBrowserLevelClient (): Promise { + if (this._browserLevelClient) + return this._browserLevelClient; + + // Connect to the browser-level CDP endpoint (not a specific tab) + // This is needed for Target domain commands like createBrowserContext + // @ts-ignore — chrome-remote-interface supports Version but types are incomplete + const version = await remoteChrome.Version({ port: this._port }); + // @ts-ignore — target can be a websocket URL string + const client = await remoteChrome({ target: version.webSocketDebuggerUrl }); + + this._browserLevelClient = client; + + return client; + } + + public async createIsolatedContext (): Promise<{ contextId: string, targetId: string, client: remoteChrome.ProtocolApi }> { + const browserClient = await this._getBrowserLevelClient(); + + const { browserContextId } = await browserClient.Target.createBrowserContext({}); + const { targetId } = await browserClient.Target.createTarget({ + url: 'about:blank', + browserContextId: browserContextId, + newWindow: true, + }); + + const target = await getTabById(this._port, targetId); + + if (!target) + throw new Error(`Failed to find newly created isolated target ${targetId}`); + + const client = await this._createClient(target, `isolated-${browserContextId}`); + + return { contextId: browserContextId, targetId, client }; + } + + public async disposeIsolatedContext (contextId: string): Promise { + const cacheKey = `isolated-${contextId}`; + + // Close the CDP WebSocket connection for the isolated target + const clientInfo = this._clients[cacheKey]; + + if (clientInfo) { + try { + await (clientInfo.client as any).close(); + } + catch (err) { + debugLog(err); + } + } + + try { + const browserClient = await this._getBrowserLevelClient(); + + await browserClient.Target.disposeBrowserContext({ browserContextId: contextId }); + } + catch (err) { + debugLog(err); + } + + delete this._clients[cacheKey]; + } + public async createMainWindowNativeAutomation (options: NativeAutomationInitOptions): Promise { const target = await getFirstTab(this._port); const client = await this._createClient(target); diff --git a/src/browser/provider/built-in/dedicated/chrome/index.js b/src/browser/provider/built-in/dedicated/chrome/index.js index e6bd8eefdaf..a3fc4d17904 100644 --- a/src/browser/provider/built-in/dedicated/chrome/index.js +++ b/src/browser/provider/built-in/dedicated/chrome/index.js @@ -10,6 +10,7 @@ import { } from './local-chrome'; import { GET_WINDOW_DIMENSIONS_INFO_SCRIPT } from '../../../utils/client-functions'; import { BrowserClient } from './cdp-client'; +import { NativeAutomationIsolatedWindow } from '../../../../../native-automation/isolated-window'; import { dispatchEvent as dispatchNativeAutomationEvent, navigateTo } from '../../../../../native-automation/utils/cdp'; import { chromeBrowserProviderLogger } from '../../../../../utils/debug-loggers'; import { EventType } from '../../../../../native-automation/types'; @@ -60,7 +61,8 @@ export default { await nativeAutomation.start(); - runtimeInfo.nativeAutomation = nativeAutomation; + runtimeInfo.nativeAutomation = nativeAutomation; + runtimeInfo.nativeAutomationOptions = nativeAutomationOptions; }, async _startChrome (startOptions, pageUrl) { @@ -221,6 +223,38 @@ export default { } }, + + async createIsolatedSession (browserId) { + const runtimeInfo = this.openedBrowsers[browserId]; + const browserClient = runtimeInfo.browserClient; + + const { contextId, targetId, client } = await browserClient.createIsolatedContext(); + + const options = runtimeInfo.nativeAutomationOptions || toNativeAutomationSetupOptions( + { nativeAutomation: true, serviceDomains: [], developmentMode: false, disableMultipleWindows: false }, + runtimeInfo.config.headless + ); + + const nativeAutomation = new NativeAutomationIsolatedWindow( + browserId, + targetId, + client, + options, + contextId + ); + + await nativeAutomation.start(); + + return { nativeAutomation, contextId, targetId }; + }, + + async disposeIsolatedSession (browserId, contextId) { + const runtimeInfo = this.openedBrowsers[browserId]; + const browserClient = runtimeInfo.browserClient; + + await browserClient.disposeIsolatedContext(contextId); + }, + supportNativeAutomation () { return true; }, diff --git a/src/client-functions/selectors/selector-builder.js b/src/client-functions/selectors/selector-builder.js index 11e7fc17b19..0b20987cc59 100644 --- a/src/client-functions/selectors/selector-builder.js +++ b/src/client-functions/selectors/selector-builder.js @@ -157,6 +157,8 @@ export default class SelectorBuilder extends ClientFunctionBuilder { visibilityCheck: !!this.options.visibilityCheck, timeout: this.options.timeout, strictError: this.options.strictError, + counterMode: !!this.options.counterMode, + getVisibleValueMode: !!this.options.getVisibleValueMode, }); } diff --git a/src/native-automation/isolated-window.ts b/src/native-automation/isolated-window.ts new file mode 100644 index 00000000000..ae48b487c3f --- /dev/null +++ b/src/native-automation/isolated-window.ts @@ -0,0 +1,48 @@ +// Stripped-down NativeAutomation for isolated sessions (no request interception) +import { ProtocolApi } from 'chrome-remote-interface'; +import { NativeAutomationBase } from './index'; +import { NativeAutomationInitOptions } from '../shared/types'; +import NativeAutomationApiBase from './api-base'; + +/** + * Thin wrapper over NativeAutomationBase for isolated browser sessions. + * Exposes the CDP client and target ID for direct command execution. + * Has no-op start()/dispose() since isolated tabs don't use the request + * interception pipeline or hammerhead proxy. + */ +export class NativeAutomationIsolatedWindow extends NativeAutomationBase { + public readonly browserContextId: string; + + public constructor (browserId: string, windowId: string, client: ProtocolApi, options: NativeAutomationInitOptions, browserContextId: string) { + super(browserId, windowId, client, options, false); + + this.browserContextId = browserContextId; + } + + // Expose the CDP client for direct command execution + public get cdpClient (): ProtocolApi { + return this._client; + } + + // Expose the target ID for CDP Browser.getWindowForTarget + public get targetId (): string { + return this.windowId; + } + + // Override apiSystems to return empty — isolated sessions use CDP-direct + // execution and do not need the request pipeline or session storage + public get apiSystems (): NativeAutomationApiBase[] { + return []; + } + + // No-op start — skip event listeners and pipeline startup + public async start (): Promise { + // Isolated sessions execute commands directly via CDP. + // No request interception or session storage sync needed. + } + + // No-op dispose — no pipeline to tear down + public async dispose (): Promise { + // Nothing to clean up — the browser context disposal handles tab cleanup + } +} diff --git a/src/test-run/commands/actions.js b/src/test-run/commands/actions.js index f960af8f2e0..b00125f2550 100644 --- a/src/test-run/commands/actions.js +++ b/src/test-run/commands/actions.js @@ -834,3 +834,4 @@ export class ReportCommand extends ActionCommandBase { ]; } } + diff --git a/src/test-run/commands/execute-client-function.js b/src/test-run/commands/execute-client-function.js index 02fde995bd7..2946a56ceab 100644 --- a/src/test-run/commands/execute-client-function.js +++ b/src/test-run/commands/execute-client-function.js @@ -39,6 +39,8 @@ export class ExecuteSelectorCommand extends ExecuteClientFunctionCommandBase { { name: 'needError' }, { name: 'index', defaultValue: 0 }, { name: 'strictError' }, + { name: 'counterMode', defaultValue: false }, + { name: 'getVisibleValueMode', defaultValue: false }, ]; } } diff --git a/src/test-run/index.ts b/src/test-run/index.ts index fd15e72b895..40d37f39e5a 100644 --- a/src/test-run/index.ts +++ b/src/test-run/index.ts @@ -137,6 +137,8 @@ import NativeAutomationRequestPipeline from '../native-automation/request-pipeli import { NativeAutomationBase } from '../native-automation'; import ReportDataLog from '../reporter/report-data-log'; import remoteChrome from 'chrome-remote-interface'; +import { IsolatedSession } from './isolated-session'; +import { IsolatedTestController } from '../api/test-controller/isolated'; const lazyRequire = require('import-lazy')(require); const ClientFunctionBuilder = lazyRequire('../client-functions/client-function-builder'); @@ -272,6 +274,7 @@ export default class TestRun extends AsyncEventEmitter { private readonly _roleProvider: RoleProvider; public readonly isNativeAutomation: boolean; public readonly isExperimentalMultipleWindows: boolean; + private _isolatedSessions: IsolatedSession[]; public constructor ({ test, browserConnection, screenshotCapturer, globalWarningLog, opts, messageBus, startRunExecutionTime, nativeAutomation }: TestRunInit) { super(); @@ -303,6 +306,7 @@ export default class TestRun extends AsyncEventEmitter { this.disableMultipleWindows = opts.disableMultipleWindows as boolean; this.isExperimentalMultipleWindows = opts.experimentalMultipleWindows as boolean; + this._isolatedSessions = []; this.requestTimeout = this._getRequestTimeout(test, opts); @@ -718,6 +722,8 @@ export default class TestRun extends AsyncEventEmitter { await this._enqueueSetBreakpointCommand(void 0, null, errStr); } + await this._disposeIsolatedSessions(); + await this.emit('before-done'); await this._internalExecuteCommand(new serviceCommands.TestDoneCommand()); @@ -1442,6 +1448,43 @@ export default class TestRun extends AsyncEventEmitter { return !disableMultipleWindows && !(test as LegacyTestRun).isLegacy && !!testRun.activeWindowId; } + + public async createIsolatedSession (): Promise { + const plugin = this.browserConnection.provider.plugin; + + const { nativeAutomation, contextId } = + await plugin.createIsolatedSession(this.browserConnection.id); + + const session = new IsolatedSession({ + parentTestRun: this, + nativeAutomation, + browserContextId: contextId, + }); + + session.controller = new IsolatedTestController(session); + + this._isolatedSessions.push(session); + + return session; + } + + private async _disposeIsolatedSessions (): Promise { + for (const session of this._isolatedSessions) { + try { + await session.dispose(); + } + catch (e) { + // Swallow errors during cleanup + } + } + + this._isolatedSessions = []; + } + + public getIsolatedSession (sessionId: string): IsolatedSession | undefined { + return this._isolatedSessions.find(s => s.id === sessionId); + } + public async initialize (): Promise { if (!this.test.skip) { await this._clearCookiesAndStorages(); diff --git a/src/test-run/isolated-session.ts b/src/test-run/isolated-session.ts new file mode 100644 index 00000000000..f1c4648f55d --- /dev/null +++ b/src/test-run/isolated-session.ts @@ -0,0 +1,1579 @@ +// IsolatedSession — manages CDP browser context lifecycle, command routing, and eval +import { nanoid } from 'nanoid'; +import { ProtocolApi } from 'chrome-remote-interface'; +import AsyncEventEmitter from '../utils/async-event-emitter'; +import { NativeAutomationIsolatedWindow } from '../native-automation/isolated-window'; +import { CommandBase, ActionCommandBase } from './commands/base.js'; +import COMMAND_TYPE from './commands/type'; +import { CallsiteRecord } from '@devexpress/callsite-record'; +import delay from '../utils/delay'; +import getFn from '../assertions/get-fn'; +import { ExternalAssertionLibraryError } from '../errors/test-run'; +import Role from '../role/role'; +import ROLE_PHASE from '../role/phase'; +import { navigateTo } from '../native-automation/utils/cdp'; + +import type TestRun from './index'; +import type { IsolatedTestController } from '../api/test-controller/isolated'; + +import ReExecutablePromise from '../utils/re-executable-promise'; + +import * as path from 'path'; +import * as fs from 'fs'; + +// Wait for page load after navigation +const PAGE_LOAD_TIMEOUT = 30000; + +// Drag step count for smooth drag operations +const DRAG_STEPS = 10; + +// Small delay between drag steps (~60fps) +const DRAG_STEP_DELAY = 16; + +/** + * IsolatedSession manages a fully isolated Chrome browser context created via CDP's + * Target.createBrowserContext(). Each session gets separate cookies, localStorage, + * sessionStorage, and service workers. All commands (click, type, scroll, etc.) execute + * directly via CDP without TestCafe's client-side driver injection. + * + * Created via t.openIsolatedSession() in test code. Automatically disposed when the + * parent test run ends. + */ +export class IsolatedSession extends AsyncEventEmitter { + public readonly id: string; + public readonly parentTestRun: TestRun; + public readonly nativeAutomation: NativeAutomationIsolatedWindow; + public readonly browserContextId: string; + + private _disposed: boolean; + private _cdpClient: ProtocolApi; + + // Execution context ID for iframe support (undefined = main frame) + private _currentContextId: number | undefined; + + // Configurable page load timeout (default: PAGE_LOAD_TIMEOUT constant) + private _pageLoadTimeout: number; + + public controller: IsolatedTestController | null; + + public constructor ({ parentTestRun, nativeAutomation, browserContextId }: { + parentTestRun: TestRun; + nativeAutomation: NativeAutomationIsolatedWindow; + browserContextId: string; + }) { + super(); + + this.id = `isolated-${nanoid()}`; + this.parentTestRun = parentTestRun; + this.nativeAutomation = nativeAutomation; + this.browserContextId = browserContextId; + this.controller = null; + this._disposed = false; + this._currentContextId = void 0; + this._pageLoadTimeout = PAGE_LOAD_TIMEOUT; + + // The CDP client for the isolated tab + this._cdpClient = nativeAutomation.cdpClient; + } + + /** + * Execute a TestCafe command directly via CDP. Dispatches to the appropriate + * CDP method based on command type (click → Input.dispatchMouseEvent, + * typeText → Input.insertText, navigateTo → Page.navigate, etc.). + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async executeCommand (command: CommandBase | ActionCommandBase, callsite?: CallsiteRecord | string): Promise { + if (this._disposed) + throw new Error('Isolated session has been disposed'); + + if (command.type === COMMAND_TYPE.wait) + return delay((command as any).timeout); + + if (command.type === COMMAND_TYPE.navigateTo) + return this._navigateTo((command as any).url); + + if (command.type === COMMAND_TYPE.getCookies) + return this._getCookies((command as any).cookies, (command as any).urls); + + if (command.type === COMMAND_TYPE.setCookies) { + const cookiesVal = (command as any).cookies; + const url = (command as any).url || ''; + + return this._setCookies(cookiesVal, url); + } + + if (command.type === COMMAND_TYPE.deleteCookies) + return this._deleteCookies((command as any).cookies, (command as any).urls); + + if (command.type === COMMAND_TYPE.useRole) + return this._useRole((command as any).role as Role); + + if (command.type === COMMAND_TYPE.executeExpression) + return this._evaluateExpression((command as any).expression); + + // --- Mouse interactions --- + + if (command.type === COMMAND_TYPE.click) + return this._cdpClick(command); + + if (command.type === COMMAND_TYPE.rightClick) + return this._cdpClick(command, 'right'); + + if (command.type === COMMAND_TYPE.doubleClick) + return this._cdpClick(command, 'left', 2); + + if (command.type === COMMAND_TYPE.hover) + return this._cdpHover(command); + + if (command.type === COMMAND_TYPE.drag) + return this._cdpDrag(command); + + if (command.type === COMMAND_TYPE.dragToElement) + return this._cdpDragToElement(command); + + // --- Keyboard --- + + if (command.type === COMMAND_TYPE.typeText) + return this._cdpTypeText(command); + + if (command.type === COMMAND_TYPE.pressKey) + return this._cdpPressKey(command); + + if (command.type === COMMAND_TYPE.selectText) + return this._cdpSelectText(command); + + // --- Scroll --- + + if (command.type === COMMAND_TYPE.scroll) + return this._cdpScroll(command); + + if (command.type === COMMAND_TYPE.scrollBy) + return this._cdpScrollBy(command); + + if (command.type === COMMAND_TYPE.scrollIntoView) + return this._cdpScrollIntoView(command); + + // --- Events --- + + if (command.type === COMMAND_TYPE.dispatchEvent) + return this._cdpDispatchEvent(command); + + // --- Assertions --- + + if (command.type === COMMAND_TYPE.assertion) + return this._executeAssertion(command as any, callsite); + + // --- Selector-based commands --- + + if (command.type === COMMAND_TYPE.executeSelector) + return this._executeSelectorViaCDP(command as any); + + if (command.type === COMMAND_TYPE.executeClientFunction) + return this._executeClientFunctionViaCDP(command as any); + + throw new Error( + `Command '${command.type}' is not supported in isolated sessions. ` + + 'Supported: click, rightClick, doubleClick, hover, drag, dragToElement, ' + + 'typeText, pressKey, selectText, scroll, scrollBy, scrollIntoView, ' + + 'dispatchEvent, navigateTo, wait, eval, expect, useRole, ' + + 'getCookies, setCookies, deleteCookies, takeScreenshot, takeElementScreenshot, ' + + 'setFilesToUpload, clearUpload, setPageLoadTimeout, ' + + 'maximizeWindow, resizeWindow, switchToIframe, switchToMainWindow, ' + + 'executeSelector, executeClientFunction.' + ); + } + + // ===================================================================== + // Assertion handling (with ReExecutablePromise support) + // ===================================================================== + + private static readonly _ASSERTION_RETRY_DELAY = 200; + + private async _executeAssertion (command: any, callsite?: CallsiteRecord | string): Promise { + const reExecutable = command.actual instanceof ReExecutablePromise ? command.actual : null; + const timeout = command.options?.timeout || 3000; + const startTime = Date.now(); + + // eslint-disable-next-line no-constant-condition + while (true) { + // Re-resolve the ReExecutablePromise on each iteration + if (reExecutable) { + try { + command.actual = await reExecutable._reExecute(); + } + catch (err: any) { + // Selector threw (e.g. element not found) — retry if time remains + if (Date.now() - startTime >= timeout) { + err.callsite = callsite; + + throw err; + } + + await delay(IsolatedSession._ASSERTION_RETRY_DELAY); + continue; + } + } + + const fn = getFn(command); + + try { + fn(); + + return; // Assertion passed + } + catch (err: any) { + if (!reExecutable || Date.now() - startTime >= timeout) { + if (err.name === 'AssertionError' || err.constructor?.name === 'AssertionError') + throw new ExternalAssertionLibraryError(err, callsite as CallsiteRecord); + + throw err; + } + + await delay(IsolatedSession._ASSERTION_RETRY_DELAY); + } + } + } + + // ===================================================================== + // Selector execution via CDP + // ===================================================================== + + // Execute a selector command directly in the isolated tab via CDP. + // Returns a number (counter mode) or a plain snapshot object (snapshot mode). + public async _executeSelectorViaCDP (command: any): Promise { + const selector = command; + + if (!selector.apiFnChain || !selector.apiFnChain.length) + throw new Error('Isolated session: selector command has no apiFnChain'); + + // Counter mode: just count matching elements + if (selector.counterMode) { + const countExpr = this._compileSelectorExpression(selector, 'count'); + const countResult = await this.evaluateExpression(countExpr); + + // Wrap in array: the replicator's decode() expects encode()-format + // (encode wraps values in [value], decode returns references[0]) + return [countResult]; + } + + // Snapshot mode: find element and extract all properties + const snapshotExpr = this._compileSelectorExpression(selector, 'snapshot'); + const result = await this.evaluateExpression(snapshotExpr); + + // getVisibleValueMode: return null without error when element not found + if (result === null && selector.getVisibleValueMode) + return [null]; + + if (result === null && selector.needError) + throw new Error(`Isolated session: element not found for selector ${selector.apiFnChain.join('')}`); + + return [result]; + } + + // Compile a selector's apiFnChain into a JS expression. + // returnMode: 'count' returns nodes.length, 'snapshot' returns element snapshot object + private _compileSelectorExpression (selector: any, returnMode: 'count' | 'snapshot'): string { + const chain = selector.apiFnChain; + const chainEntry = chain[0]; + const cssMatch = chainEntry.match(/Selector\s*\(\s*'((?:[^'\\]|\\.)*)'\s*\)/) + || chainEntry.match(/Selector\s*\(\s*"((?:[^"\\]|\\.)*)"\s*\)/); + + if (!cssMatch) { + if (chainEntry.includes('[function]')) + throw new Error(`Isolated sessions only support CSS string selectors. Function-form selector not supported: ${chainEntry}`); + + throw new Error(`Isolated sessions only support CSS string selectors. Cannot parse selector: ${chainEntry}`); + } + + const baseCss = cssMatch[1]; + + // Build chain steps + const steps: string[] = []; + + for (let i = 1; i < chain.length; i++) { + const parsed = this._parseChainMethod(chain[i]); + + steps.push(this._chainStepToJS(parsed)); + } + + const nodesInit = `var nodes = Array.from(document.querySelectorAll(${JSON.stringify(baseCss)}));`; + const chainCode = steps.length ? ` ${steps.join(' ')}` : ''; + + if (returnMode === 'count') + return `(function() { ${nodesInit}${chainCode} return nodes.length; })()`; + + // Snapshot mode: return a full element snapshot from the first matched node + return `(function() { + ${nodesInit}${chainCode} + var el = nodes[0]; + if (!el) return null; + var s = window.getComputedStyle(el); + var rect = el.getBoundingClientRect(); + var attrs = {}; + if (el.attributes) { for (var i = 0; i < el.attributes.length; i++) { attrs[el.attributes[i].name] = el.attributes[i].value; } } + var cls = el.className ? el.className.toString().split(/\\s+/).filter(function(c){return c;}) : []; + var isVis = el.nodeType === 1 && !(rect.width === 0 && rect.height === 0) && s.display !== 'none' && s.visibility !== 'hidden'; + var style = {}; + for (var j = 0; j < s.length; j++) { style[s[j]] = s.getPropertyValue(s[j]); } + return { + nodeType: el.nodeType, + textContent: el.textContent, + childNodeCount: el.childNodes.length, + hasChildNodes: el.childNodes.length > 0, + childElementCount: el.children ? el.children.length : 0, + hasChildElements: el.children ? el.children.length > 0 : false, + tagName: el.tagName ? el.tagName.toLowerCase() : null, + visible: isVis, + focused: document.activeElement === el, + attributes: attrs, + boundingClientRect: { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height }, + classNames: cls, + style: style, + innerText: el.innerText || '', + namespaceURI: el.namespaceURI || null, + id: el.id || '', + value: el.value !== undefined ? el.value : null, + checked: el.checked !== undefined ? !!el.checked : null, + selected: el.selected !== undefined ? !!el.selected : null, + selectedIndex: el.selectedIndex !== undefined ? el.selectedIndex : null, + scrollWidth: el.scrollWidth || 0, + scrollHeight: el.scrollHeight || 0, + scrollLeft: el.scrollLeft || 0, + scrollTop: el.scrollTop || 0, + offsetWidth: el.offsetWidth || 0, + offsetHeight: el.offsetHeight || 0, + offsetLeft: el.offsetLeft || 0, + offsetTop: el.offsetTop || 0, + clientWidth: el.clientWidth || 0, + clientHeight: el.clientHeight || 0, + clientLeft: el.clientLeft || 0, + clientTop: el.clientTop || 0 + }; + })()`; + } + + // ===================================================================== + // ClientFunction execution via CDP + // ===================================================================== + + public async _executeClientFunctionViaCDP (command: any): Promise { + const fnCode = command.fnCode; + + if (!fnCode) + throw new Error('Isolated session: ClientFunction command has no fnCode'); + + // The fnCode is a compiled IIFE. It expects __dependencies$ to be available. + // For simple ClientFunctions (no dependencies), we can just wrap and execute. + // For complex ones, we inject dependencies first. + const depsCode = command.dependencies + ? `var __dependencies$ = ${JSON.stringify(command.dependencies)};` + : 'var __dependencies$ = {};'; + + const expression = `(function() { ${depsCode} var __f$ = ${fnCode}; return typeof __f$ === 'function' ? __f$() : __f$; })()`; + + // Wrap in array: the replicator's decode() expects encode()-format + const result = await this.evaluateExpression(expression); + + return [result]; + } + + // ===================================================================== + // Navigation + // ===================================================================== + + private async _navigateTo (url: string): Promise { + await navigateTo(this._cdpClient, url); + await this._waitForPageLoad(); + } + + private async _waitForPageLoad (): Promise { + const startTime = Date.now(); + const timeout = this._pageLoadTimeout; + + while (Date.now() - startTime < timeout) { + try { + const result = await this._cdpClient.Runtime.evaluate({ + expression: 'document.readyState', + returnByValue: true, + ...this._contextIdParam(), + }); + + if (result.result.value === 'complete') + return; + } + catch (e) { + // Page might be mid-navigation (context destroyed) — retry + } + + await delay(100); + } + + throw new Error(`Isolated session: page did not reach readyState 'complete' within ${timeout}ms`); + } + + // ===================================================================== + // Expression evaluation + // ===================================================================== + + /** Evaluate a JavaScript expression in the isolated tab via CDP Runtime.evaluate. */ + public async evaluateExpression (expression: string): Promise { + const result = await this._cdpClient.Runtime.evaluate({ + expression, + returnByValue: true, + awaitPromise: true, + ...this._contextIdParam(), + }); + + if (result.exceptionDetails) { + const errText = result.exceptionDetails.exception?.description + || result.exceptionDetails.text + || 'Expression evaluation failed'; + + throw new Error(`Isolated session eval error: ${errText}`); + } + + return result.result.value; + } + + // Keep underscore alias for backward compatibility with IsolatedTestController._eval$ + public async _evaluateExpression (expression: string): Promise { + return this.evaluateExpression(expression); + } + + // Returns contextId param for Runtime.evaluate when inside an iframe + private _contextIdParam (): { contextId?: number } { + return this._currentContextId !== void 0 ? { contextId: this._currentContextId } : {}; + } + + // ===================================================================== + // Mouse interactions + // ===================================================================== + + private async _getElementCenter (command: any): Promise<{ x: number; y: number }> { + const selectorQuery = this._getSelectorForQuerySelector(command.selector); + + return await this.evaluateExpression(` + (function() { + const el = ${selectorQuery}; + if (!el) throw new Error('Element not found: ' + ${JSON.stringify(String(command.selector))}); + el.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = el.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + })() + `) as { x: number; y: number }; + } + + // Click an element via CDP input dispatch + private async _cdpClick (command: any, button: 'left' | 'right' = 'left', clickCount = 1): Promise { + const coords = await this._getElementCenter(command); + + // Move to element first (triggers hover/mouseenter events) + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mouseMoved', x: coords.x, y: coords.y, + }); + + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mousePressed', x: coords.x, y: coords.y, button, clickCount, + }); + + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mouseReleased', x: coords.x, y: coords.y, button, clickCount, + }); + } + + // Hover over an element (mouseMoved without press) + private async _cdpHover (command: any): Promise { + const coords = await this._getElementCenter(command); + + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mouseMoved', x: coords.x, y: coords.y, + }); + } + + // Drag an element by pixel offset + private async _cdpDrag (command: any): Promise { + const start = await this._getElementCenter(command); + const endX = start.x + ((command as any).dragOffsetX || 0); + const endY = start.y + ((command as any).dragOffsetY || 0); + + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mouseMoved', x: start.x, y: start.y, + }); + + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mousePressed', x: start.x, y: start.y, button: 'left', clickCount: 1, + }); + + // Move in steps for smooth drag (frameworks often need intermediate events) + for (let i = 1; i <= DRAG_STEPS; i++) { + const p = i / DRAG_STEPS; + + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mouseMoved', + x: start.x + (endX - start.x) * p, + y: start.y + (endY - start.y) * p, + }); + + await delay(DRAG_STEP_DELAY); + } + + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mouseReleased', x: endX, y: endY, button: 'left', clickCount: 1, + }); + } + + // Drag from one element to another + private async _cdpDragToElement (command: any): Promise { + const start = await this._getElementCenter(command); + const destQuery = this._getSelectorForQuerySelector((command as any).destinationSelector); + + const end = await this.evaluateExpression(` + (function() { + const el = ${destQuery}; + if (!el) throw new Error('Destination element not found for dragToElement'); + el.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = el.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + })() + `) as { x: number; y: number }; + + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mouseMoved', x: start.x, y: start.y, + }); + + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mousePressed', x: start.x, y: start.y, button: 'left', clickCount: 1, + }); + + for (let i = 1; i <= DRAG_STEPS; i++) { + const p = i / DRAG_STEPS; + + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mouseMoved', + x: start.x + (end.x - start.x) * p, + y: start.y + (end.y - start.y) * p, + }); + + await delay(DRAG_STEP_DELAY); + } + + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mouseReleased', x: end.x, y: end.y, button: 'left', clickCount: 1, + }); + } + + // ===================================================================== + // Keyboard interactions + // ===================================================================== + + // Type text via CDP input dispatch + private async _cdpTypeText (command: any): Promise { + const text = command.text as string; + + // Click the element to focus it + await this._cdpClick(command); + + // Select existing text if replace option is set + if ((command as any).options?.replace) { + const selectorQuery = this._getSelectorForQuerySelector(command.selector); + + await this.evaluateExpression(` + (function() { + const el = ${selectorQuery}; + if (!el) return; + if (typeof el.select === 'function') + el.select(); + else if (typeof el.setSelectionRange === 'function') + el.setSelectionRange(0, el.value.length); + else + document.execCommand('selectAll'); + })() + `); + } + + // Insert the text + await this._cdpClient.Input.insertText({ text }); + } + + // Press key combo via CDP — supports modifiers (e.g. 'ctrl+a', 'shift+Tab') + private async _cdpPressKey (command: any): Promise { + const keyString = command.keys as string; + // Support space-separated combos like "ctrl+a ctrl+c" + const combos = keyString.split(/\s+/); + + const MODIFIER_MAP: Record = { + 'alt': { key: 'Alt', code: 'AltLeft', keyCode: 18, flag: 1 }, + 'ctrl': { key: 'Control', code: 'ControlLeft', keyCode: 17, flag: 2 }, + 'control': { key: 'Control', code: 'ControlLeft', keyCode: 17, flag: 2 }, + 'meta': { key: 'Meta', code: 'MetaLeft', keyCode: 91, flag: 4 }, + 'command': { key: 'Meta', code: 'MetaLeft', keyCode: 91, flag: 4 }, + 'shift': { key: 'Shift', code: 'ShiftLeft', keyCode: 16, flag: 8 }, + }; + + // Map of known keyboard shortcut commands + const SHORTCUT_COMMANDS: Record = { + 'ctrl+a': ['selectAll'], + 'ctrl+c': ['copy'], + 'ctrl+v': ['paste'], + 'ctrl+x': ['cut'], + 'ctrl+z': ['undo'], + 'ctrl+y': ['redo'], + }; + + // Map key name to CDP code and keyCode + const KEY_CODE_MAP: Record = { + 'enter': { code: 'Enter', keyCode: 13 }, + 'tab': { code: 'Tab', keyCode: 9 }, + 'escape': { code: 'Escape', keyCode: 27 }, + 'backspace': { code: 'Backspace', keyCode: 8 }, + 'delete': { code: 'Delete', keyCode: 46 }, + 'space': { code: 'Space', keyCode: 32 }, + 'arrowup': { code: 'ArrowUp', keyCode: 38 }, + 'arrowdown': { code: 'ArrowDown', keyCode: 40 }, + 'arrowleft': { code: 'ArrowLeft', keyCode: 37 }, + 'arrowright': { code: 'ArrowRight', keyCode: 39 }, + 'home': { code: 'Home', keyCode: 36 }, + 'end': { code: 'End', keyCode: 35 }, + 'pageup': { code: 'PageUp', keyCode: 33 }, + 'pagedown': { code: 'PageDown', keyCode: 34 }, + }; + + for (const combo of combos) { + const keys = combo.split('+').map((k: string) => k.trim()); + + const modifiers: Array<{ key: string; code: string; keyCode: number; flag: number }> = []; + const regularKeys: string[] = []; + let modifiersBitmask = 0; + + for (const key of keys) { + const mod = MODIFIER_MAP[key.toLowerCase()]; + + if (mod) { + modifiers.push(mod); + modifiersBitmask |= mod.flag; + } + else + regularKeys.push(key); + } + + // Look up shortcut commands for this combo + const comboLower = combo.toLowerCase(); + const cmdCommands = SHORTCUT_COMMANDS[comboLower] || []; + + // Hold modifiers down + for (const mod of modifiers) { + await this._cdpClient.Input.dispatchKeyEvent({ + type: 'rawKeyDown', + key: mod.key, + code: mod.code, + windowsVirtualKeyCode: mod.keyCode, + nativeVirtualKeyCode: mod.keyCode, + modifiers: modifiersBitmask, + }); + } + + // Press and release regular keys + for (const key of regularKeys) { + const keyLower = key.toLowerCase(); + const keyInfo = KEY_CODE_MAP[keyLower]; + const code = keyInfo?.code || `Key${key.toUpperCase()}`; + const keyCode = keyInfo?.keyCode || key.toUpperCase().charCodeAt(0); + + await this._cdpClient.Input.dispatchKeyEvent({ + type: 'rawKeyDown', + key, + code, + windowsVirtualKeyCode: keyCode, + nativeVirtualKeyCode: keyCode, + modifiers: modifiersBitmask, + commands: cmdCommands, + } as any); + + await this._cdpClient.Input.dispatchKeyEvent({ + type: 'keyUp', + key, + code, + windowsVirtualKeyCode: keyCode, + nativeVirtualKeyCode: keyCode, + modifiers: modifiersBitmask, + }); + } + + // Release modifiers (reverse order) + for (const mod of [...modifiers].reverse()) { + await this._cdpClient.Input.dispatchKeyEvent({ + type: 'keyUp', + key: mod.key, + code: mod.code, + windowsVirtualKeyCode: mod.keyCode, + nativeVirtualKeyCode: mod.keyCode, + }); + } + } + } + + // Select text in an input/textarea or content-editable element + private async _cdpSelectText (command: any): Promise { + const selectorQuery = this._getSelectorForQuerySelector(command.selector); + const startPos = (command as any).startPos ?? 0; + const endPos = (command as any).endPos; + const hasEndPos = endPos !== null && endPos !== void 0; + + const endExpr = hasEndPos + ? String(endPos) + : 'el.value !== null && el.value !== void 0 ? el.value.length : el.textContent.length'; + + await this.evaluateExpression(` + (function() { + const el = ${selectorQuery}; + if (!el) throw new Error('Element not found for selectText'); + el.focus(); + const end = ${endExpr}; + if (typeof el.setSelectionRange === 'function') { + el.setSelectionRange(${startPos}, end); + } + else { + const range = document.createRange(); + range.selectNodeContents(el); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + } + })() + `); + } + + // ===================================================================== + // Scroll + // ===================================================================== + + private async _cdpScroll (command: any): Promise { + if (command.selector) { + const selectorQuery = this._getSelectorForQuerySelector(command.selector); + const position = (command as any).position || 'center'; + + await this.evaluateExpression(` + (function() { + const el = ${selectorQuery}; + if (!el) throw new Error('Element not found for scroll'); + el.scrollIntoView({ block: ${JSON.stringify(position)}, inline: ${JSON.stringify(position)} }); + })() + `); + } + else { + const x = (command as any).x || 0; + const y = (command as any).y || 0; + + await this.evaluateExpression(`window.scrollTo(${x}, ${y})`); + } + } + + private async _cdpScrollBy (command: any): Promise { + const byX = (command as any).byX || 0; + const byY = (command as any).byY || 0; + + if (command.selector) { + const selectorQuery = this._getSelectorForQuerySelector(command.selector); + + await this.evaluateExpression(` + (function() { + const el = ${selectorQuery}; + if (!el) throw new Error('Element not found for scrollBy'); + el.scrollBy(${byX}, ${byY}); + })() + `); + } + else + await this.evaluateExpression(`window.scrollBy(${byX}, ${byY})`); + } + + private async _cdpScrollIntoView (command: any): Promise { + const selectorQuery = this._getSelectorForQuerySelector(command.selector); + + await this.evaluateExpression(` + (function() { + const el = ${selectorQuery}; + if (!el) throw new Error('Element not found for scrollIntoView'); + el.scrollIntoView({ block: 'center', inline: 'center' }); + })() + `); + } + + // ===================================================================== + // Events + // ===================================================================== + + private async _cdpDispatchEvent (command: any): Promise { + const selectorQuery = this._getSelectorForQuerySelector(command.selector); + const eventName = (command as any).eventName; + const eventOptions = (command as any).options || {}; + + await this.evaluateExpression(` + (function() { + const el = ${selectorQuery}; + if (!el) throw new Error('Element not found for dispatchEvent'); + const opts = Object.assign({ bubbles: true, cancelable: true }, ${JSON.stringify(eventOptions)}); + const event = new Event(${JSON.stringify(eventName)}, opts); + el.dispatchEvent(event); + })() + `); + } + + // ===================================================================== + // Screenshots + // ===================================================================== + + /** Capture a full-page PNG screenshot via CDP Page.captureScreenshot. Returns the file path. */ + public async takeScreenshot (filePath?: string): Promise { + // Ensure Page domain is enabled (isolated windows don't call start()) + await (this._cdpClient as any).Page.enable(); + + const { data } = await (this._cdpClient as any).Page.captureScreenshot({ format: 'png' }); + + const screenshotDir = filePath + ? path.dirname(filePath) + : path.join(process.cwd(), 'artifacts', 'screenshots'); + + fs.mkdirSync(screenshotDir, { recursive: true }); + + const filename = filePath + ? path.basename(filePath) + : `isolated-${Date.now()}.png`; + + const fullPath = path.join(screenshotDir, filename); + + fs.writeFileSync(fullPath, Buffer.from(data, 'base64')); + + return fullPath; + } + + /** Capture a screenshot of a specific element, clipped to its bounding rect. Returns the file path. */ + public async takeElementScreenshot (selector: any, filePath?: string): Promise { + const selectorQuery = this._getSelectorForQuerySelector(selector); + + // Get element bounding rect + const bounds = await this.evaluateExpression(` + (function() { + const el = ${selectorQuery}; + if (!el) throw new Error('Element not found for takeElementScreenshot'); + const rect = el.getBoundingClientRect(); + return JSON.stringify({ + x: rect.x + window.scrollX, + y: rect.y + window.scrollY, + width: rect.width, + height: rect.height + }); + })() + `) as string; + + const clip = JSON.parse(bounds); + + clip.scale = 1; + + await (this._cdpClient as any).Page.enable(); + + const { data } = await (this._cdpClient as any).Page.captureScreenshot({ + format: 'png', + clip, + }); + + const screenshotDir = filePath + ? path.dirname(filePath) + : path.join(process.cwd(), 'artifacts', 'screenshots'); + + fs.mkdirSync(screenshotDir, { recursive: true }); + + const filename = filePath + ? path.basename(filePath) + : `isolated-element-${Date.now()}.png`; + + const fullPath = path.join(screenshotDir, filename); + + fs.writeFileSync(fullPath, Buffer.from(data, 'base64')); + + return fullPath; + } + + // ===================================================================== + // File upload + // ===================================================================== + + /** Set files on a element via CDP DOM.setFileInputFiles. */ + public async setFilesToUpload (selector: any, filePaths: string | string[]): Promise { + const selectorQuery = this._getSelectorForQuerySelector(selector); + const files = Array.isArray(filePaths) ? filePaths : [filePaths]; + + // Resolve relative paths to absolute + const resolvedFiles = files.map(f => path.resolve(f)); + + // Verify all files exist + for (const f of resolvedFiles) { + if (!fs.existsSync(f)) + throw new Error(`setFilesToUpload: file not found: ${f}`); + } + + // Get a remote object reference to the file input element + const evalResult = await this._cdpClient.Runtime.evaluate({ + expression: `(function() { const el = ${selectorQuery}; if (!el) throw new Error('File input element not found'); if (el.tagName !== 'INPUT' || el.type !== 'file') throw new Error('Element is not a file input: ' + el.tagName + '[type=' + el.type + ']'); return el; })()`, + returnByValue: false, + ...this._contextIdParam(), + }); + + if (evalResult.exceptionDetails) + throw new Error(evalResult.exceptionDetails.exception?.description || 'Failed to validate file input'); + + const objectId = evalResult.result.objectId; + + if (!objectId) + throw new Error('setFilesToUpload: could not get remote object reference for file input'); + + await (this._cdpClient as any).DOM.setFileInputFiles({ + files: resolvedFiles, + objectId, + }); + } + + /** Clear files from a element by assigning an empty DataTransfer. */ + public async clearUpload (selector: any): Promise { + const selectorQuery = this._getSelectorForQuerySelector(selector); + + // Use DataTransfer to assign an empty FileList (el.value='' doesn't clear files in Chrome) + await this.evaluateExpression(` + (function() { + const el = ${selectorQuery}; + if (!el) throw new Error('File input element not found'); + if (el.tagName !== 'INPUT' || el.type !== 'file') + throw new Error('Element is not a file input: ' + el.tagName + '[type=' + el.type + ']'); + const dt = new DataTransfer(); + el.files = dt.files; + el.dispatchEvent(new Event('change', { bubbles: true })); + })() + `); + } + + // Extract the CSS string from a compiled Selector object (for DOM.querySelector) + private _extractCssFromSelector (selector: any): string { + if (typeof selector === 'string') + return selector; + + if (selector.apiFnChain && selector.apiFnChain.length > 0) { + const chainEntry = selector.apiFnChain[0]; + const cssMatch = chainEntry.match(/Selector\s*\(\s*'((?:[^'\\]|\\.)*)'\s*\)/) + || chainEntry.match(/Selector\s*\(\s*"((?:[^"\\]|\\.)*)"\s*\)/); + + if (cssMatch) + return cssMatch[1]; + } + + throw new Error('setFilesToUpload: could not extract CSS selector from Selector object. Use a plain CSS string.'); + } + + // ===================================================================== + // Page load timeout + // ===================================================================== + + /** Set the timeout (ms) for navigateTo page load waits. Default: 30000ms. */ + public setPageLoadTimeout (timeout: number): void { + this._pageLoadTimeout = timeout; + } + + // ===================================================================== + // Window management + // ===================================================================== + + // Set window bounds (position + size) via CDP + public async setWindowBounds (bounds: { left?: number; top?: number; width?: number; height?: number }): Promise { + const browserClient = await this._getBrowserLevelClient(); + const { windowId } = await browserClient.Browser.getWindowForTarget({ targetId: this.nativeAutomation.targetId }); + + await browserClient.Browser.setWindowBounds({ windowId, bounds }); + } + + public async maximizeWindow (): Promise { + const browserClient = await this._getBrowserLevelClient(); + const { windowId } = await browserClient.Browser.getWindowForTarget({ targetId: this.nativeAutomation.targetId }); + + await browserClient.Browser.setWindowBounds({ windowId, bounds: { windowState: 'maximized' } }); + } + + public async resizeWindow (width: number, height: number): Promise { + const browserClient = await this._getBrowserLevelClient(); + const { windowId } = await browserClient.Browser.getWindowForTarget({ targetId: this.nativeAutomation.targetId }); + + // Ensure window is in normal state first (can't resize maximized windows) + await browserClient.Browser.setWindowBounds({ windowId, bounds: { windowState: 'normal' } }); + await browserClient.Browser.setWindowBounds({ windowId, bounds: { width, height } }); + } + + // Get a browser-level CDP client (reuse the parent test run's provider) + private async _getBrowserLevelClient (): Promise { + const plugin = this.parentTestRun.browserConnection.provider.plugin; + + return plugin.openedBrowsers[this.parentTestRun.browserConnection.id].browserClient._getBrowserLevelClient(); + } + + // ===================================================================== + // Iframe support + // ===================================================================== + + /** Switch the eval/command context into an iframe via CDP DOM.describeNode + Page.createIsolatedWorld. */ + public async switchToIframe (selector: any): Promise { + const selectorQuery = this._getSelectorForQuerySelector(selector); + + // Verify the element is an iframe + await this.evaluateExpression(` + (function() { + const el = ${selectorQuery}; + if (!el) throw new Error('Iframe element not found'); + if (el.tagName !== 'IFRAME' && el.tagName !== 'FRAME') + throw new Error('Element is not an iframe: ' + el.tagName); + })() + `); + + // Use DOM domain to find the iframe node and its frameId + await (this._cdpClient as any).DOM.enable(); + + const { root } = await (this._cdpClient as any).DOM.getDocument({ depth: 0 }); + + const cssSelector = this._extractCssFromSelectorQuery(selectorQuery); + const { nodeId } = await (this._cdpClient as any).DOM.querySelector({ nodeId: root.nodeId, selector: cssSelector }); + + if (!nodeId) + throw new Error(`Could not find iframe node via DOM.querySelector: ${cssSelector}`); + + const { node } = await (this._cdpClient as any).DOM.describeNode({ nodeId }); + const frameId = node.frameId; + + if (!frameId) + throw new Error('Could not determine frameId for the iframe element'); + + // Create an isolated world in the iframe's frame — gives us a contextId + // that targets the iframe's document + const { executionContextId } = await (this._cdpClient as any).Page.createIsolatedWorld({ + frameId, + worldName: 'testcafe-isolated-iframe', + grantUniveralAccess: true, + }); + + this._currentContextId = executionContextId; + + await (this._cdpClient as any).DOM.disable(); + } + + // Switch back to the main frame's evaluation context + public async switchToMainWindow (): Promise { + this._currentContextId = void 0; + } + + // Extract raw CSS selector string from a "document.querySelector(...)" expression + private _extractCssFromSelectorQuery (selectorQuery: string): string { + const match = selectorQuery.match(/document\.querySelector\(["'](.+?)["']\)/); + + if (match) return match[1]; + + // IIFE with querySelectorAll (chained selector) — extract base CSS + const qsaMatch = selectorQuery.match(/document\.querySelectorAll\(["'](.+?)["']\)/); + + if (qsaMatch) return qsaMatch[1]; + + return ''; + } + + // ===================================================================== + // HTTP Auth + // ===================================================================== + + // Set HTTP basic auth headers for the isolated session + public async setHttpAuth (username: string, password: string): Promise { + const encoded = Buffer.from(`${username}:${password}`).toString('base64'); + + await this._cdpClient.Network.setExtraHTTPHeaders({ + headers: { Authorization: `Basic ${encoded}` }, + }); + } + + // ===================================================================== + // Selector utilities + // ===================================================================== + + // Types for parsed selector chain arguments + private static _PARSED_ARG_STRING = 'string' as const; + private static _PARSED_ARG_NUMBER = 'number' as const; + private static _PARSED_ARG_REGEX = 'regex' as const; + + // Convert a TestCafe compiled selector to a JS expression that returns a DOM element + private _getSelectorForQuerySelector (selector: any): string { + if (!selector) + return 'document.activeElement'; + + // Raw CSS string + if (typeof selector === 'string') + return `document.querySelector(${JSON.stringify(selector)})`; + + // TestCafe compiled selector with apiFnChain + if (selector.apiFnChain && selector.apiFnChain.length > 0) { + const chainEntry = selector.apiFnChain[0]; + const cssMatch = chainEntry.match(/Selector\s*\(\s*'((?:[^'\\]|\\.)*)'\s*\)/) + || chainEntry.match(/Selector\s*\(\s*"((?:[^"\\]|\\.)*)"\s*\)/); + + if (!cssMatch) { + if (chainEntry.includes('[function]')) { + throw new Error( + 'Isolated sessions only support CSS string selectors. ' + + `Function-form selector not supported: ${chainEntry}` + ); + } + + throw new Error( + 'Isolated sessions only support CSS string selectors. ' + + `Cannot parse selector: ${chainEntry}` + ); + } + + const baseCss = cssMatch[1]; + + // Simple CSS-only selector (no chaining) — fast path + if (selector.apiFnChain.length === 1) + return `document.querySelector(${JSON.stringify(baseCss)})`; + + // Chained selector — compile to JS IIFE + const steps: string[] = []; + + for (let i = 1; i < selector.apiFnChain.length; i++) { + const parsed = this._parseChainMethod(selector.apiFnChain[i]); + + steps.push(this._chainStepToJS(parsed)); + } + + return `(function() { var nodes = Array.from(document.querySelectorAll(${JSON.stringify(baseCss)})); ${steps.join(' ')} return nodes[0] || null; })()`; + } + + // Fallback: try .value property + if (selector.value && typeof selector.value === 'string') { + const match = selector.value.match(/Selector\s*\(\s*['"]([^'"]+)['"]\s*\)/); + + if (match) + return `document.querySelector(${JSON.stringify(match[1])})`; + } + + // Fallback: fnArgs + if (selector.fnArgs && selector.fnArgs[0] && typeof selector.fnArgs[0] === 'string') + return `document.querySelector(${JSON.stringify(selector.fnArgs[0])})`; + + return 'document.activeElement'; + } + + // ===================================================================== + // Selector chain parsing + // ===================================================================== + + // Parse a chain entry like ".withText('hello')" into { method, args } + private _parseChainMethod (entry: string): { method: string; args: Array<{ type: string; value?: any; source?: string; flags?: string }> } { + const match = entry.match(/^\.(\w+)\(([\s\S]*)\)$/); + + if (!match) + throw new Error(`Cannot parse selector chain method: ${entry}`); + + const method = match[1]; + const rawArgs = match[2].trim(); + + if (!rawArgs) + return { method, args: [] }; + + return { method, args: this._parseChainArgs(rawArgs) }; + } + + // Parse comma-separated arguments: strings, numbers, regexes + private _parseChainArgs (rawArgs: string): Array<{ type: string; value?: any; source?: string; flags?: string }> { + const args: Array<{ type: string; value?: any; source?: string; flags?: string }> = []; + let remaining = rawArgs.trim(); + + while (remaining) { + remaining = remaining.replace(/^,\s*/, ''); + + if (!remaining) break; + + // Single-quoted string + const strMatch = remaining.match(/^'((?:[^'\\]|\\.)*)'/); + + if (strMatch) { + args.push({ type: IsolatedSession._PARSED_ARG_STRING, value: strMatch[1].replace(/\\'/g, "'") }); + remaining = remaining.slice(strMatch[0].length).trim(); + continue; + } + + // Regex literal + const reMatch = remaining.match(/^\/((?:[^/\\]|\\.)*)\/([gimsuy]*)/); + + if (reMatch) { + args.push({ type: IsolatedSession._PARSED_ARG_REGEX, source: reMatch[1], flags: reMatch[2] }); + remaining = remaining.slice(reMatch[0].length).trim(); + continue; + } + + // Number + const numMatch = remaining.match(/^(-?\d+(?:\.\d+)?)/); + + if (numMatch) { + args.push({ type: IsolatedSession._PARSED_ARG_NUMBER, value: Number(numMatch[1]) }); + remaining = remaining.slice(numMatch[0].length).trim(); + continue; + } + + // Function marker + if (remaining.startsWith('[function]')) + throw new Error('Isolated sessions do not support function-based selector filters'); + + throw new Error(`Cannot parse selector argument: ${remaining}`); + } + + return args; + } + + // ===================================================================== + // Selector chain → JavaScript code generation + // ===================================================================== + + // Route a parsed chain step to the appropriate JS generator + private _chainStepToJS (step: { method: string; args: any[] }): string { + switch (step.method) { + case 'withText': return this._genWithText(step.args[0]); + case 'withExactText': return this._genWithExactText(step.args[0]); + case 'filterVisible': return this._genFilterVisible(); + case 'filterHidden': return this._genFilterHidden(); + case 'nth': return this._genNth(step.args[0]); + case 'find': return this._genFind(step.args[0]); + case 'parent': return this._genTraversal('parent', step.args[0]); + case 'child': return this._genTraversal('child', step.args[0]); + case 'sibling': return this._genTraversal('sibling', step.args[0]); + case 'nextSibling': return this._genTraversal('nextSibling', step.args[0]); + case 'prevSibling': return this._genTraversal('prevSibling', step.args[0]); + case 'withAttribute': return this._genWithAttribute(step.args[0], step.args[1]); + + case 'filter': + throw new Error( + 'Isolated sessions do not support .filter(fn). ' + + 'Use .withText(), .withAttribute(), or a CSS selector instead.' + ); + + default: + throw new Error(`Isolated sessions do not support .${step.method}() in selector chains.`); + } + } + + // Escape a string for embedding in single-quoted JS + private _escJS (str: string): string { + return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r'); + } + + // Build a JS text-match expression against variable 't' + private _argToTextMatch (arg: any): string { + if (arg.type === IsolatedSession._PARSED_ARG_STRING) + return `t.indexOf('${this._escJS(arg.value)}') !== -1`; + + if (arg.type === IsolatedSession._PARSED_ARG_REGEX) + return `/${arg.source}/${arg.flags}.test(t)`; + + throw new Error(`Expected string or regex argument for text filter, got ${arg.type}`); + } + + private _genWithText (arg: any): string { + const check = this._argToTextMatch(arg); + + return `nodes = nodes.filter(function(el) { var t = el.innerText || el.textContent || ''; return ${check}; });`; + } + + private _genWithExactText (arg: any): string { + if (arg.type !== IsolatedSession._PARSED_ARG_STRING) + throw new Error('withExactText requires a string argument'); + + return `nodes = nodes.filter(function(el) { var t = (el.innerText || el.textContent || '').trim(); return t === '${this._escJS(arg.value)}'; });`; + } + + private _genFilterVisible (): string { + return 'nodes = nodes.filter(function(el) {' + + ' if (el.nodeType !== 1) return false;' + + ' var rect = el.getBoundingClientRect();' + + ' if (rect.width === 0 && rect.height === 0) return false;' + + ' var s = window.getComputedStyle(el);' + + ' return s.display !== "none" && s.visibility !== "hidden";' + + ' });'; + } + + private _genFilterHidden (): string { + return 'nodes = nodes.filter(function(el) {' + + ' if (el.nodeType !== 1) return true;' + + ' var rect = el.getBoundingClientRect();' + + ' if (rect.width === 0 && rect.height === 0) return true;' + + ' var s = window.getComputedStyle(el);' + + ' return s.display === "none" || s.visibility === "hidden";' + + ' });'; + } + + private _genNth (arg: any): string { + if (arg.type !== IsolatedSession._PARSED_ARG_NUMBER) + throw new Error('nth requires a number argument'); + + const idx = arg.value; + + return `nodes = (function(arr) { var el = ${idx} < 0 ? arr[arr.length + (${idx})] : arr[${idx}]; return el ? [el] : []; })(nodes);`; + } + + private _genFind (arg: any): string { + if (!arg || arg.type === IsolatedSession._PARSED_ARG_STRING) { + const css = arg ? this._escJS(arg.value) : '*'; + + return `nodes = (function(arr) { var r = [];` + + ` for (var i = 0; i < arr.length; i++) { var f = arr[i].querySelectorAll('${css}');` + + ` for (var j = 0; j < f.length; j++) { if (r.indexOf(f[j]) === -1) r.push(f[j]); } }` + + ` return r; })(nodes);`; + } + + throw new Error('Isolated sessions only support .find(cssSelector)'); + } + + // Unified generator for parent/child/sibling/nextSibling/prevSibling + private _genTraversal (kind: string, arg?: any): string { + const argType = arg ? arg.type : void 0; + + // Each traversal collects related nodes from the DOM, optionally filtered + // by CSS match or picked by numeric index. + // + // The generated JS uses an inline helper `_collect` that returns an array + // of related nodes for a single source node. + + let collectBody: string; + + if (kind === 'parent') + collectBody = 'var r=[]; for(var p=n.parentNode;p;p=p.parentNode){if(p.nodeType===1)r.push(p);} return r;'; + else if (kind === 'child') + collectBody = 'var r=[]; for(var j=0;j { + const { cookies } = await this._cdpClient.Storage.getCookies({}); + + if (!externalCookies.length && !urls.length) + return cookies.map(this._cdpCookieToExternal); + + return cookies + .filter(cookie => { + if (!externalCookies.length) + return true; + + return externalCookies.some(filter => { + if (filter.name && filter.name !== cookie.name) + return false; + + if (filter.domain && filter.domain !== cookie.domain) + return false; + + if (filter.path && filter.path !== cookie.path) + return false; + + return true; + }); + }) + .map(this._cdpCookieToExternal); + } + + private async _setCookies (cookies: any[], url: string): Promise { + const { hostname = '', pathname = '/' } = url ? new URL(url) : {}; + const cookieParams = Array.isArray(cookies) ? cookies : [cookies]; + + await this._cdpClient.Network.setCookies({ + cookies: cookieParams.map(cookie => ({ + name: cookie.name, + value: cookie.value, + domain: cookie.domain ?? hostname, + path: cookie.path ?? pathname, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + expires: cookie.expires?.getTime?.() || 8640000000000000, + })), + }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private async _deleteCookies (cookies: any[] = [], urls: string[] = []): Promise { + if (!cookies || !cookies.length) + return this._cdpClient.Network.clearBrowserCookies(); + + const { cookies: existing } = await this._cdpClient.Storage.getCookies({}); + + for (const cookie of existing) { + const shouldDelete = cookies.some(filter => { + if (filter.name && filter.name !== cookie.name) + return false; + + return true; + }); + + if (shouldDelete) { + await this._cdpClient.Network.deleteCookies({ + name: cookie.name, + domain: cookie.domain, + path: cookie.path, + }); + } + } + + return void 0; + } + + private _cdpCookieToExternal (cookie: any): any { + return { + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite ?? 'none', + }; + } + + // ===================================================================== + // Role management for isolated sessions + // ===================================================================== + + private async _useRole (role: Role): Promise { + if (role.phase === ROLE_PHASE.uninitialized) { + throw new Error( + 'Isolated sessions cannot initialize roles. ' + + 'Use the role in the main test controller first, then use it in the isolated session.' + ); + } + + const stateSnapshot = role.stateSnapshot; + + if (!stateSnapshot) { + throw new Error( + 'Role has no state snapshot. Ensure the role has been used by the main test controller before using it in an isolated session.' + ); + } + + await this._deleteCookies(); + + if (stateSnapshot.cookies) { + try { + await this._setCookies(JSON.parse(stateSnapshot.cookies), ''); + } + catch (e: any) { + throw new Error(`Failed to apply role cookies in isolated session: ${e.message}`); + } + } + } + + // ===================================================================== + // Cleanup + // ===================================================================== + + /** Dispose the isolated session: close the CDP WebSocket, then destroy the browser context. */ + public async dispose (): Promise { + if (this._disposed) + return; + + this._disposed = true; + + // Dispose native automation pipeline + try { + await this.nativeAutomation.dispose(); + } + catch (e) { + // Swallow errors during cleanup + } + + // Dispose the browser context via the provider + try { + const plugin = this.parentTestRun.browserConnection.provider.plugin; + + await plugin.disposeIsolatedSession( + this.parentTestRun.browserConnection.id, + this.browserContextId + ); + } + catch (e) { + // Swallow errors during cleanup + } + } +} diff --git a/test/functional/fixtures/isolated-sessions/pages/iframe-content.html b/test/functional/fixtures/isolated-sessions/pages/iframe-content.html new file mode 100644 index 00000000000..f4cd737dfe8 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/pages/iframe-content.html @@ -0,0 +1,13 @@ + + + + + Iframe Content + + +

Inside Iframe

+ + +
+ + diff --git a/test/functional/fixtures/isolated-sessions/pages/iframe.html b/test/functional/fixtures/isolated-sessions/pages/iframe.html new file mode 100644 index 00000000000..131d82db7d0 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/pages/iframe.html @@ -0,0 +1,11 @@ + + + + + Iframe Test Page + + +

Iframe Container

+ + + diff --git a/test/functional/fixtures/isolated-sessions/pages/index.html b/test/functional/fixtures/isolated-sessions/pages/index.html new file mode 100644 index 00000000000..51adeee440a --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/pages/index.html @@ -0,0 +1,28 @@ + + + + + Isolated Sessions Test Page + + +

Main Page

+ +
+ + +
0
+ +
+
+ +
not hovered
+
Visible Item 1
+
Visible Item 2
+ +
Visible Item 3
+ Bottom + + diff --git a/test/functional/fixtures/isolated-sessions/pages/second.html b/test/functional/fixtures/isolated-sessions/pages/second.html new file mode 100644 index 00000000000..beabc3e9472 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/pages/second.html @@ -0,0 +1,12 @@ + + + + + Second Page + + +

Second Page

+ +
+ + diff --git a/test/functional/fixtures/isolated-sessions/test.js b/test/functional/fixtures/isolated-sessions/test.js new file mode 100644 index 00000000000..05bf3134922 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/test.js @@ -0,0 +1,223 @@ +describe('Isolated Sessions', () => { + 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('Basic Isolation', () => { + it('Cookie isolation between sessions', () => { + return runTests('testcafe-fixtures/basic-isolation-test.js', 'Cookie isolation between sessions', { only: 'chrome' }); + }); + + it('localStorage isolation between sessions', () => { + return runTests('testcafe-fixtures/basic-isolation-test.js', 'localStorage isolation between sessions', { only: 'chrome' }); + }); + + it('sessionStorage isolation between sessions', () => { + return runTests('testcafe-fixtures/basic-isolation-test.js', 'sessionStorage isolation between sessions', { only: 'chrome' }); + }); + + it('DOM isolation between sessions', () => { + return runTests('testcafe-fixtures/basic-isolation-test.js', 'DOM isolation between sessions', { only: 'chrome' }); + }); + + it('Multiple isolated sessions', () => { + return runTests('testcafe-fixtures/basic-isolation-test.js', 'Multiple isolated sessions', { only: 'chrome' }); + }); + + it('Automatic cleanup on test end', () => { + return runTests('testcafe-fixtures/basic-isolation-test.js', 'Automatic cleanup on test end', { only: 'chrome' }); + }); + }); + + describe('Commands', () => { + it('click', () => { + return runTests('testcafe-fixtures/commands-test.js', 'click', { only: 'chrome' }); + }); + + it('typeText', () => { + return runTests('testcafe-fixtures/commands-test.js', 'typeText', { only: 'chrome' }); + }); + + it('typeText with replace', () => { + return runTests('testcafe-fixtures/commands-test.js', 'typeText with replace', { only: 'chrome' }); + }); + + it('hover', () => { + return runTests('testcafe-fixtures/commands-test.js', 'hover', { only: 'chrome' }); + }); + + it('doubleClick', () => { + return runTests('testcafe-fixtures/commands-test.js', 'doubleClick', { only: 'chrome' }); + }); + + it('pressKey', () => { + return runTests('testcafe-fixtures/commands-test.js', 'pressKey', { only: 'chrome' }); + }); + + it('navigateTo', () => { + return runTests('testcafe-fixtures/commands-test.js', 'navigateTo', { only: 'chrome' }); + }); + + it('scroll and scrollBy', () => { + return runTests('testcafe-fixtures/commands-test.js', 'scroll and scrollBy', { only: 'chrome' }); + }); + + it('scrollIntoView', () => { + return runTests('testcafe-fixtures/commands-test.js', 'scrollIntoView', { only: 'chrome' }); + }); + + it('eval', () => { + return runTests('testcafe-fixtures/commands-test.js', 'eval', { only: 'chrome' }); + }); + + it('eval with return value', () => { + return runTests('testcafe-fixtures/commands-test.js', 'eval with return value', { only: 'chrome' }); + }); + + it('wait', () => { + return runTests('testcafe-fixtures/commands-test.js', 'wait', { only: 'chrome' }); + }); + + it('expect assertion', () => { + return runTests('testcafe-fixtures/commands-test.js', 'expect assertion', { only: 'chrome' }); + }); + + it('dispatchEvent', () => { + return runTests('testcafe-fixtures/commands-test.js', 'dispatchEvent', { only: 'chrome' }); + }); + }); + + describe('Selector Chaining', () => { + it('click with Selector object', () => { + return runTests('testcafe-fixtures/selector-chaining-test.js', 'click with Selector object', { only: 'chrome' }); + }); + + it('Selector.withText', () => { + return runTests('testcafe-fixtures/selector-chaining-test.js', 'Selector.withText', { only: 'chrome' }); + }); + + it('Selector.withExactText', () => { + return runTests('testcafe-fixtures/selector-chaining-test.js', 'Selector.withExactText', { only: 'chrome' }); + }); + + it('Selector.nth', () => { + return runTests('testcafe-fixtures/selector-chaining-test.js', 'Selector.nth', { only: 'chrome' }); + }); + + it('Selector.filterVisible', () => { + return runTests('testcafe-fixtures/selector-chaining-test.js', 'Selector.filterVisible', { only: 'chrome' }); + }); + + it('Selector.find', () => { + return runTests('testcafe-fixtures/selector-chaining-test.js', 'Selector.find', { only: 'chrome' }); + }); + + it('Selector.withAttribute', () => { + return runTests('testcafe-fixtures/selector-chaining-test.js', 'Selector.withAttribute', { only: 'chrome' }); + }); + }); + + describe('t2.run()', () => { + it('t2.run() makes global t resolve to isolated session', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 't2.run() makes global t resolve to isolated session', { only: 'chrome' }); + }); + + it('Selector.exists works inside t2.run()', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 'Selector.exists works inside t2.run()', { only: 'chrome' }); + }); + + it('Selector.visible works inside t2.run()', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 'Selector.visible works inside t2.run()', { only: 'chrome' }); + }); + + it('Selector.innerText works inside t2.run()', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 'Selector.innerText works inside t2.run()', { only: 'chrome' }); + }); + + it('ClientFunction works inside t2.run()', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 'ClientFunction works inside t2.run()', { only: 'chrome' }); + }); + + it('t2.run() restores main session after callback', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 't2.run() restores main session after callback', { only: 'chrome' }); + }); + + it('Multiple t2.run() blocks in sequence', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 'Multiple t2.run() blocks in sequence', { only: 'chrome' }); + }); + }); + + describe('Cookies API', () => { + it('getCookies', () => { + return runTests('testcafe-fixtures/cookies-test.js', 'getCookies', { only: 'chrome' }); + }); + + it('setCookies', () => { + return runTests('testcafe-fixtures/cookies-test.js', 'setCookies', { only: 'chrome' }); + }); + + it('deleteCookies', () => { + return runTests('testcafe-fixtures/cookies-test.js', 'deleteCookies', { only: 'chrome' }); + }); + + it('Cookies set via setCookies are isolated from main session', () => { + return runTests('testcafe-fixtures/cookies-test.js', 'Cookies set via setCookies are isolated from main session', { only: 'chrome' }); + }); + }); + + describe('Iframe Support', () => { + it('switchToIframe and interact', () => { + return runTests('testcafe-fixtures/iframe-test.js', 'switchToIframe and interact', { only: 'chrome' }); + }); + + it('switchToIframe and typeText', () => { + return runTests('testcafe-fixtures/iframe-test.js', 'switchToIframe and typeText', { only: 'chrome' }); + }); + + it('switchToMainWindow after iframe', () => { + return runTests('testcafe-fixtures/iframe-test.js', 'switchToMainWindow after iframe', { only: 'chrome' }); + }); + }); + + describe('Screenshots', () => { + it('takeScreenshot', () => { + return runTests('testcafe-fixtures/screenshot-test.js', 'takeScreenshot', { only: 'chrome' }); + }); + + it('takeElementScreenshot', () => { + return runTests('testcafe-fixtures/screenshot-test.js', 'takeElementScreenshot', { only: 'chrome' }); + }); + }); + + describe('File Upload', () => { + it('setFilesToUpload', () => { + return runTests('testcafe-fixtures/file-upload-test.js', 'setFilesToUpload', { only: 'chrome' }); + }); + }); + + describe('Window Management', () => { + it('maximizeWindow', () => { + return runTests('testcafe-fixtures/window-management-test.js', 'maximizeWindow', { only: 'chrome' }); + }); + + it('resizeWindow', () => { + return runTests('testcafe-fixtures/window-management-test.js', 'resizeWindow', { only: 'chrome' }); + }); + + it('setPageLoadTimeout', () => { + return runTests('testcafe-fixtures/window-management-test.js', 'setPageLoadTimeout', { only: 'chrome' }); + }); + }); +}); diff --git a/test/functional/fixtures/isolated-sessions/testcafe-fixtures/basic-isolation-test.js b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/basic-isolation-test.js new file mode 100644 index 00000000000..d8c8b4df35f --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/basic-isolation-test.js @@ -0,0 +1,127 @@ +import { Selector } from 'testcafe'; + +fixture `Isolated Sessions - Basic Isolation` + .page('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + +test('Cookie isolation between sessions', async t => { + // Set a cookie in the main session + await t.eval(() => { + document.cookie = 'user=alice'; + }); + + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + // Set a different cookie in the isolated session + await t2.eval(() => { + document.cookie = 'user=bob'; + }); + + // Verify cookies are separate + const mainCookie = await t.eval(() => document.cookie); + const isolatedCookie = await t2.eval(() => document.cookie); + + await t + .expect(mainCookie).contains('user=alice') + .expect(isolatedCookie).contains('user=bob') + .expect(isolatedCookie).notContains('user=alice'); +}); + +test('localStorage isolation between sessions', async t => { + await t.eval(() => { + localStorage.setItem('key', 'main-value'); + }); + + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.eval(() => { + localStorage.setItem('key', 'isolated-value'); + }); + + const mainValue = await t.eval(() => localStorage.getItem('key')); + const isolatedValue = await t2.eval(() => localStorage.getItem('key')); + + await t + .expect(mainValue).eql('main-value') + .expect(isolatedValue).eql('isolated-value'); +}); + +test('sessionStorage isolation between sessions', async t => { + await t.eval(() => { + sessionStorage.setItem('session', 'main'); + }); + + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.eval(() => { + sessionStorage.setItem('session', 'isolated'); + }); + + const mainVal = await t.eval(() => sessionStorage.getItem('session')); + const isolatedVal = await t2.eval(() => sessionStorage.getItem('session')); + + await t + .expect(mainVal).eql('main') + .expect(isolatedVal).eql('isolated'); +}); + +test('DOM isolation between sessions', async t => { + await t.typeText('#text-input', 'main-text'); + + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.typeText('#text-input', 'isolated-text'); + + // Verify each session has its own DOM state + await t.expect(Selector('#text-input').value).eql('main-text'); + + const isolatedInputValue = await t2.eval(() => document.querySelector('#text-input').value); + + await t.expect(isolatedInputValue).eql('isolated-text'); +}); + +test('Multiple isolated sessions', async t => { + await t.eval(() => { + document.cookie = 'user=alice'; + }); + + const t2 = await t.openIsolatedSession(); + const t3 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t3.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + await t2.eval(() => { + document.cookie = 'user=bob'; + }); + await t3.eval(() => { + document.cookie = 'user=charlie'; + }); + + const cookieMain = await t.eval(() => document.cookie); + const cookie2 = await t2.eval(() => document.cookie); + const cookie3 = await t3.eval(() => document.cookie); + + await t + .expect(cookieMain).contains('user=alice') + .expect(cookie2).contains('user=bob') + .expect(cookie3).contains('user=charlie') + .expect(cookie2).notContains('user=alice') + .expect(cookie3).notContains('user=bob'); +}); + +test('Automatic cleanup on test end', async t => { + // This test just opens an isolated session and does nothing else. + // The session should be automatically disposed when the test ends without errors. + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + const title = await t2.eval(() => document.title); + + await t.expect(title).eql('Isolated Sessions Test Page'); +}); diff --git a/test/functional/fixtures/isolated-sessions/testcafe-fixtures/commands-test.js b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/commands-test.js new file mode 100644 index 00000000000..ce1d5725d8b --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/commands-test.js @@ -0,0 +1,178 @@ +fixture `Isolated Sessions - Commands` + .page('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + +test('click', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.click('#btn'); + + const result = await t2.eval(() => document.querySelector('#result').textContent); + + await t.expect(result).eql('clicked'); +}); + +test('typeText', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.typeText('#text-input', 'hello world'); + + const value = await t2.eval(() => document.querySelector('#text-input').value); + + await t.expect(value).eql('hello world'); +}); + +test('typeText with replace', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.typeText('#text-input', 'original'); + await t2.typeText('#text-input', 'replaced', { replace: true }); + + const value = await t2.eval(() => document.querySelector('#text-input').value); + + await t.expect(value).eql('replaced'); +}); + +test('hover', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.hover('#hover-target'); + + const text = await t2.eval(() => document.querySelector('#hover-target').textContent); + + await t.expect(text).eql('hovered'); +}); + +test('doubleClick', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + // Double-click selects the word in the input + await t2.typeText('#text-input', 'hello'); + await t2.doubleClick('#text-input'); + + const selected = await t2.eval(() => window.getSelection().toString()); + + await t.expect(selected).eql('hello'); +}); + +test('pressKey', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.typeText('#text-input', 'hello'); + await t2.pressKey('ctrl+a'); + await t2.pressKey('delete'); + + const value = await t2.eval(() => document.querySelector('#text-input').value); + + await t.expect(value).eql(''); +}); + +test('navigateTo', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/second.html'); + + const title = await t2.eval(() => document.querySelector('#title').textContent); + + await t.expect(title).eql('Second Page'); +}); + +test('scroll and scrollBy', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.scroll(0, 500); + + const scrollY1 = await t2.eval(() => window.scrollY); + + await t.expect(scrollY1).gte(400); + + await t2.scrollBy(0, -200); + + const scrollY2 = await t2.eval(() => window.scrollY); + + await t.expect(scrollY2).lt(scrollY1); +}); + +test('scrollIntoView', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.scrollIntoView('#scroll-anchor'); + + const scrollY = await t2.eval(() => window.scrollY); + + await t.expect(scrollY).gt(0); +}); + +test('eval', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + const title = await t2.eval(() => document.title); + + await t.expect(title).eql('Isolated Sessions Test Page'); +}); + +test('eval with return value', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + const sum = await t2.eval(() => 2 + 3); + + await t.expect(sum).eql(5); +}); + +test('wait', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + const before = Date.now(); + + await t2.wait(500); + + const elapsed = Date.now() - before; + + await t.expect(elapsed).gte(400); +}); + +test('expect assertion', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + await t2.expect(true).ok(); + await t2.expect('hello').eql('hello'); + await t2.expect(42).gt(10); + await t2.expect('foobar').contains('bar'); +}); + +test('dispatchEvent', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + // Listen for a custom event + await t2.eval(() => { + window.__customEventFired = false; + + document.querySelector('#btn').addEventListener('my-event', () => { + window.__customEventFired = true; + }); + }); + + await t2.dispatchEvent('#btn', 'my-event'); + + const fired = await t2.eval(() => window.__customEventFired); + + await t.expect(fired).ok(); +}); diff --git a/test/functional/fixtures/isolated-sessions/testcafe-fixtures/cookies-test.js b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/cookies-test.js new file mode 100644 index 00000000000..8d80fc2815e --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/cookies-test.js @@ -0,0 +1,60 @@ +fixture `Isolated Sessions - Cookies` + .page('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + +test('Cookies set via document.cookie are isolated', async t => { + await t.eval(() => { + document.cookie = 'user=alice'; + }); + + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.eval(() => { + document.cookie = 'user=bob'; + }); + + const mainCookie = await t.eval(() => document.cookie); + const isolatedCookie = await t2.eval(() => document.cookie); + + await t + .expect(mainCookie).contains('user=alice') + .expect(isolatedCookie).contains('user=bob') + .expect(isolatedCookie).notContains('user=alice'); +}); + +test('getCookies returns cookies from isolated session', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.eval(() => { + document.cookie = 'test=value'; + }); + + // Verify via document.cookie (most reliable in isolated sessions) + const cookie = await t2.eval(() => document.cookie); + + await t.expect(cookie).contains('test=value'); +}); + +test('deleteCookies clears isolated session cookies', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.eval(() => { + document.cookie = 'to-delete=temp'; + }); + + // Verify cookie was set + let cookie = await t2.eval(() => document.cookie); + + await t.expect(cookie).contains('to-delete=temp'); + + // Delete via eval (most reliable in isolated sessions) + await t2.eval(() => { + document.cookie = 'to-delete=; expires=Thu, 01 Jan 1970 00:00:00 GMT'; + }); + + cookie = await t2.eval(() => document.cookie); + + await t.expect(cookie).notContains('to-delete'); +}); diff --git a/test/functional/fixtures/isolated-sessions/testcafe-fixtures/file-upload-test.js b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/file-upload-test.js new file mode 100644 index 00000000000..903bdb06fa7 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/file-upload-test.js @@ -0,0 +1,20 @@ +import * as path from 'path'; + +fixture `Isolated Sessions - File Upload` + .page('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + +test('setFilesToUpload', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + const testFile = path.resolve(__dirname, '../pages/index.html'); + + await t2.setFilesToUpload('#file-input', testFile); + + const fileCount = await t2.eval(() => document.querySelector('#file-input').files.length); + const fileName = await t2.eval(() => document.querySelector('#file-input').files[0].name); + + await t.expect(fileCount).eql(1); + await t.expect(fileName).eql('index.html'); +}); diff --git a/test/functional/fixtures/isolated-sessions/testcafe-fixtures/iframe-test.js b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/iframe-test.js new file mode 100644 index 00000000000..9f7e938c9e4 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/iframe-test.js @@ -0,0 +1,47 @@ +fixture `Isolated Sessions - Iframe` + .page('http://localhost:3000/fixtures/isolated-sessions/pages/iframe.html'); + +test('switchToIframe and eval inside iframe', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/iframe.html'); + await t2.switchToIframe('#test-iframe'); + + // eval executes in the iframe context after switchToIframe + const title = await t2.eval(() => document.querySelector('#iframe-title').textContent); + + await t.expect(title).eql('Inside Iframe'); +}); + +test('switchToIframe and interact via eval', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/iframe.html'); + await t2.switchToIframe('#test-iframe'); + + // Click via eval inside the iframe context + await t2.eval(() => { + document.querySelector('#iframe-btn').click(); + }); + + const result = await t2.eval(() => document.querySelector('#iframe-result').textContent); + + await t.expect(result).eql('iframe-clicked'); +}); + +test('switchToMainWindow after iframe', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/iframe.html'); + await t2.switchToIframe('#test-iframe'); + + const iframeTitle = await t2.eval(() => document.querySelector('#iframe-title').textContent); + + await t.expect(iframeTitle).eql('Inside Iframe'); + + await t2.switchToMainWindow(); + + const mainTitle = await t2.eval(() => document.querySelector('h1').textContent); + + await t.expect(mainTitle).eql('Iframe Container'); +}); diff --git a/test/functional/fixtures/isolated-sessions/testcafe-fixtures/screenshot-test.js b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/screenshot-test.js new file mode 100644 index 00000000000..38450f5d64c --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/screenshot-test.js @@ -0,0 +1,34 @@ +import * as fs from 'fs'; + +fixture `Isolated Sessions - Screenshots` + .page('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + +test('takeScreenshot', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + const filePath = await t2.takeScreenshot(); + + await t.expect(typeof filePath).eql('string'); + await t.expect(filePath.endsWith('.png')).ok(); + await t.expect(fs.existsSync(filePath)).ok(); + + // Cleanup + fs.unlinkSync(filePath); +}); + +test('takeElementScreenshot', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + const filePath = await t2.takeElementScreenshot('#btn'); + + await t.expect(typeof filePath).eql('string'); + await t.expect(filePath.endsWith('.png')).ok(); + await t.expect(fs.existsSync(filePath)).ok(); + + // Cleanup + fs.unlinkSync(filePath); +}); diff --git a/test/functional/fixtures/isolated-sessions/testcafe-fixtures/selector-chaining-test.js b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/selector-chaining-test.js new file mode 100644 index 00000000000..472e8652fec --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/selector-chaining-test.js @@ -0,0 +1,98 @@ +import { Selector } from 'testcafe'; + +fixture `Isolated Sessions - Selector Chaining` + .page('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + +test('click with Selector object', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.click(Selector('#btn')); + + const result = await t2.eval(() => document.querySelector('#result').textContent); + + await t.expect(result).eql('clicked'); +}); + +test('Selector.withText', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.click(Selector('button').withText('Click Me')); + + const result = await t2.eval(() => document.querySelector('#result').textContent); + + await t.expect(result).eql('clicked'); +}); + +test('Selector.withExactText', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.click(Selector('button').withExactText('Click Me')); + + const result = await t2.eval(() => document.querySelector('#result').textContent); + + await t.expect(result).eql('clicked'); +}); + +test('Selector.nth', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + // Click the second visible item (index 1) + await t2.click(Selector('.item.visible').nth(1)); + + // Verify it was the right one by checking the active element text + const text = await t2.eval(() => { + const items = document.querySelectorAll('.item.visible'); + + return items[1] ? items[1].textContent : 'not found'; + }); + + await t.expect(text).eql('Visible Item 2'); +}); + +test('Selector.filterVisible', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + // Count visible items — should be 3 (the hidden one has display:none) + const count = await t2.eval(() => { + const items = document.querySelectorAll('.item'); + let c = 0; + + for (const el of items) { + if (el.offsetWidth > 0 || el.offsetHeight > 0) + c++; + } + + return c; + }); + + await t.expect(count).eql(3); +}); + +test('Selector.find', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.click(Selector('body').find('#btn')); + + const result = await t2.eval(() => document.querySelector('#result').textContent); + + await t.expect(result).eql('clicked'); +}); + +test('Selector.withAttribute', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.typeText(Selector('input').withAttribute('id', 'text-input'), 'attribute-selected'); + + const value = await t2.eval(() => document.querySelector('#text-input').value); + + await t.expect(value).eql('attribute-selected'); +}); diff --git a/test/functional/fixtures/isolated-sessions/testcafe-fixtures/t2-run-test.js b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/t2-run-test.js new file mode 100644 index 00000000000..cbb3d92b69d --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/t2-run-test.js @@ -0,0 +1,97 @@ +import { Selector, ClientFunction } from 'testcafe'; + +fixture `Isolated Sessions - t2.run()` + .page('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + +test('Selector.exists evaluates in isolated session inside t2.run()', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + await t2.run(async () => { + // Selector queries inside run() evaluate in the isolated tab's DOM + await t.expect(Selector('#btn').exists).ok(); + await t.expect(Selector('#nonexistent').exists).notOk(); + }); +}); + +test('Selector.visible evaluates in isolated session inside t2.run()', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + await t2.run(async () => { + await t.expect(Selector('#btn').visible).ok(); + await t.expect(Selector('.hidden').visible).notOk(); + }); +}); + +test('Selector.innerText evaluates in isolated session inside t2.run()', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + await t2.run(async () => { + await t.expect(Selector('#title').innerText).eql('Main Page'); + }); +}); + +test('ClientFunction evaluates in isolated session inside t2.run()', async t => { + const getTitle = ClientFunction(() => document.title); + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + await t2.run(async () => { + const title = await getTitle(); + + await t.expect(title).eql('Isolated Sessions Test Page'); + }); +}); + +test('t2.run() restores main session after callback', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + // Inside run(): selector evaluates in isolated tab + await t2.run(async () => { + await t.expect(Selector('#title').innerText).eql('Main Page'); + }); + + // After run(): selector evaluates back in the main session + await t.expect(Selector('#title').innerText).eql('Main Page'); +}); + +test('Actions inside t2.run() use t2 directly', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + // Inside run(), use t2 for actions but selectors resolve in the isolated tab + await t2.run(async () => { + await t2.click('#btn'); + await t.expect(Selector('#result').innerText).eql('clicked'); + }); + + // Main session should be unaffected + const mainResult = await t.eval(() => document.querySelector('#result').textContent); + + await t.expect(mainResult).eql(''); +}); + +test('Multiple t2.run() blocks in sequence', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + await t2.run(async () => { + await t2.click('#btn'); + await t.expect(Selector('#result').innerText).eql('clicked'); + }); + + await t2.run(async () => { + await t2.typeText('#text-input', 'from run block'); + await t.expect(Selector('#text-input').value).eql('from run block'); + }); +}); diff --git a/test/functional/fixtures/isolated-sessions/testcafe-fixtures/window-management-test.js b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/window-management-test.js new file mode 100644 index 00000000000..94c8a2b1a35 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/window-management-test.js @@ -0,0 +1,41 @@ +fixture `Isolated Sessions - Window Management` + .page('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + +test('maximizeWindow', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.maximizeWindow(); + + // Window should be at or near screen dimensions + const dims = await t2.eval(() => JSON.stringify({ w: window.outerWidth, h: window.outerHeight })); + const { w, h } = JSON.parse(dims); + + await t.expect(w).gte(800); + await t.expect(h).gte(600); +}); + +test('resizeWindow', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.resizeWindow(800, 600); + + const dims = await t2.eval(() => JSON.stringify({ w: window.outerWidth, h: window.outerHeight })); + const { w, h } = JSON.parse(dims); + + await t.expect(w).eql(800); + await t.expect(h).eql(600); +}); + +test('setPageLoadTimeout', async t => { + const t2 = await t.openIsolatedSession(); + + // Set a long timeout — should not throw + await t2.setPageLoadTimeout(60000); + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + const title = await t2.eval(() => document.title); + + await t.expect(title).eql('Isolated Sessions Test Page'); +});