From f1e68e5330465b6e9893476a7caa258ca282741e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yannick=20H=C3=B6chst=C3=B6tter?= Date: Mon, 28 Sep 2026 11:06:07 +0200 Subject: [PATCH 1/5] feat(native-automation): create isolated browser contexts over CDP Add a browser-level CDP client to the Chrome BrowserClient and use it to create and dispose browser contexts (Target.createBrowserContext) with one page target each. A NativeAutomationIsolatedWindow attaches to such a target with the page-level CDP setup only, without the request pipeline and proxy hooks the main window uses. The main window's targetCreated handler now ignores targets from other browser contexts, so an isolated tab does not get treated as a child window of the test page. The browser-level connection is cached as a promise and forgotten on disconnect and on close; a failed target creation disposes the context it was created in so nothing leaks. --- .../dedicated/chrome/cdp-client/index.ts | 113 ++++++++++++++++++ .../built-in/dedicated/chrome/index.js | 39 +++++- src/native-automation/index.ts | 5 + src/native-automation/isolated-window.ts | 47 ++++++++ 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/native-automation/isolated-window.ts 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..fd1afa2227a 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,8 @@ export class BrowserClient { private _videoFramesBuffer: VideoFrameData[]; private _lastFrame: VideoFrameData | null; private _screencastFrameListenerAttached = false; + private _browserLevelClient: Promise | null = null; + private _closed = false; public constructor (runtimeInfo: RuntimeInfo) { this._runtimeInfo = runtimeInfo; @@ -361,6 +363,24 @@ export class BrowserClient { } public async closeTab (): Promise { + this._closed = true; + + if (this._browserLevelClient) { + const browserLevelClientPromise = this._browserLevelClient; + + this._browserLevelClient = null; + + try { + // chrome-remote-interface types omit close() + const browserLevelClient: any = await browserLevelClientPromise; + + await browserLevelClient.close(); + } + catch (err) { + debugLog(err); + } + } + if (this._parentTarget) await remoteChrome.Close({ id: this._parentTarget.id, port: this._port }); } @@ -460,6 +480,99 @@ export class BrowserClient { } } + private _getBrowserLevelClient (): Promise { + if (this._closed) + return Promise.reject(new Error('The browser connection is closed')); + + if (this._browserLevelClient) + return this._browserLevelClient; + + const clientPromise = this._connectBrowserLevelClient(); + const forget = (): void => { + if (this._browserLevelClient === clientPromise) + this._browserLevelClient = null; + }; + + this._browserLevelClient = clientPromise; + + clientPromise.then(client => (client as any).on('disconnect', forget), forget); + + return clientPromise; + } + + private async _connectBrowserLevelClient (): Promise { + // 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 + return remoteChrome({ target: version.webSocketDebuggerUrl }); + } + + // Public accessor for isolated-session window management (Browser.setWindowBounds etc.) + public async getBrowserLevelClient (): Promise { + return this._getBrowserLevelClient(); + } + + public async createIsolatedContext (): Promise<{ contextId: string, targetId: string, client: remoteChrome.ProtocolApi }> { + const browserClient = await this._getBrowserLevelClient(); + + const { browserContextId } = await browserClient.Target.createBrowserContext({}); + + try { + 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 }; + } + catch (err) { + await this.disposeIsolatedContext(browserContextId); + + throw err; + } + } + + 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) { + // chrome-remote-interface types omit close() + const isolatedClient: any = clientInfo.client; + + try { + await isolatedClient.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..f3cbec2b7d4 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,41 @@ 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); + }, + + async getBrowserLevelCDPClient (browserId) { + return this.openedBrowsers[browserId].browserClient.getBrowserLevelClient(); + }, + supportNativeAutomation () { return true; }, diff --git a/src/native-automation/index.ts b/src/native-automation/index.ts index adb991c7af4..d0be9e84cce 100644 --- a/src/native-automation/index.ts +++ b/src/native-automation/index.ts @@ -82,12 +82,17 @@ export class NativeAutomationMainWindow extends NativeAutomationBase { async start (): Promise { await super.start(); + const { targetInfo: ownTargetInfo } = await this._client.Target.getTargetInfo({ targetId: this.windowId }); + await this._client.Target.setDiscoverTargets({ discover: true }); this._client.Target.on('targetCreated', async ({ targetInfo }) => { if (targetInfo.type !== 'page' || targetInfo.targetId === this.windowId) return; + if (targetInfo.browserContextId !== ownTargetInfo.browserContextId) + return; + this._resolveNewWindowOpeningPromise = this.emit(NEW_WINDOW_OPENED_IN_NATIVE_AUTOMATION, targetInfo); }); } diff --git a/src/native-automation/isolated-window.ts b/src/native-automation/isolated-window.ts new file mode 100644 index 00000000000..6e8e2418435 --- /dev/null +++ b/src/native-automation/isolated-window.ts @@ -0,0 +1,47 @@ +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 + } +} From 0a146fabfe36d874b24c33425589f4afaf9bb39c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yannick=20H=C3=B6chst=C3=B6tter?= Date: Mon, 28 Sep 2026 11:06:07 +0200 Subject: [PATCH 2/5] feat(test-controller): add t.openIsolatedSession() t.openIsolatedSession() opens a second browser session in an isolated CDP browser context (own cookies, storage, cache and HTTP auth) and resolves to an IsolatedTestController. Multi-user scenarios such as a video call between two accounts can run inside one test in one browser instead of two concurrent tests or two browsers. The controller exposes the TestController action, assertion, cookie, screenshot, window and role API and executes commands against the isolated tab directly over CDP (Input, Runtime, Page, Network, Storage). Selectors and client functions are compiled with the regular builders and evaluated in the isolated page. t2.run(fn) executes a callback in which Selector, ClientFunction and t.expect resolve against the isolated tab, so page objects written for t work unchanged. Isolated sessions are disposed when the test finishes, before the before-done hooks. Opening one without native automation throws the coded error E103. --- src/api/test-controller/index.js | 17 + src/api/test-controller/isolated.js | 487 ++++ .../selectors/selector-builder.js | 2 + src/errors/test-run/templates.js | 4 + src/errors/types.js | 1 + src/shared/errors/index.js | 6 + .../commands/execute-client-function.js | 2 + src/test-run/index.ts | 39 + src/test-run/isolated-session.ts | 2040 +++++++++++++++++ ts-defs-src/test-api/test-controller.d.ts | 46 + 10 files changed, 2644 insertions(+) create mode 100644 src/api/test-controller/isolated.js create mode 100644 src/test-run/isolated-session.ts diff --git a/src/api/test-controller/index.js b/src/api/test-controller/index.js index 7cfd9153f47..992d046b712 100644 --- a/src/api/test-controller/index.js +++ b/src/api/test-controller/index.js @@ -76,6 +76,7 @@ import { MultipleWindowsModeIsDisabledError, MultipleWindowsModeIsNotAvailableInRemoteBrowserError, MultipleWindowsModeIsNotSupportedInNativeAutomationModeError, + IsolatedSessionRequiresNativeAutomationError, } from '../../errors/test-run'; import { AssertionCommand } from '../../test-run/commands/assertion'; @@ -647,6 +648,22 @@ export default class TestController { [delegatedAPI(ReportCommand.methodName)] (...args) { return this.enqueueCommand(ReportCommand, { args }); } + + _openIsolatedSession$ () { + const callsite = getCallsiteForMethod('openIsolatedSession'); + + return this._enqueueTask('openIsolatedSession', () => { + return async () => { + if (!this.testRun.isNativeAutomation) + throw new IsolatedSessionRequiresNativeAutomationError(); + + 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..e313aadb9a1 --- /dev/null +++ b/src/api/test-controller/isolated.js @@ -0,0 +1,487 @@ +// +// 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. +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 ClientFunctionBuilder from '../../client-functions/client-function-builder'; +import { isSelector } from '../../client-functions/types'; + +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.testRun = isolatedSession; + + this._addTestControllerToExecutionChain(); + } + + _addTestControllerToExecutionChain () { + this.executionChain._testController = this; + } + + _createCommand (CmdCtor, cmdArgs, callsite) { + try { + return new CmdCtor(cmdArgs, this._session.parentTestRun); + } + catch (err) { + err.callsite = callsite; + + throw err; + } + } + + _enqueueTask (apiMethodName, createTaskExecutor, callsite) { + const taskExecutor = createTaskExecutor(); + + const executor = async () => { + this._session.throwUnexpectedDialog(); + + const result = await taskExecutor(); + + this._session.throwUnexpectedDialog(); + + return result; + }; + + this.executionChain.then = originalThen; + this.executionChain = this.executionChain.then(executor).catch(err => { + this.executionChain = Promise.resolve(); + + throw err; + }); + + 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 () => this._session.executeCommand(command, callsite); + }, 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 + // ===================================================================== + + _getSelectorForScroll (args) { + const selector = typeof args[0] === 'string' && args.length > 1 || isSelector(args[0]) ? args[0] : void 0; + + if (selector) + args.shift(); + + return selector; + } + + [delegatedAPI(ScrollCommand.methodName)] (...args) { + const selector = this._getSelectorForScroll(args); + + if (typeof args[0] === 'string') { + const [position, options] = args; + + return this.enqueueCommand(ScrollCommand, { selector, position, options }); + } + + const [x, y, options] = args; + + return this.enqueueCommand(ScrollCommand, { selector, x, y, options }); + } + + [delegatedAPI(ScrollByCommand.methodName)] (...args) { + const selector = this._getSelectorForScroll(args); + + const [byX, byY, options] = args; + + return this.enqueueCommand(ScrollByCommand, { selector, byX, byY, 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, options) { + const session = this._session; + const callsite = getCallsiteForMethod('eval'); + + return this._enqueueTask('eval', () => { + return async () => { + if (options?.dependencies) { + const command = new ClientFunctionBuilder(fn, options, { instantiation: 'eval', execution: 'eval' }).getCommand([]); + const [value] = await session._executeClientFunctionViaCDP(command); + + return value; + } + + const expression = `(${fn.toString()})()`; + + return session.evaluateExpression(expression); + }; + }, callsite); + } + + _takeScreenshot$ (options) { + const session = this._session; + const callsite = getCallsiteForMethod('takeScreenshot'); + + return this._enqueueTask('takeScreenshot', () => { + return async () => { + return session.takeScreenshot(options); + }; + }, 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` imported from + // 'testcafe' resolves to t2 and every Selector / ClientFunction targets + // the isolated session's CDP tab. A `t` captured from the test function's + // parameter is still the main controller: its actions hit the main tab. + // + // Usage: + // await t2.run(async () => { + // await t2.click(selector) // → clicks in isolated tab + // await t.expect(sel.exists).ok() // → checks DOM in isolated tab + // await sel.innerText // → reads DOM in isolated tab + // }) + // ===================================================================== + + _run$ (fn) { + const session = this._session; + const testRun = session.parentTestRun; + const callsite = getCallsiteForMethod('run'); + + return this._enqueueTask('run', () => { + return async () => { + const originalController = testRun.controller; + const outerChain = this.executionChain; + + // The callback runs inside a task of the outer chain: commands it enqueues + // need a chain of their own, or they would wait for this task and deadlock + this.executionChain = Promise.resolve(); + this._addTestControllerToExecutionChain(); + + testRun.controller = this; + + try { + // The tracking marker lets the global `t` proxy resolve this test run. + const wrappedFn = testRunTracker.addTrackingMarkerToFunction(testRun.id, fn); + + await session.withSelectorRouting(async () => { + await wrappedFn(); + await this.executionChain; + }); + } + finally { + testRun.controller = originalController; + this.executionChain = outerChain; + } + }; + }, callsite); + } + + shouldStop (command) { + return command === 'debug'; + } +} + +IsolatedTestController.API_LIST = getDelegatedAPIList(IsolatedTestController.prototype); + +delegateAPI(IsolatedTestController.prototype, IsolatedTestController.API_LIST, { useCurrentCtxAsHandler: 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/errors/test-run/templates.js b/src/errors/test-run/templates.js index 8b112e5f3a6..b4c196e1e17 100644 --- a/src/errors/test-run/templates.js +++ b/src/errors/test-run/templates.js @@ -410,6 +410,10 @@ export default { The Native Automation mode does not support the use of multiple browser windows. Use the "disable native automation" option to continue. `, + [TEST_RUN_ERRORS.isolatedSessionRequiresNativeAutomationError]: () => ` + The "openIsolatedSession" method is available only in the Native Automation mode. Enable native automation to continue. + `, + [TEST_RUN_ERRORS.cannotCloseWindowWithoutParent]: () => ` Cannot close the window because it does not have a parent. The parent window was closed or you are attempting to close the root browser window where tests were launched. `, diff --git a/src/errors/types.js b/src/errors/types.js index 3f97b2baafa..879edd7945e 100644 --- a/src/errors/types.js +++ b/src/errors/types.js @@ -103,6 +103,7 @@ export const TEST_RUN_ERRORS = { actionInvalidObjectPropertyError: 'E100', actionElementIsNotTargetError: 'E101', multipleWindowsModeIsNotSupportedInNativeAutomationError: 'E102', + isolatedSessionRequiresNativeAutomationError: 'E103', }; export const RUNTIME_ERRORS = { diff --git a/src/shared/errors/index.js b/src/shared/errors/index.js index 1c85cad9e77..b4fd58b2f03 100644 --- a/src/shared/errors/index.js +++ b/src/shared/errors/index.js @@ -475,3 +475,9 @@ export class MultipleWindowsModeIsNotSupportedInNativeAutomationModeError extend super(TEST_RUN_ERRORS.multipleWindowsModeIsNotSupportedInNativeAutomationError); } } + +export class IsolatedSessionRequiresNativeAutomationError extends TestRunErrorBase { + constructor () { + super(TEST_RUN_ERRORS.isolatedSessionRequiresNativeAutomationError); + } +} 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 f291b0dc613..72443c49ad5 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,39 @@ 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) { + // Best-effort teardown: a dispose failure must not mask the test result. + try { + await session.dispose(); + } + catch (e) { + // ignore + } + } + + this._isolatedSessions = []; + } + 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..df3dd97716a --- /dev/null +++ b/src/test-run/isolated-session.ts @@ -0,0 +1,2040 @@ +import { nanoid } from 'nanoid'; +import { noop } from 'lodash'; +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 { initSelector } from './commands/validations/initializers'; +import testRunMarker from './marker-symbol'; +import { createReplicator } from '../client-functions/replicator'; + +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; + +// Fallback when neither the selector nor the run options define a timeout +const DEFAULT_SELECTOR_TIMEOUT = 10000; + +// Fallback when neither the assertion options nor the run options define a timeout +const DEFAULT_ASSERTION_TIMEOUT = 3000; + +// Poll interval while waiting for an element to appear and become visible +const ELEMENT_WAIT_POLL_INTERVAL = 100; + +// Drag step count for smooth drag operations +const DRAG_STEPS = 10; + +// Small delay between drag steps (~60fps) +const DRAG_STEP_DELAY = 16; + +const SCROLL_POSITIONS = ['topLeft', 'top', 'topRight', 'left', 'center', 'right', 'bottomLeft', 'bottom', 'bottomRight']; + +// Resolve options for callers that act on the element as it is right now +const RESOLVE_NOW = { timeout: 0, visibilityCheck: false }; + +const IS_VISIBLE_FN = `function (el) { + if (el.nodeType !== 1) return false; + var rect = el.getBoundingClientRect(); + var s = window.getComputedStyle(el); + return !(rect.width === 0 && rect.height === 0) && s.display !== 'none' && s.visibility !== 'hidden'; +}`; + +const SNAPSHOT_FN = `function () { + var el = this; + 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 = (${IS_VISIBLE_FN})(el); + 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 + }; +}`; + +// Compiled ClientFunction code calls hammerhead's script-processing instructions, and the isolated tab has no hammerhead +const HAMMERHEAD_INSTRUCTION_SHIMS = ` + var __get$ = function (obj, prop) { return obj[prop]; }; + var __set$ = function (obj, prop, value) { return obj[prop] = value; }; + var __call$ = function (obj, method, args) { return obj[method].apply(obj, args); }; + var __get$Loc = function (location) { return location; }; + var __set$Loc = function () { return false; }; + var __proc$Script = function (script) { return script; }; + var __proc$Html = function (html) { return html; }; + var __get$Eval = function (evalFn) { return evalFn; }; + var __get$PostMessage = function (win, postMessageFn) { return win ? win.postMessage : postMessageFn; }; + var __arrayFrom$ = function (value) { return Array.isArray(value) ? value : Array.from(value); }; + var __rest$Array = function (arr, start) { return Array.prototype.slice.call(arr, start); }; + var __rest$Object = function (obj, excluded) { + var rest = {}; + Object.keys(obj).forEach(function (key) { if (excluded.indexOf(key) === -1) rest[key] = obj[key]; }); + return rest; + }; +`; + +const FRAME_CONTENT_ORIGIN_FN = `function () { + var rect = this.getBoundingClientRect(); + var s = window.getComputedStyle(this); + return { x: rect.left + this.clientLeft + parseFloat(s.paddingLeft), y: rect.top + this.clientTop + parseFloat(s.paddingTop) }; +}`; + +const DISPATCH_EVENT_FN = `function (eventName, options) { + const opts = Object.assign({}, options, { + bubbles: options.bubbles !== false, + cancelable: options.cancelable !== false, + detail: options.detail || { click: 1, dblclick: 2, mousedown: 1, mouseup: 1 }[eventName], + view: window, + buttons: options.buttons === void 0 ? 1 : options.buttons, + }); + let ctorName = 'CustomEvent'; + if (/^((mouse\\w+)|((dbl)?click)|(contextmenu))$/.test(eventName) || /^((drag\\w*)|(drop))$/.test(eventName)) + ctorName = 'MouseEvent'; + else if (/^pointer\\w+/.test(eventName)) + ctorName = 'PointerEvent'; + else if (/^key\\w+$/.test(eventName)) + ctorName = 'KeyboardEvent'; + else if (/^(before)?input$/.test(eventName)) + ctorName = 'InputEvent'; + else if (/^(blur|(focus(in|out)?))$/.test(eventName)) + ctorName = 'FocusEvent'; + if (options.eventConstructor && typeof window[options.eventConstructor] === 'function') + ctorName = options.eventConstructor; + this.dispatchEvent(new window[ctorName](eventName, opts)); +}`; + +const FILE_INPUT_CHECK_FN = `function () { + if (this.tagName !== 'INPUT' || this.type !== 'file') + throw new Error('Element is not a file input: ' + this.tagName + '[type=' + this.type + ']'); +}`; + +class CompiledClientFunction { + public constructor (public readonly fnCode: string, public readonly dependencies: unknown) {} +} + +class ClientFunctionTransform { + public readonly type = 'Function'; + + public shouldTransform (): boolean { + return false; + } + + public toSerializable (): void { + return void 0; + } + + public fromSerializable (value: { fnCode: string; dependencies: unknown }): object { + return new CompiledClientFunction(value.fnCode, value.dependencies); + } +} + +function toClientJs (value: any): string { + if (value === void 0) + return 'void 0'; + + if (typeof value === 'number') + return Object.is(value, -0) ? '-0' : String(value); + + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return JSON.stringify(value); + + if (value instanceof CompiledClientFunction) + return `(function() { var __dependencies$ = ${toClientJs(value.dependencies)}; return ${value.fnCode.replace(/;\s*$/, '')}; })()`; + + if (value instanceof Date) + return `new Date(${value.getTime()})`; + + if (value instanceof RegExp) + return `new RegExp(${JSON.stringify(value.source)}, ${JSON.stringify(value.flags)})`; + + if (Array.isArray(value)) + return `[${value.map(toClientJs).join(', ')}]`; + + const proto = typeof value === 'object' ? Object.getPrototypeOf(value) : void 0; + + if (proto === Object.prototype || proto === null) + return `{${Object.keys(value).map(key => `${JSON.stringify(key)}: ${toClientJs(value[key])}`).join(', ')}}`; + + throw new Error(`Isolated session: cannot pass ${Object.prototype.toString.call(value)} to the isolated tab. Use primitives, arrays, plain objects, dates, regular expressions or functions.`); +} + +export interface ResolvedElement { + objectId: string; + contextId: number | undefined; + description: string; +} + +/** + * 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; + + // Main-world execution context of every frame, by frameId + private readonly _frameContexts: Map; + + private _frameElements: ResolvedElement[]; + + private _unexpectedDialog: { type: string; message: string; url: string } | null; + + // Configurable page load timeout (default: PAGE_LOAD_TIMEOUT constant) + private _pageLoadTimeout: number; + + // Role storages ({ localStorage, sessionStorage } snapshot strings) waiting to be + // applied on the next navigation — storage APIs need a real page origin + private _pendingRoleStorages: { localStorage?: string; sessionStorage?: string } | null; + + 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; + + this._pendingRoleStorages = null; + + // The CDP client for the isolated tab + this._cdpClient = nativeAutomation.cdpClient; + + this._frameContexts = new Map(); + this._frameElements = []; + this._unexpectedDialog = null; + + // Lets ClientFunction / Selector .with({ boundTestRun: t2 }) accept the isolated session + (this as any)[testRunMarker] = true; + + this._trackFrameContexts(); + this._handleNativeDialogs(); + IsolatedSession._installSelectorRouter(parentTestRun); + } + + // TestRun.opts is private — the isolated session intentionally reads the run + // options (selectorTimeout, assertionTimeout, screenshots) at runtime + private get _runOpts (): any { + return (this.parentTestRun as any).opts || {}; + } + + /** + * 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.). + */ + 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; + + // Selectors and ClientFunctions (e.g. sel.visible in an assertion or inside t2.run) + // execute through testRun.executeCommand, which targets the MAIN tab. One permanent + // router per test run consults a stack of sessions that currently own selector + // execution and redirects selector / ClientFunction commands to the innermost one. + private static _installSelectorRouter (testRun: any): void { + if (testRun._isolatedSelectorTargets) + return; + + testRun._isolatedSelectorTargets = []; + + const originalExecuteCommand = testRun.executeCommand.bind(testRun); + + testRun.executeCommand = async (cmd: any, cmdCallsite?: any) => { + const sessions = testRun._isolatedSelectorTargets; + const active = sessions.length ? sessions[sessions.length - 1] : null; + + if (active && cmd.type === COMMAND_TYPE.executeSelector) + return active._executeSelectorViaCDP(cmd); + + if (active && cmd.type === COMMAND_TYPE.executeClientFunction) + return active._executeClientFunctionViaCDP(cmd); + + return originalExecuteCommand(cmd, cmdCallsite); + }; + } + + /** Route selectors and ClientFunctions to this session while fn runs. */ + public async withSelectorRouting (fn: () => Promise): Promise { + const sessions = (this.parentTestRun as any)._isolatedSelectorTargets; + + sessions.push(this); + + try { + return await fn(); + } + finally { + sessions.splice(sessions.lastIndexOf(this), 1); + } + } + + private async _executeAssertion (command: any, callsite?: CallsiteRecord | string): Promise { + const reExecutable = command.actual instanceof ReExecutablePromise ? command.actual : null; + const timeout = command.options?.timeout ?? this._runOpts.assertionTimeout ?? DEFAULT_ASSERTION_TIMEOUT; + const startTime = Date.now(); + + const sessions = (this.parentTestRun as any)._isolatedSelectorTargets; + + if (reExecutable) + sessions.push(this); + + try { + 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); + } + } + } + finally { + if (reExecutable) + sessions.splice(sessions.lastIndexOf(this), 1); + } + } + + // ===================================================================== + // 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). + // Like the driver, counter mode answers at once and snapshot mode waits up to the selector timeout. + public async _executeSelectorViaCDP (command: any): Promise { + // Wrap in array: the replicator's decode() expects encode()-format + // (encode wraps values in [value], decode returns references[0]) + if (command.counterMode) + return [await this.evaluateExpression(`${this._compileNodesExpression(command)}.length`)]; + + const timeout = command.timeout ?? this._runOpts.selectorTimeout ?? DEFAULT_SELECTOR_TIMEOUT; + const found = await this._findElement(command, timeout, command.visibilityCheck); + + if (found.objectId) + return [await this.callOnElement(found as ResolvedElement, SNAPSHOT_FN)]; + + // getVisibleValueMode: return null without error when element not found + if (command.needError && !command.getVisibleValueMode) + throw new Error(found.error); + + return [null]; + } + + /** + * Resolve a CSS string, Selector, node snapshot or selector command to a remote object + * in the current frame. Waits up to the selector timeout for the element to appear (and, + * with visibilityCheck, to become visible), then throws. + */ + public async resolveElement (selector: any, options: { timeout?: number; visibilityCheck?: boolean } = {}): Promise { + const command: any = initSelector('selector', selector, { testRun: this.parentTestRun }); + const timeout = options.timeout ?? command.timeout ?? this._runOpts.selectorTimeout ?? DEFAULT_SELECTOR_TIMEOUT; + const visibilityCheck = options.visibilityCheck ?? command.visibilityCheck; + const found = await this._findElement(command, timeout, visibilityCheck); + + if (!found.objectId) + throw new Error(found.error); + + return found as ResolvedElement; + } + + /** Call a function with the element as `this` and return its result by value. */ + public async callOnElement (element: ResolvedElement, functionDeclaration: string, args: unknown[] = []): Promise { + const result = await this._cdpClient.Runtime.callFunctionOn({ + objectId: element.objectId, + functionDeclaration, + arguments: args.map(value => ({ value })), + returnByValue: true, + awaitPromise: true, + }); + + this._throwOnException(result.exceptionDetails); + + return result.result.value; + } + + private async _findElement (command: any, timeout: number, visibilityCheck: boolean): Promise & { error: string }> { + const description = this._describeSelector(command); + const expression = `(function() { + var el = ${this._compileNodesExpression(command)}[0]; + if (!el) return null; + return ${!!visibilityCheck} && !(${IS_VISIBLE_FN})(el) ? 'invisible' : el; + })()`; + + const startTime = Date.now(); + + while (true) { + const contextId = this._currentContextId; + const result = await this._cdpClient.Runtime.evaluate({ expression, ...this._contextIdParam() }); + + this._throwOnException(result.exceptionDetails); + + if (result.result.objectId) + return { objectId: result.result.objectId, contextId, description, error: '' }; + + if (Date.now() - startTime >= timeout) { + const reason = result.result.value === 'invisible' ? 'did not become visible' : 'was not found'; + + return { description, error: `Isolated session: the element ${reason} within ${timeout}ms: ${description}` }; + } + + await delay(ELEMENT_WAIT_POLL_INTERVAL); + } + } + + // A selector head is "Selector('')" with the CSS inserted verbatim (quotes unescaped). + // Returns null when the entry is not a plain CSS-string selector. + private _extractBaseCss (chainEntry: string): string | null { + const cssMatch = chainEntry.match(/^Selector\('([\s\S]*)'\)$/); + + return cssMatch ? cssMatch[1] : null; + } + + // Parse an apiFnChain into its base CSS and compiled chain-step JS statements + private _compileChain (selector: any): { baseCss: string; steps: string[] } { + const chain = selector.apiFnChain; + const chainEntry = chain[0]; + const baseCss = this._extractBaseCss(chainEntry); + + if (baseCss === null) { + 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 steps: string[] = []; + + for (let i = 1; i < chain.length; i++) { + // .with() options already sit on the command (timeout, visibilityCheck) + if (chain[i].startsWith('.with(')) + continue; + + const parsed = this._parseChainMethod(chain[i]); + + steps.push(this._chainStepToJS(parsed)); + } + + return { baseCss, steps }; + } + + // Compile a selector's apiFnChain into a JS expression that evaluates to the matching nodes + private _compileNodesExpression (selector: any): string { + const { baseCss, steps } = this._compileChain(selector); + + return `(function() { var nodes = Array.from(document.querySelectorAll(${JSON.stringify(baseCss)})); ${steps.join(' ')} return nodes; })()`; + } + + // ===================================================================== + // 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'); + + // decode() rewrites its input in place, and a retrying assertion re-runs the same command + const replicator = createReplicator([new ClientFunctionTransform()]); + const decode = (encoded: unknown): string => toClientJs(replicator.decode(JSON.parse(JSON.stringify(encoded)))); + const args = decode(command.args); + const dependencies = decode(command.dependencies); + + const expression = `(function() { ${HAMMERHEAD_INSTRUCTION_SHIMS} var __dependencies$ = ${dependencies}; var __f$ = ${fnCode}; return __f$.apply(window, ${args}); })()`; + + // 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 { + // Navigation destroys iframe execution contexts — a stale contextId would + // make every subsequent eval fail until the page-load poll times out. + this._currentContextId = void 0; + this._frameElements = []; + + const { errorText } = await this._cdpClient.Page.navigate({ url }); + + if (errorText) + throw new Error(`Isolated session: failed to navigate to ${url}: ${errorText}`); + + await this._waitForPageLoad(); + await this._applyPendingRoleStorages(); + } + + 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(), + }); + + this._throwOnException(result.exceptionDetails); + + return result.result.value; + } + + private _throwOnException (exceptionDetails: any): void { + if (!exceptionDetails) + return; + + const errText = exceptionDetails.exception?.description + || exceptionDetails.text + || 'Expression evaluation failed'; + + throw new Error(`Isolated session eval error: ${errText}`); + } + + // Returns contextId param for Runtime.evaluate when inside an iframe + private _contextIdParam (): { contextId?: number } { + return this._currentContextId !== void 0 ? { contextId: this._currentContextId } : {}; + } + + // ===================================================================== + // Mouse interactions + // ===================================================================== + + // Convert TestCafe action modifiers ({ ctrl, alt, shift, meta }) to a CDP bitmask + private _getModifiersBitmask (options: any): number { + if (!options) + return 0; + + return (options.alt ? 1 : 0) | (options.ctrl ? 2 : 0) | (options.meta ? 4 : 0) | (options.shift ? 8 : 0); + } + + // TestCafe offsets are relative to the element's top-left corner; + // negative values count from the right/bottom edge + private _offsetExpr (offset: number | undefined, sizeExpr: string): string { + if (typeof offset !== 'number') + return `${sizeExpr} / 2`; + + return offset >= 0 ? String(offset) : `${sizeExpr} + (${offset})`; + } + + // Wait until the element exists and is visible, then return the target point. + // Mirrors TestCafe's action auto-wait: poll until the selector timeout elapses. + private async _waitForElementPoint (selector: any, options: any): Promise<{ x: number; y: number }> { + const element = await this.resolveElement(selector, { visibilityCheck: true }); + const xExpr = this._offsetExpr(options?.offsetX, 'rect.width'); + const yExpr = this._offsetExpr(options?.offsetY, 'rect.height'); + + const point = await this.callOnElement(element, `function () { + this.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = this.getBoundingClientRect(); + return { x: rect.x + (${xExpr}), y: rect.y + (${yExpr}) }; + }`); + + return this._toTopViewport(point); + } + + private async _toTopViewport (point: T): Promise { + for (const frame of this._frameElements) { + const origin = await this.callOnElement(frame, FRAME_CONTENT_ORIGIN_FN); + + point.x += origin.x; + point.y += origin.y; + } + + return point; + } + + private async _getElementPoint (command: any): Promise<{ x: number; y: number }> { + return this._waitForElementPoint(command.selector, command.options); + } + + private _describeSelector (selector: any): string { + return selector.apiFnChain.join(''); + } + + private async _setCaret (selector: any, caretPos: number | null): Promise { + const element = await this.resolveElement(selector, RESOLVE_NOW); + + await this.callOnElement(element, `function (caretPos) { + if (typeof this.setSelectionRange === 'function' && typeof this.value === 'string') { + const pos = caretPos === null ? this.value.length : caretPos; + try { + this.setSelectionRange(pos, pos); + } + catch (e) {} + } + else if (this.isContentEditable && caretPos === null) { + const selection = window.getSelection(); + selection.selectAllChildren(this); + selection.collapseToEnd(); + } + }`, [caretPos]); + } + + // Click an element via CDP input dispatch + private async _cdpClick (command: any, button: 'left' | 'right' = 'left', clickCount = 1): Promise { + const coords = await this._getElementPoint(command); + const modifiers = this._getModifiersBitmask(command.options?.modifiers); + + // mouseMoved before press so hover/mouseenter handlers fire, as a real click would + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mouseMoved', x: coords.x, y: coords.y, modifiers, + }); + + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mousePressed', x: coords.x, y: coords.y, button, clickCount, modifiers, + }); + + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mouseReleased', x: coords.x, y: coords.y, button, clickCount, modifiers, + }); + + if (typeof command.options?.caretPos === 'number') + await this._setCaret(command.selector, command.options.caretPos); + } + + // Hover over an element (mouseMoved without press) + private async _cdpHover (command: any): Promise { + const coords = await this._getElementPoint(command); + const modifiers = this._getModifiersBitmask(command.options?.modifiers); + + await this._cdpClient.Input.dispatchMouseEvent({ + type: 'mouseMoved', x: coords.x, y: coords.y, modifiers, + }); + } + + // Drag an element by pixel offset + private async _cdpDrag (command: any): Promise { + const start = await this._getElementPoint(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._getElementPoint(command); + + const end = await this._waitForElementPoint( + (command as any).destinationSelector, + { offsetX: (command as any).options?.destinationOffsetX, offsetY: (command as any).options?.destinationOffsetY } + ); + + 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; + + await this._cdpClick(command); + + if (!command.options?.replace && typeof command.options?.caretPos !== 'number') + await this._setCaret(command.selector, null); + + if (command.options?.replace) { + const element = await this.resolveElement(command.selector, RESOLVE_NOW); + + await this.callOnElement(element, `function () { + const el = this; + if (typeof el.select === 'function') + el.select(); + else if (typeof el.setSelectionRange === 'function') + el.setSelectionRange(0, el.value.length); + else + document.execCommand('selectAll'); + }`); + } + + 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 }, + }; + + const SHORTCUT_COMMANDS: Record = { + 'ctrl+a': ['selectAll'], + 'ctrl+c': ['copy'], + 'ctrl+v': ['paste'], + 'ctrl+x': ['cut'], + 'ctrl+z': ['undo'], + 'ctrl+y': ['redo'], + }; + + // Canonical DOM key values — event.key must be 'Enter', not 'enter', or + // application key handlers won't match. text triggers the browser's default + // behavior for printable keys and Enter (newline insertion, form submission). + const KEY_CODE_MAP: Record = { + 'enter': { key: 'Enter', code: 'Enter', keyCode: 13, text: '\r' }, + 'tab': { key: 'Tab', code: 'Tab', keyCode: 9 }, + 'esc': { key: 'Escape', code: 'Escape', keyCode: 27 }, + 'escape': { key: 'Escape', code: 'Escape', keyCode: 27 }, + 'backspace': { key: 'Backspace', code: 'Backspace', keyCode: 8 }, + 'delete': { key: 'Delete', code: 'Delete', keyCode: 46 }, + 'space': { key: ' ', code: 'Space', keyCode: 32, text: ' ' }, + 'up': { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 }, + 'down': { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 }, + 'left': { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 }, + 'right': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 }, + 'arrowup': { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 }, + 'arrowdown': { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 }, + 'arrowleft': { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 }, + 'arrowright': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 }, + 'home': { key: 'Home', code: 'Home', keyCode: 36 }, + 'end': { key: 'End', code: 'End', keyCode: 35 }, + 'pageup': { key: 'PageUp', code: 'PageUp', keyCode: 33 }, + 'pagedown': { key: 'PageDown', code: 'PageDown', keyCode: 34 }, + 'ins': { key: 'Insert', code: 'Insert', keyCode: 45 }, + 'capslock': { key: 'CapsLock', code: 'CapsLock', keyCode: 20 }, + 'f1': { key: 'F1', code: 'F1', keyCode: 112 }, + 'f2': { key: 'F2', code: 'F2', keyCode: 113 }, + 'f3': { key: 'F3', code: 'F3', keyCode: 114 }, + 'f4': { key: 'F4', code: 'F4', keyCode: 115 }, + 'f5': { key: 'F5', code: 'F5', keyCode: 116 }, + 'f6': { key: 'F6', code: 'F6', keyCode: 117 }, + 'f7': { key: 'F7', code: 'F7', keyCode: 118 }, + 'f8': { key: 'F8', code: 'F8', keyCode: 119 }, + 'f9': { key: 'F9', code: 'F9', keyCode: 120 }, + 'f10': { key: 'F10', code: 'F10', keyCode: 121 }, + 'f11': { key: 'F11', code: 'F11', keyCode: 122 }, + 'f12': { key: 'F12', code: 'F12', keyCode: 123 }, + }; + + 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); + } + + const comboLower = combo.toLowerCase(); + const cmdCommands = SHORTCUT_COMMANDS[comboLower] || []; + + 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, + }); + } + + for (const key of regularKeys) { + const keyLower = key.toLowerCase(); + const keyInfo = KEY_CODE_MAP[keyLower]; + + // Fabricating key/code/keyCode for an unknown named key would dispatch a + // plausible-looking but wrong event — fail loudly instead + if (!keyInfo && key.length > 1) { + throw new Error( + `Isolated session: unsupported key '${key}' in pressKey. ` + + `Supported named keys: ${Object.keys(KEY_CODE_MAP).join(', ')}.` + ); + } + + const hasNonShiftModifiers = (modifiersBitmask & ~8) !== 0; + const hasShift = (modifiersBitmask & 8) !== 0; + const isLetter = /^[a-z]$/i.test(key); + const isDigit = /^[0-9]$/.test(key); + + // Shift changes which character a non-letter key produces ('shift+1' → '!'), + // which depends on the keyboard layout — require the shifted character directly + if (hasShift && key.length === 1 && !isLetter) + throw new Error(`Isolated session: pressKey('shift+${key}') is not supported — press the shifted character directly, e.g. pressKey('!').`); + + const shiftedKey = hasShift && isLetter ? key.toUpperCase() : key; + const keyValue = keyInfo ? keyInfo.key : shiftedKey; + + let code: string; + + if (keyInfo) + code = keyInfo.code; + else if (isDigit) + code = `Digit${key}`; + else if (isLetter) + code = `Key${key.toUpperCase()}`; + else + code = ''; + + const keyCode = keyInfo ? keyInfo.keyCode : key.toUpperCase().charCodeAt(0); + + // text makes the browser run default key behavior (character insertion, + // Enter submitting a form). Suppressed when a non-shift modifier is held — + // ctrl+a must not type an 'a'. + let text: string | undefined = keyInfo ? keyInfo.text : shiftedKey; + + if (hasNonShiftModifiers) + text = void 0; + + await this._cdpClient.Input.dispatchKeyEvent({ + type: text ? 'keyDown' : 'rawKeyDown', + key: keyValue, + code, + text, + windowsVirtualKeyCode: keyCode, + nativeVirtualKeyCode: keyCode, + modifiers: modifiersBitmask, + commands: cmdCommands, + } as any); + + await this._cdpClient.Input.dispatchKeyEvent({ + type: 'keyUp', + key: keyValue, + 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 element = await this.resolveElement(command.selector); + + await this.callOnElement(element, `function (startPos, endPos) { + const el = this; + el.focus(); + if (typeof el.setSelectionRange === 'function') { + const end = endPos === null ? el.value.length : endPos; + if (startPos > end) + el.setSelectionRange(end, startPos, 'backward'); + else + el.setSelectionRange(startPos, end); + return; + } + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + const points = []; + let offset = 0; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + points.push({ node: node, start: offset }); + offset += node.data.length; + } + const end = endPos === null ? offset : Math.min(endPos, offset); + const toPoint = function (pos) { + for (let i = points.length - 1; i >= 0; i--) { + if (points[i].start <= pos) + return [points[i].node, pos - points[i].start]; + } + return [el, 0]; + }; + const anchor = toPoint(Math.min(startPos, offset)); + const focus = toPoint(end); + window.getSelection().setBaseAndExtent(anchor[0], anchor[1], focus[0], focus[1]); + }`, [command.startPos ?? 0, command.endPos ?? null]); + } + + // ===================================================================== + // Scroll + // ===================================================================== + + private async _cdpScroll (command: any): Promise { + const { x, y, position } = command; + + if (position && !SCROLL_POSITIONS.includes(position)) + throw new Error(`Isolated session: unknown scroll position '${position}'. Use one of: ${SCROLL_POSITIONS.join(', ')}.`); + + await this._setScroll(command.selector, { x, y, position }); + } + + private async _cdpScrollBy (command: any): Promise { + await this._setScroll(command.selector, { byX: command.byX, byY: command.byY }); + } + + private async _setScroll (selector: any, scroll: { x?: number | null; y?: number | null; position?: string; byX?: number; byY?: number }): Promise { + const element = selector + ? await this.resolveElement(selector) + : await this._resolveScrollingElement(); + + await this.callOnElement(element, `function (s) { + const el = this; + let x = s.x; + let y = s.y; + if (s.position) { + const centerX = Math.floor(el.scrollWidth / 2 - el.clientWidth / 2); + const centerY = Math.floor(el.scrollHeight / 2 - el.clientHeight / 2); + const positions = { + top: [centerX, 0], + right: [el.scrollWidth, centerY], + bottom: [centerX, el.scrollHeight], + left: [0, centerY], + topRight: [el.scrollWidth, 0], + topLeft: [0, 0], + bottomRight: [el.scrollWidth, el.scrollHeight], + bottomLeft: [0, el.scrollHeight], + center: [centerX, centerY], + }; + x = positions[s.position][0]; + y = positions[s.position][1]; + } + let left = typeof x === 'number' ? x : el.scrollLeft; + let top = typeof y === 'number' ? y : el.scrollTop; + if (s.byX) + left += s.byX; + if (s.byY) + top += s.byY; + el.scrollLeft = left; + el.scrollTop = top; + }`, [scroll]); + } + + private async _resolveScrollingElement (): Promise { + const contextId = this._currentContextId; + const result = await this._cdpClient.Runtime.evaluate({ + expression: 'document.scrollingElement || document.documentElement', + ...this._contextIdParam(), + }); + + this._throwOnException(result.exceptionDetails); + + return { objectId: result.result.objectId as string, contextId, description: 'document' }; + } + + private async _cdpScrollIntoView (command: any): Promise { + const element = await this.resolveElement(command.selector); + + await this.callOnElement(element, `function () { + this.scrollIntoView({ block: 'center', inline: 'center' }); + }`); + } + + // ===================================================================== + // Events + // ===================================================================== + + private async _cdpDispatchEvent (command: any): Promise { + const element = await this.resolveElement(command.selector); + const eventName = (command as any).eventName; + const eventOptions = (command as any).options || {}; + + await this.callOnElement(element, DISPATCH_EVENT_FN, [eventName, eventOptions]); + } + + // ===================================================================== + // Screenshots + // ===================================================================== + + // Resolve where to write a screenshot: explicit absolute path wins; relative paths + // and generated names go under the run's screenshots dir (or ./artifacts/screenshots) + private _resolveScreenshotPath (filePath: string | undefined, generatedPrefix: string): string { + const baseDir = this._runOpts.screenshots?.path || path.join(process.cwd(), 'artifacts', 'screenshots'); + + if (!filePath) + return path.join(baseDir, `${generatedPrefix}-${Date.now()}.png`); + + return path.isAbsolute(filePath) ? filePath : path.join(baseDir, filePath); + } + + /** Capture a PNG screenshot of the viewport, or of the whole page with { fullPage: true }. Returns the file path. */ + public async takeScreenshot (options?: string | { path?: string; fullPage?: boolean }): Promise { + const { path: filePath, fullPage } = typeof options === 'object' && options !== null ? options : { path: options, fullPage: false }; + + // Ensure Page domain is enabled (isolated windows don't call start()) + await (this._cdpClient as any).Page.enable(); + + let clip: object | undefined; + + if (fullPage) { + const { cssContentSize } = await (this._cdpClient as any).Page.getLayoutMetrics(); + + clip = { x: 0, y: 0, width: cssContentSize.width, height: cssContentSize.height, scale: 1 }; + } + + return this._captureScreenshot(clip, this._resolveScreenshotPath(filePath, 'isolated')); + } + + /** 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 element = await this.resolveElement(selector); + + const rect = await this.callOnElement(element, `function () { + this.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = this.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }`); + + await this._toTopViewport(rect); + + const scroll = await this._cdpClient.Runtime.evaluate({ expression: '({ x: window.scrollX, y: window.scrollY })', returnByValue: true }); + + await (this._cdpClient as any).Page.enable(); + + const clip = { ...rect, x: rect.x + scroll.result.value.x, y: rect.y + scroll.result.value.y, scale: 1 }; + + return this._captureScreenshot(clip, this._resolveScreenshotPath(filePath, 'isolated-element')); + } + + private async _captureScreenshot (clip: object | undefined, fullPath: string): Promise { + const { data } = await (this._cdpClient as any).Page.captureScreenshot({ + format: 'png', + clip, + captureBeyondViewport: !!clip, + }); + + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + 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 element = await this.resolveElement(selector, { visibilityCheck: false }); + const files = Array.isArray(filePaths) ? filePaths : [filePaths]; + + const resolvedFiles = files.map(f => path.resolve(f)); + + for (const f of resolvedFiles) { + if (!fs.existsSync(f)) + throw new Error(`setFilesToUpload: file not found: ${f}`); + } + + await this.callOnElement(element, FILE_INPUT_CHECK_FN); + + await (this._cdpClient as any).DOM.enable(); + + await (this._cdpClient as any).DOM.setFileInputFiles({ + files: resolvedFiles, + objectId: element.objectId, + }); + + // DOM.setFileInputFiles acks before the renderer has applied the files — + // reading el.files immediately races and sees the old (empty) list. + // Poll until they land instead of returning a silent maybe. + const startTime = Date.now(); + + while (true) { + const count = await this.callOnElement(element, 'function () { return this.files ? this.files.length : -1; }'); + + if (count === resolvedFiles.length) + return; + + if (Date.now() - startTime >= 3000) + throw new Error(`setFilesToUpload: expected ${resolvedFiles.length} file(s) on the input, found ${count} after 3000ms`); + + await delay(50); + } + } + + /** Clear files from a element. */ + public async clearUpload (selector: any): Promise { + const element = await this.resolveElement(selector, { visibilityCheck: false }); + + await this.callOnElement(element, FILE_INPUT_CHECK_FN); + + // Assigning an empty DataTransfer FileList leaves the files in place (Chrome 154) + await this.callOnElement(element, `function () { + this.value = ''; + this.dispatchEvent(new Event('change', { bubbles: true })); + }`); + } + + // ===================================================================== + // 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.getBrowserLevelCDPClient(this.parentTestRun.browserConnection.id); + } + + // ===================================================================== + // Iframe support + // ===================================================================== + + /** Switch the eval/command context into an iframe: its frame's main-world execution context. */ + public async switchToIframe (selector: any): Promise { + const element = await this.resolveElement(selector); + + await this.callOnElement(element, `function () { + if (this.tagName !== 'IFRAME' && this.tagName !== 'FRAME') + throw new Error('Element is not an iframe: ' + this.tagName); + }`); + + const startTime = Date.now(); + let contextId = void 0; + + while (!contextId) { + const { node } = await (this._cdpClient as any).DOM.describeNode({ objectId: element.objectId }); + + contextId = node.frameId && this._frameContexts.get(node.frameId); + + if (!contextId && Date.now() - startTime >= this._pageLoadTimeout) + throw new Error(`Isolated session: the iframe has no loaded document: ${element.description}`); + + if (!contextId) + await delay(50); + } + + this._currentContextId = contextId; + this._frameElements.push(element); + } + + // Switch back to the main frame's evaluation context + public async switchToMainWindow (): Promise { + this._currentContextId = void 0; + this._frameElements = []; + } + + private _trackFrameContexts (): void { + const { Runtime } = this._cdpClient; + + Runtime.on('executionContextCreated', ({ context }) => { + if (context.auxData?.isDefault && context.auxData.frameId) + this._frameContexts.set(context.auxData.frameId, context.id); + }); + + Runtime.on('executionContextDestroyed', ({ executionContextId }) => { + for (const [frameId, contextId] of this._frameContexts) { + if (contextId === executionContextId) + this._frameContexts.delete(frameId); + } + }); + + Runtime.on('executionContextsCleared', () => this._frameContexts.clear()); + } + + private _handleNativeDialogs (): void { + const { Page } = this._cdpClient; + + Page.on('javascriptDialogOpening', ({ type, message, url }) => { + const leavePage = type === 'beforeunload'; + + if (!leavePage && !this._unexpectedDialog) + this._unexpectedDialog = { type, message, url }; + + Page.handleJavaScriptDialog({ accept: leavePage }).catch(noop); + }); + } + + public throwUnexpectedDialog (): void { + const dialog = this._unexpectedDialog; + + if (!dialog) + return; + + this._unexpectedDialog = null; + + throw new Error( + `Isolated session: a native ${dialog.type} dialog ("${dialog.message}") was invoked on page ${dialog.url}. ` + + 'Isolated sessions dismiss native dialogs (confirm returns false, prompt returns null) and fail the next t2 command. ' + + `Stub window.${dialog.type} with t2.eval() before the action if the dialog is expected.` + ); + } + + // ===================================================================== + // HTTP Auth + // ===================================================================== + + // Set HTTP basic auth headers for the isolated session. + // NOTE: the header is attached to EVERY request from this tab, including ones to + // third-party origins — do not use with pages that load cross-origin resources + // unless leaking the credentials there is acceptable. + public async setHttpAuth (username: string, password: string): Promise { + const encoded = Buffer.from(`${username}:${password}`).toString('base64'); + + // Extra headers only take effect while the Network domain is enabled — + // isolated windows skip start(), so nothing enabled it yet + await this._cdpClient.Network.enable({}); + + 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; + + // ===================================================================== + // 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; + + 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; + } + + 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; + } + + 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; + } + + 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 { hostname, pathname } = new URL(url); + + return { domain: hostname, path: pathname }; + }); + } + + // Cookies set with an explicit domain are stored dot-prefixed ('.localhost') — + // compare without the leading dot so filters like { domain: 'localhost' } match + private _normalizeCookieDomain (domain: string): string { + return domain.replace(/^\./, ''); + } + + private _cookieMatchesFilter (cookie: any, filter: any): boolean { + if (filter.name && filter.name !== cookie.name) + return false; + + if (filter.domain && this._normalizeCookieDomain(filter.domain) !== this._normalizeCookieDomain(cookie.domain)) + return false; + + if (filter.path && filter.path !== cookie.path) + return false; + + return true; + } + + // Read the isolated context's cookies. Storage.getCookies on the TAB session + // returns the DEFAULT browser context's cookies — it must be called on the + // browser-level connection with an explicit browserContextId. + private async _getContextCookies (): Promise { + const browserClient = await this._getBrowserLevelClient(); + const { cookies } = await browserClient.Storage.getCookies({ browserContextId: this.browserContextId }); + + return cookies; + } + + private async _getCookies (externalCookies: any[] = [], urls: string[] = []): Promise { + const cookies = await this._getContextCookies(); + + if (!externalCookies.length) + return cookies.map(this._cdpCookieToExternal); + + const parsedUrls = this._parseCookieUrls(urls); + + return cookies + .filter(cookie => externalCookies.some(filter => { + // A filter without both domain and path is scoped by the provided urls, + // mirroring the non-isolated CookieProvider#getCookies behavior. + if (filter.domain && filter.path || !parsedUrls.length) + return this._cookieMatchesFilter(cookie, filter); + + return parsedUrls.some(url => + this._cookieMatchesFilter(cookie, { ...filter, domain: url.domain, path: url.path })); + })) + .map(this._cdpCookieToExternal); + } + + // CDP expects cookie expiry as seconds since epoch. Accepts a Date, a parseable + // date string (role snapshots arrive JSON-parsed), or a number; anything else + // (including TestCafe's 'Infinity') becomes a session cookie, which outlives + // the isolated context anyway. + private _cookieExpiresToSeconds (expires: any): number | undefined { + if (expires instanceof Date) + return expires.getTime() / 1000; + + if (expires === 'Infinity') + return void 0; + + if (typeof expires === 'string') { + const parsed = Date.parse(expires); + + // An unparseable date string is a caller bug — silently making a session + // cookie out of it would hide the mistake + if (isNaN(parsed)) + throw new Error(`Isolated session: cannot parse cookie 'expires' value: ${expires}`); + + return parsed / 1000; + } + + if (typeof expires === 'number' && isFinite(expires)) { + // Heuristic: values this large can only be milliseconds + return expires > 1e11 ? expires / 1000 : expires; + } + + return void 0; + } + + private async _setCookies (cookies: any[], url: string): Promise { + const { hostname = '', pathname = '/' } = url ? new URL(url) : {}; + const cookieParams = Array.isArray(cookies) ? cookies : [cookies]; + + // Chrome rejects cookies that have neither a domain nor a URL — and + // Network.setCookies drops rejected cookies without reporting them + for (const cookie of cookieParams) { + if (!cookie.domain && !hostname) + throw new Error(`Isolated session: cannot set cookie '${cookie.name}' — no domain given and no URL to derive one from.`); + } + + 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: this._cookieExpiresToSeconds(cookie.expires), + })), + }); + } + + private async _deleteCookies (cookies: any[] = [], urls: string[] = []): Promise { + if (!cookies || !cookies.length) + return this._cdpClient.Network.clearBrowserCookies(); + + const existing = await this._getContextCookies(); + const parsedUrls = this._parseCookieUrls(urls); + + for (const cookie of existing) { + if (parsedUrls.length && !parsedUrls.some(url => this._normalizeCookieDomain(url.domain) === this._normalizeCookieDomain(cookie.domain) && url.path === cookie.path)) + continue; + + if (cookies.some(filter => this._cookieMatchesFilter(cookie, filter))) { + 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) { + let roleCookies: any[] = []; + + try { + roleCookies = JSON.parse(stateSnapshot.cookies); + + await this._setCookies(roleCookies, ''); + } + catch (e: any) { + throw new Error(`Failed to apply role cookies in isolated session: ${e.message}`); + } + + // Network.setCookies drops cookies Chrome rejects (e.g. __Host- prefixed + // cookies with a domain attribute) without any error — verify they landed + const applied = await this._getContextCookies(); + const appliedNames = new Set(applied.map(c => c.name)); + const missing = roleCookies.filter(c => c.name && !appliedNames.has(c.name)).map(c => c.name); + + if (missing.length) { + throw new Error( + `Isolated session: the browser rejected the following role cookies: ${missing.join(', ')}. ` + + 'The role state would be incomplete.' + ); + } + } + + // Storage snapshots need a page on the role's origin — about:blank has no + // usable localStorage. Apply immediately when the session is already on a real + // page; otherwise stash them and apply after the next navigateTo. + const storages = stateSnapshot.storages; + + if (storages && (this._hasStorageEntries(storages.localStorage) || this._hasStorageEntries(storages.sessionStorage))) { + this._pendingRoleStorages = storages; + + const onRealPage = await this.evaluateExpression("location.origin !== 'null' && location.protocol !== 'about:'"); + + if (onRealPage) + await this._applyPendingRoleStorages(); + } + } + + // A hammerhead storage snapshot is a JSON string of [[keys], [values]] + private _hasStorageEntries (snapshot: string | undefined): boolean { + return !!snapshot && snapshot !== '[[],[]]'; + } + + private async _applyPendingRoleStorages (): Promise { + if (!this._pendingRoleStorages) + return; + + const { localStorage: localSnapshot, sessionStorage: sessionSnapshot } = this._pendingRoleStorages; + + await this.evaluateExpression(` + (function() { + function applySnapshot(storage, snapshot) { + if (!snapshot) return; + const parsed = JSON.parse(snapshot); + const keys = parsed[0] || []; + const values = parsed[1] || []; + if (keys.length !== values.length) + throw new Error('Malformed storage snapshot: ' + keys.length + ' keys vs ' + values.length + ' values'); + for (let i = 0; i < keys.length; i++) + storage.setItem(keys[i], values[i]); + } + applySnapshot(window.localStorage, ${JSON.stringify(localSnapshot || '')}); + applySnapshot(window.sessionStorage, ${JSON.stringify(sessionSnapshot || '')}); + })() + `); + + // Only forget the snapshot once it has actually been applied — a failure above + // keeps it pending for the next navigation instead of dropping it silently + this._pendingRoleStorages = null; + } + + // ===================================================================== + // 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; + + // Best-effort teardown: a cleanup failure must not mask the test result. + try { + await this.nativeAutomation.dispose(); + } + catch (e) { + // ignore + } + + try { + const plugin = this.parentTestRun.browserConnection.provider.plugin; + + await plugin.disposeIsolatedSession( + this.parentTestRun.browserConnection.id, + this.browserContextId + ); + } + catch (e) { + // ignore + } + } +} diff --git a/ts-defs-src/test-api/test-controller.d.ts b/ts-defs-src/test-api/test-controller.d.ts index a357f8caf44..3673633ef7f 100644 --- a/ts-defs-src/test-api/test-controller.d.ts +++ b/ts-defs-src/test-api/test-controller.d.ts @@ -575,6 +575,8 @@ interface TestController { * @param args - The data you want to report. */ report (...args: any[]): TestControllerPromise; + + openIsolatedSession (): Promise; } interface TestControllerPromise extends TestController, Promise { @@ -586,3 +588,47 @@ interface WindowDescriptorPromise extends TestControllerPromise { } +interface IsolatedTestController { + click(selector: string | Selector, options?: ClickActionOptions): IsolatedTestControllerPromise; + rightClick(selector: string | Selector, options?: ClickActionOptions): IsolatedTestControllerPromise; + doubleClick(selector: string | Selector, options?: ClickActionOptions): IsolatedTestControllerPromise; + hover(selector: string | Selector, options?: MouseActionOptions): IsolatedTestControllerPromise; + drag(selector: string | Selector, dragOffsetX: number, dragOffsetY: number, options?: MouseActionOptions): IsolatedTestControllerPromise; + dragToElement(selector: string | Selector, destinationSelector: string | Selector, options?: DragToElementOptions): IsolatedTestControllerPromise; + typeText(selector: string | Selector, text: string, options?: TypeActionOptions): IsolatedTestControllerPromise; + pressKey(keys: string, options?: PressActionOptions): IsolatedTestControllerPromise; + selectText(selector: string | Selector, startPos?: number, endPos?: number, options?: ActionOptions): IsolatedTestControllerPromise; + scroll(posX: number, posY: number, options?: OffsetOptions): IsolatedTestControllerPromise; + scroll(selector: string | Selector, position?: ScrollLogicalPosition, options?: OffsetOptions): IsolatedTestControllerPromise; + scrollBy(x: number, y: number, options?: OffsetOptions): IsolatedTestControllerPromise; + scrollBy(selector: string | Selector, x: number, y: number, options?: OffsetOptions): IsolatedTestControllerPromise; + scrollIntoView(selector: string | Selector, options?: OffsetOptions): IsolatedTestControllerPromise; + dispatchEvent(selector: string | Selector, eventName: string, options?: object): IsolatedTestControllerPromise; + wait(timeout: number): IsolatedTestControllerPromise; + navigateTo(url: string): IsolatedTestControllerPromise; + expect(actual: A | Promise): Assertion; + useRole(role: Role): IsolatedTestControllerPromise; + getCookies(cookies?: CookieOptions | CookieOptions[]): Promise; + getCookies(names: string | string[], urls?: string | string[]): Promise; + setCookies(cookies?: CookieOptions | CookieOptions[]): IsolatedTestControllerPromise; + setCookies(nameValueObjects: Record | Record[], url?: string): IsolatedTestControllerPromise; + deleteCookies(cookies?: CookieOptions | CookieOptions[]): IsolatedTestControllerPromise; + deleteCookies(names: string | string[], urls?: string | string[]): IsolatedTestControllerPromise; + eval(fn: Function): Promise; + takeScreenshot(path?: string): IsolatedTestControllerPromise; + takeElementScreenshot(selector: string | Selector, path?: string): IsolatedTestControllerPromise; + setFilesToUpload(selector: string | Selector, filePath: string | string[]): IsolatedTestControllerPromise; + clearUpload(selector: string | Selector): IsolatedTestControllerPromise; + switchToIframe(selector: string | Selector): IsolatedTestControllerPromise; + switchToMainWindow(): IsolatedTestControllerPromise; + maximizeWindow(): IsolatedTestControllerPromise; + resizeWindow(width: number, height: number): IsolatedTestControllerPromise; + setWindowBounds(bounds: { left?: number; top?: number; width?: number; height?: number }): IsolatedTestControllerPromise; + setHttpAuth(username: string, password: string): IsolatedTestControllerPromise; + setPageLoadTimeout(duration: number): IsolatedTestControllerPromise; + run(fn: () => any): IsolatedTestControllerPromise; +} + +interface IsolatedTestControllerPromise extends IsolatedTestController, Promise { +} + From a5454b3db93cadbd47185cb5d6465218fb39ab2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yannick=20H=C3=B6chst=C3=B6tter?= Date: Mon, 28 Sep 2026 11:06:07 +0200 Subject: [PATCH 3/5] test: cover isolated sessions Add the isolated-sessions functional suite (own gulp task, excluded from the screenshot glob) covering isolation of cookies and storage, actions, selectors and assertions, t2.run, iframes, roles, HTTP auth, file upload, screenshots and window management. Add a server test for context creation and disposal in the CDP client, TypeScript definition checks for the new API, the E103 formatting test and the openIsolatedSession exception in the TestController events test, since opening a session is a task and not a logged command. --- Gulpfile.js | 7 + gulp/constants/functional-test-globs.js | 3 + .../isolated-sessions/pages/actions.html | 38 ++ .../pages/iframe-content.html | 14 + .../isolated-sessions/pages/iframe.html | 11 + .../isolated-sessions/pages/iframes.html | 12 + .../isolated-sessions/pages/index.html | 28 ++ .../isolated-sessions/pages/second-frame.html | 11 + .../isolated-sessions/pages/second.html | 12 + .../fixtures/isolated-sessions/test.js | 386 ++++++++++++++++++ .../testcafe-fixtures/actions-test.js | 305 ++++++++++++++ .../testcafe-fixtures/basic-isolation-test.js | 140 +++++++ .../testcafe-fixtures/commands-test.js | 285 +++++++++++++ .../testcafe-fixtures/cookies-test.js | 98 +++++ .../testcafe-fixtures/file-upload-test.js | 20 + .../testcafe-fixtures/http-auth-test.js | 24 ++ .../testcafe-fixtures/iframe-test.js | 129 ++++++ .../testcafe-fixtures/role-test.js | 31 ++ .../testcafe-fixtures/screenshot-test.js | 34 ++ .../selector-chaining-test.js | 164 ++++++++ .../testcafe-fixtures/t2-run-test.js | 170 ++++++++ .../window-management-test.js | 41 ++ ...d-session-requires-native-automation-error | 19 + .../typescript-defs/isolated-sessions.ts | 62 +++ test/server/isolated-context-test.js | 109 +++++ test/server/test-controller-events-test.js | 2 +- test/server/test-run-error-formatting-test.js | 5 + 27 files changed, 2159 insertions(+), 1 deletion(-) create mode 100644 test/functional/fixtures/isolated-sessions/pages/actions.html create mode 100644 test/functional/fixtures/isolated-sessions/pages/iframe-content.html create mode 100644 test/functional/fixtures/isolated-sessions/pages/iframe.html create mode 100644 test/functional/fixtures/isolated-sessions/pages/iframes.html create mode 100644 test/functional/fixtures/isolated-sessions/pages/index.html create mode 100644 test/functional/fixtures/isolated-sessions/pages/second-frame.html create mode 100644 test/functional/fixtures/isolated-sessions/pages/second.html create mode 100644 test/functional/fixtures/isolated-sessions/test.js create mode 100644 test/functional/fixtures/isolated-sessions/testcafe-fixtures/actions-test.js create mode 100644 test/functional/fixtures/isolated-sessions/testcafe-fixtures/basic-isolation-test.js create mode 100644 test/functional/fixtures/isolated-sessions/testcafe-fixtures/commands-test.js create mode 100644 test/functional/fixtures/isolated-sessions/testcafe-fixtures/cookies-test.js create mode 100644 test/functional/fixtures/isolated-sessions/testcafe-fixtures/file-upload-test.js create mode 100644 test/functional/fixtures/isolated-sessions/testcafe-fixtures/http-auth-test.js create mode 100644 test/functional/fixtures/isolated-sessions/testcafe-fixtures/iframe-test.js create mode 100644 test/functional/fixtures/isolated-sessions/testcafe-fixtures/role-test.js create mode 100644 test/functional/fixtures/isolated-sessions/testcafe-fixtures/screenshot-test.js create mode 100644 test/functional/fixtures/isolated-sessions/testcafe-fixtures/selector-chaining-test.js create mode 100644 test/functional/fixtures/isolated-sessions/testcafe-fixtures/t2-run-test.js create mode 100644 test/functional/fixtures/isolated-sessions/testcafe-fixtures/window-management-test.js create mode 100644 test/server/data/expected-test-run-errors/isolated-session-requires-native-automation-error create mode 100644 test/server/data/test-suites/typescript-defs/isolated-sessions.ts create mode 100644 test/server/isolated-context-test.js diff --git a/Gulpfile.js b/Gulpfile.js index 2eefdcf97c4..72a2e6dfa75 100644 --- a/Gulpfile.js +++ b/Gulpfile.js @@ -33,6 +33,7 @@ const { TESTS_GLOB, LEGACY_TESTS_GLOB, MULTIPLE_WINDOWS_TESTS_GLOB, + ISOLATED_SESSIONS_TESTS_GLOB, HEADED_CHROME_FIREFOX_TESTS_GLOB, } = require('./gulp/constants/functional-test-globs'); @@ -466,6 +467,12 @@ gulp.step('test-functional-local-multiple-windows-na-run', () => { gulp.task('test-functional-local-multiple-windows-na', gulp.series('prepare-tests', 'test-functional-local-multiple-windows-na-run')); +gulp.step('test-functional-local-isolated-sessions-run', () => { + return testFunctional(ISOLATED_SESSIONS_TESTS_GLOB, functionalTestConfig.testingEnvironmentNames.localChrome, { nativeAutomation: true }); +}); + +gulp.task('test-functional-local-isolated-sessions', gulp.series('prepare-tests', 'test-functional-local-isolated-sessions-run')); + gulp.step('test-functional-local-chrome-firefox-headed-run', () => { return testFunctional(HEADED_CHROME_FIREFOX_TESTS_GLOB, functionalTestConfig.testingEnvironmentNames.localBrowsersChromeFirefox); }); 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/test/functional/fixtures/isolated-sessions/pages/actions.html b/test/functional/fixtures/isolated-sessions/pages/actions.html new file mode 100644 index 00000000000..140e8c12965 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/pages/actions.html @@ -0,0 +1,38 @@ + + + + + Isolated Sessions Actions Page + + +
+
Target
+ +
Hello big World
+
+
+
+
+
+ + +
+ + + 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..94ffada4704 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/pages/iframe-content.html @@ -0,0 +1,14 @@ + + + + + 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/iframes.html b/test/functional/fixtures/isolated-sessions/pages/iframes.html new file mode 100644 index 00000000000..4aaba690eb8 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/pages/iframes.html @@ -0,0 +1,12 @@ + + + + + Multiple Iframes Page + + +

Multiple Iframes

+ + + + 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-frame.html b/test/functional/fixtures/isolated-sessions/pages/second-frame.html new file mode 100644 index 00000000000..61fc6ce92d4 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/pages/second-frame.html @@ -0,0 +1,11 @@ + + + + + Second Frame + + +

Second Frame

+ + + 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..70df47e3d24 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/test.js @@ -0,0 +1,386 @@ +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' }); + }); + + it('Isolated pages are not instrumented by the main window', () => { + return runTests('testcafe-fixtures/basic-isolation-test.js', 'Isolated pages are not instrumented by the main window', { 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' }); + }); + + it('click waits for element to appear', () => { + // The element appears after 800ms — needs more than the default 200ms functional-test selector timeout + return runTests('testcafe-fixtures/commands-test.js', 'click waits for element to appear', { only: 'chrome', selectorTimeout: 3000 }); + }); + + it('pressKey dispatches canonical key values', () => { + return runTests('testcafe-fixtures/commands-test.js', 'pressKey dispatches canonical key values', { only: 'chrome' }); + }); + + it('click honors modifiers and offsets', () => { + return runTests('testcafe-fixtures/commands-test.js', 'click honors modifiers and offsets', { only: 'chrome' }); + }); + + it('A caught error on a direct method does not block later commands', () => { + return runTests('testcafe-fixtures/commands-test.js', 'A caught error on a direct method does not block later commands', { only: 'chrome' }); + }); + + it('Chained assertions and actions outside t2.run()', () => { + return runTests('testcafe-fixtures/commands-test.js', 'Chained assertions and actions outside t2.run()', { 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' }); + }); + + it('Selector assertion outside t2.run() targets isolated session', () => { + return runTests('testcafe-fixtures/selector-chaining-test.js', 'Selector assertion outside t2.run() targets isolated session', { only: 'chrome' }); + }); + + it('Selector objects passed to direct methods', () => { + return runTests('testcafe-fixtures/selector-chaining-test.js', 'Selector objects passed to direct methods', { only: 'chrome' }); + }); + + it('Direct method with a missing element throws instead of using the focused element', () => { + return runTests('testcafe-fixtures/selector-chaining-test.js', 'Direct method with a missing element throws instead of using the focused element', { only: 'chrome' }); + }); + }); + + describe('t2.run()', () => { + it('Selector.exists evaluates in isolated session inside t2.run()', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 'Selector.exists evaluates in isolated session inside t2.run()', { only: 'chrome' }); + }); + + it('Selector.visible evaluates in isolated session inside t2.run()', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 'Selector.visible evaluates in isolated session inside t2.run()', { only: 'chrome' }); + }); + + it('Selector.innerText evaluates in isolated session inside t2.run()', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 'Selector.innerText evaluates in isolated session inside t2.run()', { only: 'chrome' }); + }); + + it('ClientFunction evaluates in isolated session inside t2.run()', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 'ClientFunction evaluates in isolated session inside t2.run()', { only: 'chrome' }); + }); + + it('Actions inside t2.run() use t2 directly', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 'Actions inside t2.run() use t2 directly', { 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' }); + }); + + it('t2 assertions after t2.run() still target the isolated session', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 't2 assertions after t2.run() still target the isolated session', { only: 'chrome' }); + }); + + it('Chained calls inside t2.run()', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 'Chained calls inside t2.run()', { only: 'chrome' }); + }); + + it('t2.run() waits for commands the callback did not await', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 't2.run() waits for commands the callback did not await', { only: 'chrome' }); + }); + + it('Selector property read inside t2.run() waits for a late element', () => { + return runTests('testcafe-fixtures/t2-run-test.js', 'Selector property read inside t2.run() waits for a late element', { only: 'chrome' }); + }); + }); + + describe('Actions', () => { + it('rightClick with modifiers', () => { + return runTests('testcafe-fixtures/actions-test.js', 'rightClick with modifiers', { only: 'chrome' }); + }); + + it('drag by offset', () => { + return runTests('testcafe-fixtures/actions-test.js', 'drag by offset', { only: 'chrome' }); + }); + + it('dragToElement', () => { + return runTests('testcafe-fixtures/actions-test.js', 'dragToElement', { only: 'chrome' }); + }); + + it('selectText in an input', () => { + return runTests('testcafe-fixtures/actions-test.js', 'selectText in an input', { only: 'chrome' }); + }); + + it('selectText in a contenteditable honors start and end', () => { + return runTests('testcafe-fixtures/actions-test.js', 'selectText in a contenteditable honors start and end', { only: 'chrome' }); + }); + + it('setWindowBounds', () => { + return runTests('testcafe-fixtures/actions-test.js', 'setWindowBounds', { only: 'chrome' }); + }); + + it('scroll positions and element scrolling', () => { + return runTests('testcafe-fixtures/actions-test.js', 'scroll positions and element scrolling', { only: 'chrome' }); + }); + + it('navigateTo a failing URL throws', () => { + return runTests('testcafe-fixtures/actions-test.js', 'navigateTo a failing URL throws', { only: 'chrome' }); + }); + + it('element-taking commands wait for late elements', () => { + return runTests('testcafe-fixtures/actions-test.js', 'element-taking commands wait for late elements', { only: 'chrome', selectorTimeout: 3000 }); + }); + + it('typeText types at the end of the existing value', () => { + return runTests('testcafe-fixtures/actions-test.js', 'typeText types at the end of the existing value', { only: 'chrome' }); + }); + + it('dispatchEvent builds the matching event type', () => { + return runTests('testcafe-fixtures/actions-test.js', 'dispatchEvent builds the matching event type', { only: 'chrome' }); + }); + + it('takeScreenshot with fullPage captures the whole page', () => { + return runTests('testcafe-fixtures/actions-test.js', 'takeScreenshot with fullPage captures the whole page', { only: 'chrome' }); + }); + + it('takeElementScreenshot of an element taller than the viewport', () => { + return runTests('testcafe-fixtures/actions-test.js', 'takeElementScreenshot of an element taller than the viewport', { only: 'chrome' }); + }); + + it('native alert and confirm are dismissed and fail the command', () => { + return runTests('testcafe-fixtures/actions-test.js', 'native alert and confirm are dismissed and fail the command', { only: 'chrome' }); + }); + + it('ClientFunction arguments inside t2.run()', () => { + return runTests('testcafe-fixtures/actions-test.js', 'ClientFunction arguments inside t2.run()', { only: 'chrome' }); + }); + + it('ClientFunction bound to t2', () => { + return runTests('testcafe-fixtures/actions-test.js', 'ClientFunction bound to t2', { only: 'chrome' }); + }); + + it('ClientFunction and t2.eval dependencies', () => { + return runTests('testcafe-fixtures/actions-test.js', 'ClientFunction and t2.eval dependencies', { only: 'chrome' }); + }); + }); + + describe('Cookies API', () => { + it('Cookies set via document.cookie are isolated', () => { + return runTests('testcafe-fixtures/cookies-test.js', 'Cookies set via document.cookie are isolated', { only: 'chrome' }); + }); + + it('getCookies returns cookies from isolated session', () => { + return runTests('testcafe-fixtures/cookies-test.js', 'getCookies returns cookies from isolated session', { only: 'chrome' }); + }); + + it('deleteCookies clears isolated session cookies', () => { + return runTests('testcafe-fixtures/cookies-test.js', 'deleteCookies clears isolated session cookies', { only: 'chrome' }); + }); + + it('cookies API set, get, delete', () => { + return runTests('testcafe-fixtures/cookies-test.js', 'cookies API set, get, delete', { only: 'chrome' }); + }); + + it('cookie expires is respected', () => { + return runTests('testcafe-fixtures/cookies-test.js', 'cookie expires is respected', { only: 'chrome' }); + }); + }); + + describe('Iframe Support', () => { + it('switchToIframe and eval inside iframe', () => { + return runTests('testcafe-fixtures/iframe-test.js', 'switchToIframe and eval inside iframe', { only: 'chrome' }); + }); + + it('switchToIframe and interact via eval', () => { + return runTests('testcafe-fixtures/iframe-test.js', 'switchToIframe and interact via eval', { only: 'chrome' }); + }); + + it('switchToMainWindow after iframe', () => { + return runTests('testcafe-fixtures/iframe-test.js', 'switchToMainWindow after iframe', { only: 'chrome' }); + }); + + it('navigateTo after switchToIframe resets the eval context', () => { + return runTests('testcafe-fixtures/iframe-test.js', 'navigateTo after switchToIframe resets the eval context', { only: 'chrome' }); + }); + + it('switchToIframe with a Selector object', () => { + return runTests('testcafe-fixtures/iframe-test.js', 'switchToIframe with a Selector object', { only: 'chrome' }); + }); + + it('switchToIframe with Selector nth picks that iframe', () => { + return runTests('testcafe-fixtures/iframe-test.js', 'switchToIframe with Selector nth picks that iframe', { only: 'chrome' }); + }); + + it('switchToIframe with an attribute selector and a nested iframe', () => { + return runTests('testcafe-fixtures/iframe-test.js', 'switchToIframe with an attribute selector and a nested iframe', { only: 'chrome' }); + }); + + it('eval inside an iframe sees the page globals', () => { + return runTests('testcafe-fixtures/iframe-test.js', 'eval inside an iframe sees the page globals', { only: 'chrome' }); + }); + + it('click and typeText inside an iframe', () => { + return runTests('testcafe-fixtures/iframe-test.js', 'click and typeText inside an 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('Roles', () => { + it('useRole applies cookies and storage to isolated session', () => { + return runTests('testcafe-fixtures/role-test.js', 'useRole applies cookies and storage to isolated session', { only: 'chrome' }); + }); + }); + + describe('HTTP Auth', () => { + it('setHttpAuth attaches basic auth credentials', () => { + return runTests('testcafe-fixtures/http-auth-test.js', 'setHttpAuth attaches basic auth credentials', { 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/actions-test.js b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/actions-test.js new file mode 100644 index 00000000000..705f4978cc6 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/actions-test.js @@ -0,0 +1,305 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { PNG } from 'pngjs'; +import { Selector, ClientFunction } from 'testcafe'; + +const PAGE_URL = 'http://localhost:3000/fixtures/isolated-sessions/pages/actions.html'; + +fixture `Isolated Sessions - Actions` + .page(PAGE_URL); + +async function openActionsPage (t) { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo(PAGE_URL); + + return t2; +} + +function readResult (t2) { + return t2.eval(() => document.querySelector('#result').textContent); +} + +test('rightClick with modifiers', async t => { + const t2 = await openActionsPage(t); + + await t2.rightClick('#target', { modifiers: { shift: true } }); + + await t.expect(await readResult(t2)).eql('contextmenu button:2 shift:true'); +}); + +test('drag by offset', async t => { + const t2 = await openActionsPage(t); + + await t2.drag('#drag-source', 100, 50); + + await t.expect(await readResult(t2)).contains('moved:100,50'); +}); + +test('dragToElement', async t => { + const t2 = await openActionsPage(t); + + await t2.dragToElement('#drag-source', '#drop-target'); + + await t.expect(await readResult(t2)).contains('target:drop-target'); +}); + +test('selectText in an input', async t => { + const t2 = await openActionsPage(t); + + await t2.selectText('#long-input', 2, 5); + + const selection = await t2.eval(() => { + const el = document.querySelector('#long-input'); + + return el.value.substring(el.selectionStart, el.selectionEnd); + }); + + await t.expect(selection).eql('cde'); +}); + +test('selectText in a contenteditable honors start and end', async t => { + const t2 = await openActionsPage(t); + + await t2.selectText('#editable', 6, 9); + + await t.expect(await t2.eval(() => window.getSelection().toString())).eql('big'); + + await t2.selectText('#editable', 10); + + await t.expect(await t2.eval(() => window.getSelection().toString())).eql('World'); +}); + +test('setWindowBounds', async t => { + const t2 = await openActionsPage(t); + + await t2.setWindowBounds({ width: 700, height: 500 }); + + await t.expect(await t2.eval(() => window.outerWidth)).eql(700); + await t.expect(await t2.eval(() => window.outerHeight)).eql(500); +}); + +test('scroll positions and element scrolling', async t => { + const t2 = await openActionsPage(t); + + const scroller = () => t2.eval(() => { + const el = document.querySelector('#scroller'); + + return { + pos: el.scrollLeft + ',' + el.scrollTop, + max: el.scrollWidth - el.clientWidth + ',' + (el.scrollHeight - el.clientHeight), + center: Math.floor(el.scrollWidth / 2 - el.clientWidth / 2) + ',' + Math.floor(el.scrollHeight / 2 - el.clientHeight / 2), + }; + }); + + await t2.scroll('#scroller', 'bottomRight'); + await t.expect((await scroller()).pos).eql((await scroller()).max); + + await t2.scroll('#scroller', 'topLeft'); + await t.expect((await scroller()).pos).eql('0,0'); + + await t2.scroll('#scroller', 'center'); + await t.expect((await scroller()).pos).eql((await scroller()).center); + + await t2.scroll('#scroller', 30, 40); + await t.expect((await scroller()).pos).eql('30,40'); + + await t2.scrollBy('#scroller', 5, 10); + await t.expect((await scroller()).pos).eql('35,50'); + + await t2.scroll('bottom'); + await t.expect(await t2.eval(() => window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 1)).ok(); + + await t2.scroll('top'); + await t.expect(await t2.eval(() => window.scrollY)).eql(0); +}); + +test('navigateTo a failing URL throws', async t => { + const t2 = await t.openIsolatedSession(); + + let message = ''; + + try { + await t2.navigateTo('http://localhost:3999/'); + } + catch (err) { + message = err.message; + } + + await t + .expect(message).contains('failed to navigate to http://localhost:3999/') + .expect(message).contains('net::ERR_CONNECTION_REFUSED'); +}); + +test('element-taking commands wait for late elements', async t => { + const t2 = await openActionsPage(t); + + const addLate = html => t2.eval(new Function(` + setTimeout(function () { + document.body.insertAdjacentHTML('beforeend', ${JSON.stringify(html)}); + }, 500); + `)); + + await addLate(''); + await t2.selectText('#late-select', 0, 4); + + await addLate('
'); + await t2.scroll('#late-scroll', 0, 20); + + await addLate('
'); + await t2.scrollBy('#late-scroll-by', 0, 20); + + await addLate('
x
'); + await t2.scrollIntoView('#late-into-view'); + + await addLate('
x
'); + await t2.dispatchEvent('#late-event', 'click'); + + await addLate(''); + await t2.setFilesToUpload('#late-file', path.resolve(__dirname, '../pages/index.html')); + + await addLate(''); + await t2.clearUpload('#late-clear'); + + await addLate('
'); + fs.unlinkSync(await t2.takeElementScreenshot('#late-shot')); + + await addLate(''); + await t2.switchToIframe('#late-frame'); + + await t.expect(await t2.eval(() => document.querySelector('#iframe-title').textContent)).eql('Inside Iframe'); +}); + +test('typeText types at the end of the existing value', async t => { + const t2 = await openActionsPage(t); + + await t2.typeText('#long-input', '!'); + + await t.expect(await t2.eval(() => document.querySelector('#long-input').value)).eql('abcdefghijklmnopqrstuvwxyz0123456789!'); + + await t2.typeText('#long-input', '_', { caretPos: 1 }); + + await t.expect(await t2.eval(() => document.querySelector('#long-input').value)).eql('a_bcdefghijklmnopqrstuvwxyz0123456789!'); +}); + +test('dispatchEvent builds the matching event type', async t => { + const t2 = await openActionsPage(t); + + await t2.eval(() => { + const target = document.querySelector('#target'); + const log = []; + + window.__events = log; + + ['mousedown', 'keydown', 'my-event'].forEach(name => { + target.addEventListener(name, e => { + log.push([e.type, e.constructor.name, e.clientX, e.key, e.detail].join(':')); + }); + }); + }); + + await t2 + .dispatchEvent('#target', 'mousedown', { clientX: 12 }) + .dispatchEvent('#target', 'keydown', { key: 'a' }) + .dispatchEvent('#target', 'my-event', { eventConstructor: 'CustomEvent', detail: 'payload' }); + + await t.expect(await t2.eval(() => window.__events)).eql([ + 'mousedown:MouseEvent:12::1', + 'keydown:KeyboardEvent::a:0', + 'my-event:CustomEvent:::payload', + ]); +}); + +test('takeScreenshot with fullPage captures the whole page', async t => { + const t2 = await openActionsPage(t); + + const filePath = await t2.takeScreenshot({ fullPage: true }); + const png = PNG.sync.read(fs.readFileSync(filePath)); + const viewport = await t2.eval(() => window.innerHeight); + + fs.unlinkSync(filePath); + + await t.expect(png.height).gt(viewport); + await t.expect(png.height).gte(3500); +}); + +test('takeElementScreenshot of an element taller than the viewport', async t => { + const t2 = await openActionsPage(t); + + const filePath = await t2.takeElementScreenshot('#tall'); + const png = PNG.sync.read(fs.readFileSync(filePath)); + + fs.unlinkSync(filePath); + + await t.expect(png.height).eql(3000); + + const idx = (png.width * (png.height - 5) + 5) * 4; + + await t.expect([png.data[idx], png.data[idx + 1], png.data[idx + 2]]).eql([0, 128, 0]); +}); + +test('native alert and confirm are dismissed and fail the command', async t => { + const t2 = await openActionsPage(t); + + let alertError = ''; + + await t2.click('#alert-btn').catch(err => { + alertError = err.message; + }); + + await t.expect(alertError).contains('alert'); + await t.expect(await readResult(t2)).eql('after-alert'); + + let confirmError = ''; + + await t2.click('#confirm-btn').catch(err => { + confirmError = err.message; + }); + + await t.expect(confirmError).contains('confirm'); + await t.expect(await readResult(t2)).eql('dismissed'); +}); + +test('ClientFunction arguments inside t2.run()', async t => { + const t2 = await openActionsPage(t); + + const summarize = ClientFunction((a, b, opts) => [document.title, a + b, opts.label, opts.when.getUTCFullYear(), window.location.pathname].join(':')); + + await t2.run(async () => { + const value = await summarize(2, 3, { label: 'x', when: new Date(Date.UTC(2020, 0, 1)) }); + + await t.expect(value).eql('Isolated Sessions Actions Page:5:x:2020:/fixtures/isolated-sessions/pages/actions.html'); + }); +}); + +test('ClientFunction bound to t2', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/second.html'); + + const getTitle = ClientFunction(() => document.title).with({ boundTestRun: t2 }); + + await t.expect(await getTitle()).eql('Second Page'); + + const title = await Selector('#title').with({ boundTestRun: t2 }).innerText; + + await t.expect(title).eql('Second Page'); +}); + +test('ClientFunction and t2.eval dependencies', async t => { + const t2 = await openActionsPage(t); + + // eslint-disable-next-line no-undef + const withDeps = ClientFunction(() => prefix + double(settings.base), { + dependencies: { prefix: 'n=', double: x => x * 2, settings: { base: 2 } }, + }).with({ boundTestRun: t2 }); + + await t.expect(await withDeps()).eql('n=4'); + + // eslint-disable-next-line no-undef + const evalValue = await t2.eval(() => prefix + double(settings.base), { + dependencies: { prefix: 'e=', double: x => x * 3, settings: { base: 2 } }, + }); + + await t.expect(evalValue).eql('e=6'); +}); 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..28bfdd5674d --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/basic-isolation-test.js @@ -0,0 +1,140 @@ +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'); +}); + +test('Isolated pages are not instrumented by the main window', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + const hammerheadInIsolated = await t2.eval(() => typeof window['%hammerhead%']); + const hammerheadInMain = await t.eval(() => typeof window['%hammerhead%']); + + await t + .expect(hammerheadInIsolated).eql('undefined') + .expect(hammerheadInMain).eql('object'); +}); 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..9b32e9c345d --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/commands-test.js @@ -0,0 +1,285 @@ +import { Selector } from 'testcafe'; + +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(); +}); + +test('click waits for element to appear', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + // The button appears 800ms after the click is enqueued — the action must wait + await t2.eval(() => { + setTimeout(() => { + const btn = document.createElement('button'); + + btn.id = 'delayed-btn'; + btn.textContent = 'Delayed'; + btn.onclick = () => { + document.querySelector('#result').textContent = 'delayed-clicked'; + }; + + document.body.appendChild(btn); + }, 800); + }); + + await t2.click('#delayed-btn'); + + const result = await t2.eval(() => document.querySelector('#result').textContent); + + await t.expect(result).eql('delayed-clicked'); +}); + +test('pressKey dispatches canonical key values', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + // Application handlers match on event.key === 'Enter' — the exact DOM value + await t2.eval(() => { + document.querySelector('#text-input').addEventListener('keydown', e => { + if (e.key === 'Enter') + document.querySelector('#result').textContent = 'enter-pressed'; + }); + }); + + await t2.click('#text-input'); + await t2.pressKey('enter'); + + const result = await t2.eval(() => document.querySelector('#result').textContent); + + await t.expect(result).eql('enter-pressed'); +}); + +test('click honors modifiers and offsets', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + await t2.eval(() => { + document.querySelector('#drop-target').addEventListener('click', e => { + document.querySelector('#result').textContent = 'ctrl:' + e.ctrlKey + ' x:' + e.offsetX; + }); + }); + + // #drop-target is 100x100 — offset (5, 5) lands near the corner, not the center + await t2.click('#drop-target', { modifiers: { ctrl: true }, offsetX: 5, offsetY: 5 }); + + const result = await t2.eval(() => document.querySelector('#result').textContent); + + await t.expect(result).contains('ctrl:true'); + + const clickX = parseInt(result.match(/x:(-?\d+)/)[1], 10); + + await t.expect(clickX).lt(20); +}); + +test('A caught error on a direct method does not block later commands', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + let message = ''; + + await t2.eval(() => { + throw new Error('deliberate'); + }).catch(err => { + message = err.message; + }); + + await t.expect(message).contains('deliberate'); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/second.html'); + + const title = await t2.eval(() => document.title); + + await t.expect(title).eql('Second Page'); +}); + +test('Chained assertions and actions outside t2.run()', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + await t2 + .expect(Selector('#btn').exists).ok() + .expect(Selector('#nonexistent').exists).notOk() + .click('#btn') + .expect(Selector('#result').innerText).eql('clicked'); +}); 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..49d46cb5cc9 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/cookies-test.js @@ -0,0 +1,98 @@ +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'); +}); + +test('cookies API set, get, delete', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + await t2.setCookies({ name: 'api-cookie', value: 'api-value' }, 'http://localhost:3000/'); + + const documentCookie = await t2.eval(() => document.cookie); + + await t.expect(documentCookie).contains('api-cookie=api-value'); + + const cookies = await t2.getCookies('api-cookie', 'http://localhost:3000/'); + + await t + .expect(cookies.length).eql(1) + .expect(cookies[0].value).eql('api-value'); + + await t2.deleteCookies('api-cookie', 'http://localhost:3000/'); + + const afterDelete = await t2.eval(() => document.cookie); + + await t.expect(afterDelete).notContains('api-cookie'); +}); + +test('cookie expires is respected', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + await t2.setCookies({ name: 'expired-cookie', value: 'x', expires: new Date(Date.now() - 86400000) }, 'http://localhost:3000/'); + await t2.setCookies({ name: 'future-cookie', value: 'y', expires: new Date(Date.now() + 86400000) }, 'http://localhost:3000/'); + + const documentCookie = await t2.eval(() => document.cookie); + + await t + .expect(documentCookie).notContains('expired-cookie') + .expect(documentCookie).contains('future-cookie=y'); +}); 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/http-auth-test.js b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/http-auth-test.js new file mode 100644 index 00000000000..7db65c1933d --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/http-auth-test.js @@ -0,0 +1,24 @@ +fixture `Isolated Sessions - HTTP Auth` + .page('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + +test('setHttpAuth attaches basic auth credentials', async t => { + const t2 = await t.openIsolatedSession(); + + let message = ''; + + try { + await t2.navigateTo('http://localhost:3002/'); + } + catch (err) { + message = err.message; + } + + await t.expect(message).contains('net::ERR_INVALID_AUTH_CREDENTIALS'); + + await t2.setHttpAuth('username', 'password'); + await t2.navigateTo('http://localhost:3002/'); + + const after = await t2.eval(() => document.querySelector('#result').textContent); + + await t.expect(after).eql('authorized'); +}); 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..5ff1d7bd820 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/iframe-test.js @@ -0,0 +1,129 @@ +import { Selector } from 'testcafe'; + +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'); +}); + +test('navigateTo after switchToIframe resets the eval context', 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'); + + // Navigating destroys the iframe context — evals must target the new page, + // not time out against the stale iframe context + 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('switchToIframe with a Selector object', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/iframe.html'); + await t2.switchToIframe(Selector('#test-iframe')); + + const title = await t2.eval(() => document.querySelector('#iframe-title').textContent); + + await t.expect(title).eql('Inside Iframe'); +}); + +test('switchToIframe with Selector nth picks that iframe', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/iframes.html'); + await t2.switchToIframe(Selector('iframe').nth(1)); + + const title = await t2.eval(() => document.querySelector('h2').textContent); + + await t.expect(title).eql('Second Frame'); +}); + +test('switchToIframe with an attribute selector and a nested iframe', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/iframes.html'); + await t2.switchToIframe('iframe[name="second"]'); + + const title = await t2.eval(() => document.querySelector('h2').textContent); + + await t.expect(title).eql('Second Frame'); + + await t2.switchToIframe('#nested'); + + const nestedTitle = await t2.eval(() => document.querySelector('h2').textContent); + + await t.expect(nestedTitle).eql('Inside Iframe'); +}); + +test('eval inside an iframe sees the page globals', 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 value = await t2.eval(() => window.iframeGlobal); + + await t.expect(value).eql('iframe-global'); +}); + +test('click and typeText inside an 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'); + await t2.click('#iframe-btn').typeText('#iframe-input', 'typed in frame'); + + await t.expect(await t2.eval(() => document.querySelector('#iframe-result').textContent)).eql('iframe-clicked'); + await t.expect(await t2.eval(() => document.querySelector('#iframe-input').value)).eql('typed in frame'); +}); diff --git a/test/functional/fixtures/isolated-sessions/testcafe-fixtures/role-test.js b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/role-test.js new file mode 100644 index 00000000000..b2e5c1c5d05 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/role-test.js @@ -0,0 +1,31 @@ +import { Role } from 'testcafe'; + +const PAGE_URL = 'http://localhost:3000/fixtures/isolated-sessions/pages/index.html'; + +const testRole = Role(PAGE_URL, async t => { + await t.eval(() => { + document.cookie = 'role-cookie=role-value'; + localStorage.setItem('role-key', 'role-value'); + }); +}); + +fixture `Isolated Sessions - Roles` + .page(PAGE_URL); + +test('useRole applies cookies and storage to isolated session', async t => { + // Initialize the role in the main session first — isolated sessions can only + // consume an existing state snapshot + await t.useRole(testRole); + + const t2 = await t.openIsolatedSession(); + + await t2.useRole(testRole); + await t2.navigateTo(PAGE_URL); + + const cookie = await t2.eval(() => document.cookie); + const storage = await t2.eval(() => localStorage.getItem('role-key')); + + await t + .expect(cookie).contains('role-cookie=role-value') + .expect(storage).eql('role-value'); +}); 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..3131b78c64a --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/selector-chaining-test.js @@ -0,0 +1,164 @@ +import * as fs from 'fs'; +import * as path from 'path'; +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'); +}); + +test('Selector assertion outside t2.run() targets isolated session', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + // Elements that exist in exactly one of the two tabs + await t.eval(() => { + const el = document.createElement('div'); + + el.id = 'main-only'; + document.body.appendChild(el); + }); + + await t2.eval(() => { + const el = document.createElement('div'); + + el.id = 'isolated-only'; + document.body.appendChild(el); + }); + + // t2 assertions must evaluate selectors in the isolated tab, not the main one + await t2.expect(Selector('#isolated-only').exists).ok(); + await t2.expect(Selector('#main-only').exists).notOk(); + await t2.expect(Selector('#btn').visible).ok(); +}); + +test('Selector objects passed to direct methods', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + const filePath = await t2.takeElementScreenshot(Selector('#btn')); + const png = fs.readFileSync(filePath); + const btnWidth = await t2.eval(() => Math.round(document.querySelector('#btn').getBoundingClientRect().width)); + + fs.unlinkSync(filePath); + + // PNG IHDR stores the width big-endian at byte 16; would be the full page width + await t.expect(Math.abs(png.readUInt32BE(16) - btnWidth)).lte(2); + + await t2.setFilesToUpload(Selector('input').withAttribute('type', 'file'), path.resolve(__dirname, '../pages/index.html')); + await t.expect(await t2.eval(() => document.querySelector('#file-input').files.length)).eql(1); + + await t2.clearUpload(Selector('#file-input')); + await t.expect(await t2.eval(() => document.querySelector('#file-input').files.length)).eql(0); +}); + +test('Direct method with a missing element throws instead of using the focused element', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + let message = ''; + + try { + await t2.takeElementScreenshot(Selector('#nonexistent')); + } + catch (err) { + message = err.message; + } + + await t.expect(message).contains('#nonexistent'); +}); 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..f19b7e6e168 --- /dev/null +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/t2-run-test.js @@ -0,0 +1,170 @@ +import { Selector, ClientFunction, t as globalT } 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'); + }); +}); + +test('t2 assertions after t2.run() still target the isolated session', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + await t2.eval(() => { + const el = document.createElement('div'); + + el.id = 'isolated-only'; + document.body.appendChild(el); + }); + + await t2.run(async () => { + await t2.expect(Selector('#isolated-only').exists).ok(); + }); + + await t2.expect(Selector('#isolated-only').exists).ok(); +}); + +test('Chained calls 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 t2 + .click('#btn') + .typeText('#text-input', 'chained') + .expect(Selector('#result').innerText).eql('clicked') + .expect(Selector('#text-input').value).eql('chained'); + + await globalT + .click('#increment') + .expect(Selector('#counter').innerText).eql('1'); + }); +}); + +test('t2.run() waits for commands the callback did not await', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + await t2.run(async () => { + t2.typeText('#text-input', 'not awaited'); + }); + + const value = await t2.eval(() => document.getElementById('text-input').value); + + await t.expect(value).eql('not awaited'); +}); + +test('Selector property read inside t2.run() waits for a late element', async t => { + const t2 = await t.openIsolatedSession(); + + await t2.navigateTo('http://localhost:3000/fixtures/isolated-sessions/pages/index.html'); + + await t2.eval(() => { + setTimeout(() => { + const el = document.createElement('div'); + + el.id = 'late'; + el.textContent = 'late text'; + document.body.appendChild(el); + }, 800); + }); + + await t2.run(async () => { + const text = await Selector('#late').with({ timeout: 3000 }).innerText; + + await t.expect(text).eql('late text'); + }); +}); 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'); +}); diff --git a/test/server/data/expected-test-run-errors/isolated-session-requires-native-automation-error b/test/server/data/expected-test-run-errors/isolated-session-requires-native-automation-error new file mode 100644 index 00000000000..cac80584bd8 --- /dev/null +++ b/test/server/data/expected-test-run-errors/isolated-session-requires-native-automation-error @@ -0,0 +1,19 @@ +The "openIsolatedSession" method is available only in the Native Automation mode. Enable native automation to continue. + +Browser: Chrome 15.0.874.120 / macOS 10.15 +Screenshot: /unix/path/with/ + + 18 |function func1 () { + 19 | record = createCallsiteRecord({ byFunctionName: 'func1' }); + 20 |} + 21 | + 22 |(function func2 () { + > 23 | func1(); + 24 |})(); + 25 | + 26 |stackTrace.filter.deattach(stackFilter); + 27 | + 28 |module.exports = record; + + at func2 (testfile.js:23:5) + at Object. (testfile.js:24:3) diff --git a/test/server/data/test-suites/typescript-defs/isolated-sessions.ts b/test/server/data/test-suites/typescript-defs/isolated-sessions.ts new file mode 100644 index 00000000000..3093fc43ae5 --- /dev/null +++ b/test/server/data/test-suites/typescript-defs/isolated-sessions.ts @@ -0,0 +1,62 @@ +import { Role, Selector } from 'testcafe'; + +const pageUrl = 'http://localhost:3000/fixtures/isolated-sessions/pages/index.html'; + +const someUser = Role(pageUrl, async t => { + await t.typeText('#name', 'SomeUser'); +}); + +fixture `Isolated sessions` + .page(pageUrl); + +test('Chained actions', async t => { + const t2 = await t.openIsolatedSession(); + + await t2 + .maximizeWindow() + .navigateTo(pageUrl) + .click('#button', { offsetX: 1, modifiers: { shift: true } }) + .rightClick(Selector('#button')) + .doubleClick('#button') + .hover('#button') + .drag('#button', 10, 20) + .dragToElement('#button', Selector('#target'), { destinationOffsetX: 1 }) + .typeText('#input', 'text', { replace: true }) + .pressKey('ctrl+a') + .selectText('#input', 0, 2) + .scroll(0, 500) + .scroll('#target', 'center') + .scrollBy(0, 100) + .scrollBy('#target', 0, 100) + .scrollIntoView('#target') + .dispatchEvent('#button', 'click', { bubbles: true }) + .wait(100) + .useRole(someUser) + .setCookies({ name: 'value' }, 'http://localhost:3000') + .deleteCookies('name', 'http://localhost:3000') + .setFilesToUpload('#file', ['a.txt', 'b.txt']) + .clearUpload('#file') + .switchToIframe('#iframe') + .switchToMainWindow() + .resizeWindow(800, 600) + .setWindowBounds({ left: 0, top: 0, width: 800, height: 600 }) + .setHttpAuth('user', 'password') + .setPageLoadTimeout(1000) + .expect(Selector('#target').exists).ok(); +}); + +test('Values and run', async t => { + const t2 = await t.openIsolatedSession(); + + const cookies: CookieOptions[] = await t2.getCookies(['name'], 'http://localhost:3000'); + const title: string = await t2.eval(() => document.title); + const screenshotPath: string = await t2.takeScreenshot('isolated.png'); + const elementPath: string = await t2.takeElementScreenshot('#target'); + + await t2.expect(cookies.length).eql(1); + + await t2.run(async () => { + await t.click('#button'); + await t2.click('#button').typeText('#input', title + screenshotPath + elementPath); + }); +}); diff --git a/test/server/isolated-context-test.js b/test/server/isolated-context-test.js new file mode 100644 index 00000000000..6b8a3273c7a --- /dev/null +++ b/test/server/isolated-context-test.js @@ -0,0 +1,109 @@ +const { expect } = require('chai'); +const proxyquire = require('proxyquire'); + +describe('BrowserClient isolated contexts', () => { + let connections = null; + let disposed = null; + let tab = null; + + function createFakeClient () { + const listeners = {}; + + return { + listeners, + + on (event, cb) { + listeners[event] = cb; + }, + + close: async () => {}, + + Target: { + createBrowserContext: async () => ({ browserContextId: 'ctx-1' }), + createTarget: async () => ({ targetId: 'target-1' }), + disposeBrowserContext: async ({ browserContextId }) => { + disposed.push(browserContextId); + }, + }, + }; + } + + function createBrowserClient () { + const remoteChrome = async () => { + const client = createFakeClient(); + + connections.push(client); + + return client; + }; + + remoteChrome.Version = async () => ({ webSocketDebuggerUrl: 'ws://localhost/devtools/browser' }); + + remoteChrome['@noCallThru'] = true; + + const { BrowserClient } = proxyquire('../../lib/browser/provider/built-in/dedicated/chrome/cdp-client', { + 'chrome-remote-interface': remoteChrome, + './utils': { getTabById: async () => tab, '@noCallThru': false }, + }); + + return new BrowserClient({ browserId: 'browser-1', cdpPort: 9222 }); + } + + beforeEach(() => { + connections = []; + disposed = []; + tab = null; + }); + + it('Should dispose the browser context when the isolated tab cannot be attached', async () => { + const browserClient = createBrowserClient(); + + let error = null; + + try { + await browserClient.createIsolatedContext(); + } + catch (err) { + error = err; + } + + expect(error.message).contains('target-1'); + expect(disposed).eql(['ctx-1']); + }); + + it('Should open one browser-level connection for concurrent callers', async () => { + const browserClient = createBrowserClient(); + + const [first, second] = await Promise.all([ + browserClient.getBrowserLevelClient(), + browserClient.getBrowserLevelClient(), + ]); + + expect(connections.length).eql(1); + expect(first).equal(second); + }); + + it('Should reconnect after the browser-level connection is lost', async () => { + const browserClient = createBrowserClient(); + + const first = await browserClient.getBrowserLevelClient(); + + first.listeners.disconnect(); + + const second = await browserClient.getBrowserLevelClient(); + + expect(connections.length).eql(2); + expect(second).not.equal(first); + }); + + it('Should not open a browser-level connection after the tab is closed', async () => { + const browserClient = createBrowserClient(); + + await browserClient.getBrowserLevelClient(); + await browserClient.closeTab(); + await browserClient.disposeIsolatedContext('ctx-1'); + + expect(connections.length).eql(1); + expect(disposed).eql([]); + }); +}); diff --git a/test/server/test-controller-events-test.js b/test/server/test-controller-events-test.js index c5768c507da..0dbd3a571e3 100644 --- a/test/server/test-controller-events-test.js +++ b/test/server/test-controller-events-test.js @@ -200,7 +200,7 @@ describe('TestController action events', () => { // eval, expect and request has their functional tests // addRequestHooks/removeRequestHooks are not logged - const exceptions = ['eval', 'expect', 'addRequestHooks', 'removeRequestHooks', 'request']; + const exceptions = ['eval', 'expect', 'addRequestHooks', 'removeRequestHooks', 'request', 'openIsolatedSession']; const props = TestController.API_LIST .filter(prop => !prop.accessor) diff --git a/test/server/test-run-error-formatting-test.js b/test/server/test-run-error-formatting-test.js index 0369ac11dc7..75b101cf5e8 100644 --- a/test/server/test-run-error-formatting-test.js +++ b/test/server/test-run-error-formatting-test.js @@ -89,6 +89,7 @@ const { CannotCloseWindowWithoutParentError, MultipleWindowsModeIsNotAvailableInRemoteBrowserError, MultipleWindowsModeIsNotSupportedInNativeAutomationModeError, + IsolatedSessionRequiresNativeAutomationError, CannotRestoreChildWindowError, TimeoutError, ActionCookieArgumentError, @@ -775,6 +776,10 @@ describe('Error formatting', () => { assertErrorMessage('multiple-windows-mode-is-not-supported-in-native-automation-error', new MultipleWindowsModeIsNotSupportedInNativeAutomationModeError('openWindow')); }); + it('Should format "isolatedSessionRequiresNativeAutomationError"', () => { + assertErrorMessage('isolated-session-requires-native-automation-error', new IsolatedSessionRequiresNativeAutomationError()); + }); + it('Should format "cannotRestoreChildWindowError"', () => { assertErrorMessage('cannot-restore-child-window-error', new CannotRestoreChildWindowError()); }); From faa1f57c587667dbd22969c5a0ea443519220d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yannick=20H=C3=B6chst=C3=B6tter?= Date: Mon, 28 Sep 2026 11:21:31 +0200 Subject: [PATCH 4/5] fix(isolated-session): pass relatedTarget to dispatchEvent and honor caretPos in contenteditable dispatchEvent forwarded relatedTarget inside the plain options object, so an element passed as relatedTarget never reached the event as a node; it is now resolved like the target and passed by object id. typeText with caretPos placed the caret only in input and textarea elements; in a contenteditable element the caret now moves to the requested offset. --- src/api/test-controller/isolated.js | 2 +- src/test-run/isolated-session.ts | 26 ++++++++++++++----- .../fixtures/isolated-sessions/test.js | 8 ++++++ .../testcafe-fixtures/actions-test.js | 22 ++++++++++++++++ 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/api/test-controller/isolated.js b/src/api/test-controller/isolated.js index e313aadb9a1..0126ebdc9d1 100644 --- a/src/api/test-controller/isolated.js +++ b/src/api/test-controller/isolated.js @@ -250,7 +250,7 @@ export class IsolatedTestController { // ===================================================================== [delegatedAPI(DispatchEventCommand.methodName)] (selector, eventName, options) { - return this.enqueueCommand(DispatchEventCommand, { selector, eventName, options }); + return this.enqueueCommand(DispatchEventCommand, { selector, eventName, options, relatedTarget: options?.relatedTarget }); } // ===================================================================== diff --git a/src/test-run/isolated-session.ts b/src/test-run/isolated-session.ts index df3dd97716a..d0f9bf8a58f 100644 --- a/src/test-run/isolated-session.ts +++ b/src/test-run/isolated-session.ts @@ -125,8 +125,9 @@ const FRAME_CONTENT_ORIGIN_FN = `function () { return { x: rect.left + this.clientLeft + parseFloat(s.paddingLeft), y: rect.top + this.clientTop + parseFloat(s.paddingTop) }; }`; -const DISPATCH_EVENT_FN = `function (eventName, options) { +const DISPATCH_EVENT_FN = `function (eventName, options, relatedTarget) { const opts = Object.assign({}, options, { + relatedTarget: relatedTarget || null, bubbles: options.bubbles !== false, cancelable: options.cancelable !== false, detail: options.detail || { click: 1, dblclick: 2, mousedown: 1, mouseup: 1 }[eventName], @@ -784,7 +785,7 @@ export class IsolatedSession extends AsyncEventEmitter { private async _setCaret (selector: any, caretPos: number | null): Promise { const element = await this.resolveElement(selector, RESOLVE_NOW); - await this.callOnElement(element, `function (caretPos) { + const inContentEditable = await this.callOnElement(element, `function (caretPos) { if (typeof this.setSelectionRange === 'function' && typeof this.value === 'string') { const pos = caretPos === null ? this.value.length : caretPos; try { @@ -797,7 +798,13 @@ export class IsolatedSession extends AsyncEventEmitter { selection.selectAllChildren(this); selection.collapseToEnd(); } + else if (this.isContentEditable) + return true; + return false; }`, [caretPos]); + + if (inContentEditable) + await this._cdpSelectText({ selector, startPos: caretPos, endPos: caretPos }); } // Click an element via CDP input dispatch @@ -1219,11 +1226,18 @@ export class IsolatedSession extends AsyncEventEmitter { // ===================================================================== private async _cdpDispatchEvent (command: any): Promise { - const element = await this.resolveElement(command.selector); - const eventName = (command as any).eventName; - const eventOptions = (command as any).options || {}; + const element = await this.resolveElement(command.selector); + const relatedTarget = command.relatedTarget ? await this.resolveElement(command.relatedTarget) : null; + const { relatedTarget: _, ...options } = command.options || {}; + + const result = await this._cdpClient.Runtime.callFunctionOn({ + objectId: element.objectId, + functionDeclaration: DISPATCH_EVENT_FN, + arguments: [{ value: command.eventName }, { value: options }, relatedTarget ? { objectId: relatedTarget.objectId } : { value: null }], + awaitPromise: true, + }); - await this.callOnElement(element, DISPATCH_EVENT_FN, [eventName, eventOptions]); + this._throwOnException(result.exceptionDetails); } // ===================================================================== diff --git a/test/functional/fixtures/isolated-sessions/test.js b/test/functional/fixtures/isolated-sessions/test.js index 70df47e3d24..c9d2747abf1 100644 --- a/test/functional/fixtures/isolated-sessions/test.js +++ b/test/functional/fixtures/isolated-sessions/test.js @@ -253,6 +253,14 @@ describe('Isolated Sessions', () => { return runTests('testcafe-fixtures/actions-test.js', 'typeText types at the end of the existing value', { only: 'chrome' }); }); + it('dispatchEvent passes relatedTarget as an element', () => { + return runTests('testcafe-fixtures/actions-test.js', 'dispatchEvent passes relatedTarget as an element', { only: 'chrome' }); + }); + + it('typeText with caretPos in a contenteditable', () => { + return runTests('testcafe-fixtures/actions-test.js', 'typeText with caretPos in a contenteditable', { only: 'chrome' }); + }); + it('dispatchEvent builds the matching event type', () => { return runTests('testcafe-fixtures/actions-test.js', 'dispatchEvent builds the matching event type', { only: 'chrome' }); }); diff --git a/test/functional/fixtures/isolated-sessions/testcafe-fixtures/actions-test.js b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/actions-test.js index 705f4978cc6..8cc4ce11df7 100644 --- a/test/functional/fixtures/isolated-sessions/testcafe-fixtures/actions-test.js +++ b/test/functional/fixtures/isolated-sessions/testcafe-fixtures/actions-test.js @@ -210,6 +210,28 @@ test('dispatchEvent builds the matching event type', async t => { ]); }); +test('dispatchEvent passes relatedTarget as an element', async t => { + const t2 = await openActionsPage(t); + + await t2.eval(() => { + document.querySelector('#target').addEventListener('mouseover', e => { + window.__related = e.relatedTarget && e.relatedTarget.id; + }); + }); + + await t2.dispatchEvent('#target', 'mouseover', { relatedTarget: '#editable' }); + + await t.expect(await t2.eval(() => window.__related)).eql('editable'); +}); + +test('typeText with caretPos in a contenteditable', async t => { + const t2 = await openActionsPage(t); + + await t2.typeText('#editable', 'X', { caretPos: 6 }); + + await t.expect(await t2.eval(() => document.querySelector('#editable').textContent)).eql('Hello Xbig World'); +}); + test('takeScreenshot with fullPage captures the whole page', async t => { const t2 = await openActionsPage(t); From 514c7d58a163a3a8d626e986f6b3f6e7daef518d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yannick=20H=C3=B6chst=C3=B6tter?= Date: Mon, 28 Sep 2026 13:01:44 +0200 Subject: [PATCH 5/5] fix(isolated-session): release CDP remote objects once a command finishes Every element lookup created a Runtime remote object without an object group and nothing released it, so handles lived until the isolated tab closed and elements the page had removed stayed in memory; thrown exceptions were retained too. Lookups, the scroll document and evaluateExpression now use the isolated-command group, released with Runtime.releaseObjectGroup when the last t2 command in flight finishes. The iframe element entered with switchToIframe is copied into the isolated-frames group and released on switchToMainWindow or navigateTo. --- src/api/test-controller/isolated.js | 12 +- src/test-run/isolated-session.ts | 48 ++++++-- test/server/isolated-session-test.js | 173 +++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 14 deletions(-) create mode 100644 test/server/isolated-session-test.js diff --git a/src/api/test-controller/isolated.js b/src/api/test-controller/isolated.js index 0126ebdc9d1..0c69d3cd6e1 100644 --- a/src/api/test-controller/isolated.js +++ b/src/api/test-controller/isolated.js @@ -315,7 +315,7 @@ export class IsolatedTestController { const callsite = getCallsiteForMethod('eval'); return this._enqueueTask('eval', () => { - return async () => { + return () => session.withCommandObjectGroup(async () => { if (options?.dependencies) { const command = new ClientFunctionBuilder(fn, options, { instantiation: 'eval', execution: 'eval' }).getCommand([]); const [value] = await session._executeClientFunctionViaCDP(command); @@ -326,7 +326,7 @@ export class IsolatedTestController { const expression = `(${fn.toString()})()`; return session.evaluateExpression(expression); - }; + }); }, callsite); } @@ -369,7 +369,7 @@ export class IsolatedTestController { return this._enqueueTask('switchToIframe', () => { return async () => { - await session.switchToIframe(selector); + await session.withCommandObjectGroup(() => session.switchToIframe(selector)); }; }, callsite); } @@ -391,7 +391,7 @@ export class IsolatedTestController { return this._enqueueTask('takeElementScreenshot', () => { return async () => { - return session.takeElementScreenshot(selector, filePath); + return session.withCommandObjectGroup(() => session.takeElementScreenshot(selector, filePath)); }; }, callsite); } @@ -402,7 +402,7 @@ export class IsolatedTestController { return this._enqueueTask('setFilesToUpload', () => { return async () => { - await session.setFilesToUpload(selector, filePaths); + await session.withCommandObjectGroup(() => session.setFilesToUpload(selector, filePaths)); }; }, callsite); } @@ -413,7 +413,7 @@ export class IsolatedTestController { return this._enqueueTask('clearUpload', () => { return async () => { - await session.clearUpload(selector); + await session.withCommandObjectGroup(() => session.clearUpload(selector)); }; }, callsite); } diff --git a/src/test-run/isolated-session.ts b/src/test-run/isolated-session.ts index d0f9bf8a58f..00ecb610df5 100644 --- a/src/test-run/isolated-session.ts +++ b/src/test-run/isolated-session.ts @@ -43,6 +43,9 @@ const DRAG_STEP_DELAY = 16; const SCROLL_POSITIONS = ['topLeft', 'top', 'topRight', 'left', 'center', 'right', 'bottomLeft', 'bottom', 'bottomRight']; +const COMMAND_OBJECT_GROUP = 'isolated-command'; +const FRAME_OBJECT_GROUP = 'isolated-frames'; + // Resolve options for callers that act on the element as it is right now const RESOLVE_NOW = { timeout: 0, visibilityCheck: false }; @@ -237,6 +240,8 @@ export class IsolatedSession extends AsyncEventEmitter { private _frameElements: ResolvedElement[]; + private _commandsInFlight: number; + private _unexpectedDialog: { type: string; message: string; url: string } | null; // Configurable page load timeout (default: PAGE_LOAD_TIMEOUT constant) @@ -271,6 +276,7 @@ export class IsolatedSession extends AsyncEventEmitter { this._frameContexts = new Map(); this._frameElements = []; + this._commandsInFlight = 0; this._unexpectedDialog = null; // Lets ClientFunction / Selector .with({ boundTestRun: t2 }) accept the isolated session @@ -296,6 +302,23 @@ export class IsolatedSession extends AsyncEventEmitter { if (this._disposed) throw new Error('Isolated session has been disposed'); + return this.withCommandObjectGroup(() => this._dispatchCommand(command, callsite)); + } + + // Commands can overlap (bound selectors, t2.run callbacks): release only when none is in flight + public async withCommandObjectGroup (fn: () => Promise): Promise { + this._commandsInFlight++; + + try { + return await fn(); + } + finally { + if (--this._commandsInFlight === 0) + await this._cdpClient.Runtime.releaseObjectGroup({ objectGroup: COMMAND_OBJECT_GROUP }).catch(noop); + } + } + + private async _dispatchCommand (command: CommandBase | ActionCommandBase, callsite?: CallsiteRecord | string): Promise { if (command.type === COMMAND_TYPE.wait) return delay((command as any).timeout); @@ -415,11 +438,8 @@ export class IsolatedSession extends AsyncEventEmitter { const sessions = testRun._isolatedSelectorTargets; const active = sessions.length ? sessions[sessions.length - 1] : null; - if (active && cmd.type === COMMAND_TYPE.executeSelector) - return active._executeSelectorViaCDP(cmd); - - if (active && cmd.type === COMMAND_TYPE.executeClientFunction) - return active._executeClientFunctionViaCDP(cmd); + if (active && (cmd.type === COMMAND_TYPE.executeSelector || cmd.type === COMMAND_TYPE.executeClientFunction)) + return active.executeCommand(cmd); return originalExecuteCommand(cmd, cmdCallsite); }; @@ -564,7 +584,7 @@ export class IsolatedSession extends AsyncEventEmitter { while (true) { const contextId = this._currentContextId; - const result = await this._cdpClient.Runtime.evaluate({ expression, ...this._contextIdParam() }); + const result = await this._cdpClient.Runtime.evaluate({ expression, objectGroup: COMMAND_OBJECT_GROUP, ...this._contextIdParam() }); this._throwOnException(result.exceptionDetails); @@ -658,6 +678,8 @@ export class IsolatedSession extends AsyncEventEmitter { this._currentContextId = void 0; this._frameElements = []; + await this._cdpClient.Runtime.releaseObjectGroup({ objectGroup: FRAME_OBJECT_GROUP }).catch(noop); + const { errorText } = await this._cdpClient.Page.navigate({ url }); if (errorText) @@ -702,6 +724,7 @@ export class IsolatedSession extends AsyncEventEmitter { expression, returnByValue: true, awaitPromise: true, + objectGroup: COMMAND_OBJECT_GROUP, ...this._contextIdParam(), }); @@ -1204,7 +1227,8 @@ export class IsolatedSession extends AsyncEventEmitter { private async _resolveScrollingElement (): Promise { const contextId = this._currentContextId; const result = await this._cdpClient.Runtime.evaluate({ - expression: 'document.scrollingElement || document.documentElement', + expression: 'document.scrollingElement || document.documentElement', + objectGroup: COMMAND_OBJECT_GROUP, ...this._contextIdParam(), }); @@ -1435,14 +1459,22 @@ export class IsolatedSession extends AsyncEventEmitter { await delay(50); } + const frame = await this._cdpClient.Runtime.callFunctionOn({ + objectId: element.objectId, + functionDeclaration: 'function () { return this; }', + objectGroup: FRAME_OBJECT_GROUP, + }); + this._currentContextId = contextId; - this._frameElements.push(element); + this._frameElements.push({ ...element, objectId: frame.result.objectId as string }); } // Switch back to the main frame's evaluation context public async switchToMainWindow (): Promise { this._currentContextId = void 0; this._frameElements = []; + + await this._cdpClient.Runtime.releaseObjectGroup({ objectGroup: FRAME_OBJECT_GROUP }).catch(noop); } private _trackFrameContexts (): void { diff --git a/test/server/isolated-session-test.js b/test/server/isolated-session-test.js new file mode 100644 index 00000000000..e10906ab017 --- /dev/null +++ b/test/server/isolated-session-test.js @@ -0,0 +1,173 @@ +const { expect } = require('chai'); +const { IsolatedSession } = require('../../lib/test-run/isolated-session'); +const COMMAND_TYPE = require('../../lib/test-run/commands/type'); +const { initSelector } = require('../../lib/test-run/commands/validations/initializers'); + +describe('IsolatedSession remote objects', () => { + let calls = null; + let listeners = null; + let client = null; + let lookups = null; + let polls = null; + + function record (method, handler) { + return async params => { + calls.push({ method, params }); + + return handler(params); + }; + } + + function createSession () { + const parentTestRun = { opts: {}, executeCommand: async () => {} }; + + return new IsolatedSession({ parentTestRun, nativeAutomation: { cdpClient: client }, browserContextId: 'ctx-1' }); + } + + function releases () { + return calls.filter(call => call.method === 'Runtime.releaseObjectGroup').map(call => call.params.objectGroup); + } + + function click (selector) { + return { type: COMMAND_TYPE.click, selector, options: {} }; + } + + beforeEach(() => { + calls = []; + listeners = {}; + lookups = 0; + polls = []; + + client = { + Runtime: { + on: (event, cb) => { + listeners[event] = cb; + }, + + evaluate: record('Runtime.evaluate', ({ expression }) => { + if (expression === 'document.readyState') + return { result: { value: 'complete' } }; + + if (polls.length) + return { result: polls.shift() }; + + return { result: { type: 'object', objectId: `el-${++lookups}` } }; + }), + + callFunctionOn: record('Runtime.callFunctionOn', ({ objectId, returnByValue }) => { + return returnByValue ? { result: { value: { x: 1, y: 1 } } } : { result: { objectId: `${objectId}-frame` } }; + }), + + releaseObjectGroup: record('Runtime.releaseObjectGroup', () => {}), + }, + + Page: { + on: () => {}, + navigate: record('Page.navigate', () => ({})), + }, + + Input: { + dispatchMouseEvent: record('Input.dispatchMouseEvent', () => {}), + }, + + DOM: { + describeNode: record('DOM.describeNode', () => ({ node: { frameId: 'frame-1' } })), + }, + }; + }); + + it('Should release the command object group after a command', async () => { + const session = createSession(); + + polls = [{ value: null }, { value: 'invisible' }]; + + await session.executeCommand(click('#button')); + + const lookupGroups = calls.filter(call => call.method === 'Runtime.evaluate').map(call => call.params.objectGroup); + + expect(lookupGroups).eql(['isolated-command', 'isolated-command', 'isolated-command']); + expect(calls[calls.length - 1].method).eql('Runtime.releaseObjectGroup'); + expect(releases()).eql(['isolated-command']); + }); + + it('Should release the command object group after a failing command and keep its error', async () => { + const session = createSession(); + + client.Input.dispatchMouseEvent = async () => { + throw new Error('dispatch failed'); + }; + + client.Runtime.releaseObjectGroup = record('Runtime.releaseObjectGroup', () => { + throw new Error('Target closed'); + }); + + let error = null; + + try { + await session.executeCommand(click('#button')); + } + catch (err) { + error = err; + } + + expect(error.message).eql('dispatch failed'); + expect(releases()).eql(['isolated-command']); + }); + + it('Should release the command object group only after overlapping commands finish', async () => { + const session = createSession(); + const selector = initSelector('selector', '#label', { testRun: session.parentTestRun }); + let openGate = null; + const gate = new Promise(resolve => { + openGate = resolve; + }); + + client.Input.dispatchMouseEvent = record('Input.dispatchMouseEvent', () => gate); + + const clicking = session.executeCommand(click('#button')); + + await session.executeCommand(selector); + + expect(releases()).eql([]); + + openGate(); + await clicking; + + expect(releases()).eql(['isolated-command']); + }); + + it('Should keep iframe elements until switchToMainWindow', async () => { + const session = createSession(); + + listeners.executionContextCreated({ context: { id: 7, auxData: { isDefault: true, frameId: 'frame-1' } } }); + + await session.withCommandObjectGroup(() => session.switchToIframe('#frame')); + await session.executeCommand(click('#button')); + + const frameCopy = calls.find(call => call.method === 'Runtime.callFunctionOn' && call.params.objectGroup === 'isolated-frames'); + const originCalls = calls.filter(call => call.method === 'Runtime.callFunctionOn' && call.params.objectId === 'el-1-frame'); + + expect(frameCopy.params.objectId).eql('el-1'); + expect(originCalls.length).eql(1); + expect(releases()).eql(['isolated-command', 'isolated-command']); + + await session.switchToMainWindow(); + + expect(releases()).eql(['isolated-command', 'isolated-command', 'isolated-frames']); + }); + + it('Should release iframe elements on navigation', async () => { + const session = createSession(); + + listeners.executionContextCreated({ context: { id: 7, auxData: { isDefault: true, frameId: 'frame-1' } } }); + + await session.withCommandObjectGroup(() => session.switchToIframe('#frame')); + await session.executeCommand({ type: COMMAND_TYPE.navigateTo, url: 'http://example.test/' }); + + const navigateIndex = calls.findIndex(call => call.method === 'Page.navigate'); + const frameRelease = calls.findIndex(call => call.method === 'Runtime.releaseObjectGroup' && call.params.objectGroup === 'isolated-frames'); + + expect(frameRelease).gte(0); + expect(frameRelease).lt(navigateIndex); + }); +});