From 0f12eb315d624ce10981f32c3ca853ebc45ce335 Mon Sep 17 00:00:00 2001 From: DudaGod Date: Mon, 9 Oct 2023 22:21:50 +0300 Subject: [PATCH 1/2] feat: ability to run tests in isolated environment --- README.md | 5 + src/browser/existing-browser.js | 46 ++++++++ src/config/browser-options.js | 1 + src/config/defaults.js | 1 + src/config/types.ts | 1 + src/constants/browser.ts | 1 + src/utils/browser.ts | 7 ++ test/src/browser/existing-browser.js | 162 ++++++++++++++++++++++++++- test/src/browser/utils.js | 29 ++++- test/src/config/browser-options.js | 4 +- test/src/utils/browser.ts | 24 ++++ 11 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 src/constants/browser.ts create mode 100644 src/utils/browser.ts create mode 100644 test/src/utils/browser.ts diff --git a/README.md b/README.md index 85999e515..ded0f6f96 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Hermione is a utility for integration testing of web pages using [WebdriverIO](h - [key](#key) - [region](#region) - [headless](#headless) + - [isolation](#isolation) - [system](#system) - [debug](#debug) - [mochaOpts](#mochaopts) @@ -852,6 +853,7 @@ Option name | Description `key` | Cloud service access key or secret key. Default value is `null`. `region` | Ability to choose different datacenters for run in cloud service. Default value is `null`. `headless` | Ability to run headless browser in cloud service. Default value is `null`. +`isolation` | Ability to execute tests in isolated clean-state environment ([incognito browser context](https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-createBrowserContext)). Default value is `false`. #### desiredCapabilities **Required.** Used WebDriver [DesiredCapabilities](https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities). For example, @@ -1116,6 +1118,9 @@ Ability to choose different datacenters for run in cloud service. Default value #### headless Ability to run headless browser in cloud service. Default value is `null`. +#### isolation +Ability to execute tests in isolated clean-state environment ([incognito browser context](https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-createBrowserContext)). It means that `testsPerSession` can be set to `Infinity` in order to speed up tests execution and save browser resources. Currently works only in chrome@93 and higher. Default value is `false`. + ### system #### debug diff --git a/src/browser/existing-browser.js b/src/browser/existing-browser.js index c649b0842..c1d11da21 100644 --- a/src/browser/existing-browser.js +++ b/src/browser/existing-browser.js @@ -12,6 +12,9 @@ const Camera = require("./camera"); const clientBridge = require("./client-bridge"); const history = require("./history"); const logger = require("../utils/logger"); +const { WEBDRIVER_PROTOCOL } = require("../constants/config"); +const { MIN_CHROME_VERSION_SUPPORT_ISOLATION } = require("../constants/browser"); +const { isSupportIsolation } = require("../utils/browser"); const OPTIONAL_SESSION_OPTS = ["transformRequest", "transformResponse"]; @@ -38,6 +41,8 @@ module.exports = class ExistingBrowser extends Browser { await history.runGroup(this._callstackHistory, "hermione: init browser", async () => { this._addCommands(); + await this._performIsolation({ sessionCaps, sessionOpts }); + try { this.config.prepareBrowser && this.config.prepareBrowser(this.publicAPI); } catch (e) { @@ -201,6 +206,47 @@ module.exports = class ExistingBrowser extends Browser { return this._config.baseUrl ? url.resolve(this._config.baseUrl, uri) : uri; } + async _performIsolation({ sessionCaps, sessionOpts }) { + if (!this._config.isolation) { + return; + } + + const { browserName, browserVersion = "", version = "" } = sessionCaps; + const { automationProtocol } = sessionOpts; + + if (!isSupportIsolation(browserName, browserVersion)) { + logger.warn( + `WARN: test isolation works only with chrome@${MIN_CHROME_VERSION_SUPPORT_ISOLATION} and higher, ` + + `but got ${browserName}@${browserVersion || version}`, + ); + return; + } + + const puppeteer = await this._session.getPuppeteer(); + const browserCtxs = puppeteer.browserContexts(); + + const incognitoCtx = await puppeteer.createIncognitoBrowserContext(); + const page = await incognitoCtx.newPage(); + + if (automationProtocol === WEBDRIVER_PROTOCOL) { + const windowIds = await this._session.getWindowHandles(); + const incognitoWindowId = windowIds.find(id => id.includes(page.target()._targetId)); + + await this._session.switchToWindow(incognitoWindowId); + } + + for (const ctx of browserCtxs) { + if (ctx.isIncognito()) { + await ctx.close(); + continue; + } + + for (const page of await ctx.pages()) { + await page.close(); + } + } + } + async _prepareSession() { await this._setOrientation(this.config.orientation); await this._setWindowSize(this.config.windowSize); diff --git a/src/config/browser-options.js b/src/config/browser-options.js index 0be2cc16f..9846380b6 100644 --- a/src/config/browser-options.js +++ b/src/config/browser-options.js @@ -311,5 +311,6 @@ function buildBrowserOptions(defaultFactory, extra) { key: options.optionalString("key"), region: options.optionalString("region"), headless: options.optionalBoolean("headless"), + isolation: options.boolean("isolation"), }); } diff --git a/src/config/defaults.js b/src/config/defaults.js index 667feb25c..b81b5d01d 100644 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -90,6 +90,7 @@ module.exports = { key: null, region: null, headless: null, + isolation: false, }; module.exports.configPaths = [".hermione.conf.ts", ".hermione.conf.js"]; diff --git a/src/config/types.ts b/src/config/types.ts index de50da035..b8fdbb863 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -119,6 +119,7 @@ export interface CommonConfig { system: SystemConfig; headless: boolean | null; + isolation: boolean; } export interface SetsConfig { diff --git a/src/constants/browser.ts b/src/constants/browser.ts new file mode 100644 index 000000000..f7772cf45 --- /dev/null +++ b/src/constants/browser.ts @@ -0,0 +1 @@ +export const MIN_CHROME_VERSION_SUPPORT_ISOLATION = 93; diff --git a/src/utils/browser.ts b/src/utils/browser.ts new file mode 100644 index 000000000..1830a2a5d --- /dev/null +++ b/src/utils/browser.ts @@ -0,0 +1,7 @@ +import { MIN_CHROME_VERSION_SUPPORT_ISOLATION } from "../constants/browser"; + +export const isSupportIsolation = (browserName: string, browserVersion = ""): boolean => { + const browserVersionMajor = browserVersion.split(".")[0]; + + return browserName === "chrome" && Number(browserVersionMajor) >= MIN_CHROME_VERSION_SUPPORT_ISOLATION; +}; diff --git a/test/src/browser/existing-browser.js b/test/src/browser/existing-browser.js index ead6feba9..92bb3950c 100644 --- a/test/src/browser/existing-browser.js +++ b/test/src/browser/existing-browser.js @@ -11,8 +11,16 @@ const Camera = require("src/browser/camera"); const clientBridge = require("src/browser/client-bridge"); const logger = require("src/utils/logger"); const history = require("src/browser/history"); -const { SAVE_HISTORY_MODE } = require("src/constants/config"); -const { mkExistingBrowser_: mkBrowser_, mkSessionStub_ } = require("./utils"); +const { SAVE_HISTORY_MODE, WEBDRIVER_PROTOCOL, DEVTOOLS_PROTOCOL } = require("src/constants/config"); +const { MIN_CHROME_VERSION_SUPPORT_ISOLATION } = require("src/constants/browser"); +const { + mkExistingBrowser_: mkBrowser_, + mkSessionStub_, + mkCDPStub_, + mkCDPBrowserCtx_, + mkCDPPage_, + mkCDPTarget_, +} = require("./utils"); describe("ExistingBrowser", () => { const sandbox = sinon.sandbox.create(); @@ -390,6 +398,156 @@ describe("ExistingBrowser", () => { }); }); + describe("perform isolation", () => { + let cdp, incognitoBrowserCtx, incognitoPage, incognitoTarget; + + beforeEach(() => { + incognitoTarget = mkCDPTarget_(); + incognitoPage = mkCDPPage_(); + incognitoPage.target.returns(incognitoTarget); + + incognitoBrowserCtx = mkCDPBrowserCtx_(); + incognitoBrowserCtx.newPage.resolves(incognitoPage); + incognitoBrowserCtx.isIncognito.returns(true); + + cdp = mkCDPStub_(); + cdp.createIncognitoBrowserContext.resolves(incognitoBrowserCtx); + + session.getPuppeteer.resolves(cdp); + }); + + describe("should do nothing if", () => { + it("'isolation' option is not specified", async () => { + await initBrowser_(mkBrowser_({ isolation: false })); + + assert.notCalled(session.getPuppeteer); + assert.notCalled(logger.warn); + }); + + it("test wasn't run in chrome", async () => { + const sessionCaps = { browserName: "firefox", browserVersion: "104.0" }; + + await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); + + assert.notCalled(session.getPuppeteer); + }); + + it(`test wasn't run in chrome@${MIN_CHROME_VERSION_SUPPORT_ISOLATION} or higher`, async () => { + const sessionCaps = { browserName: "chrome", browserVersion: "90.0" }; + + await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); + + assert.notCalled(session.getPuppeteer); + }); + }); + + describe("should warn that isolation doesn't work in", () => { + it("chrome browser (w3c)", async () => { + const sessionCaps = { browserName: "chrome", browserVersion: "90.0" }; + + await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); + + assert.calledOnceWith( + logger.warn, + `WARN: test isolation works only with chrome@${MIN_CHROME_VERSION_SUPPORT_ISOLATION} and higher, ` + + "but got chrome@90.0", + ); + }); + + it("chrome browser (jsonwp)", async () => { + const sessionCaps = { browserName: "chrome", version: "70.0" }; + + await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); + + assert.calledOnceWith( + logger.warn, + `WARN: test isolation works only with chrome@${MIN_CHROME_VERSION_SUPPORT_ISOLATION} and higher, ` + + "but got chrome@70.0", + ); + }); + }); + + it("should create incognito browser context", async () => { + const sessionCaps = { browserName: "chrome", browserVersion: "100.0" }; + + await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); + + assert.calledOnceWithExactly(cdp.createIncognitoBrowserContext); + }); + + it("should get current browser contexts before create incognito", async () => { + const sessionCaps = { browserName: "chrome", browserVersion: "100.0" }; + + await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); + + assert.callOrder(cdp.browserContexts, cdp.createIncognitoBrowserContext); + }); + + it("should create new page inside incognito browser context", async () => { + const sessionCaps = { browserName: "chrome", browserVersion: "100.0" }; + + await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); + + assert.calledOnceWithExactly(incognitoBrowserCtx.newPage); + }); + + describe(`in "${WEBDRIVER_PROTOCOL}" protocol`, () => { + it("should switch to incognito window", async () => { + incognitoTarget._targetId = "456"; + session.getWindowHandles.resolves(["window_123", "window_456", "window_789"]); + + const sessionCaps = { browserName: "chrome", browserVersion: "100.0" }; + const sessionOpts = { automationProtocol: WEBDRIVER_PROTOCOL }; + + await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps, sessionOpts }); + + assert.calledOnceWith(session.switchToWindow, "window_456"); + assert.callOrder(incognitoBrowserCtx.newPage, session.getWindowHandles); + }); + }); + + describe(`in "${DEVTOOLS_PROTOCOL}" protocol`, () => { + it("should not switch to incognito window", async () => { + const sessionCaps = { browserName: "chrome", browserVersion: "100.0" }; + const sessionOpts = { automationProtocol: DEVTOOLS_PROTOCOL }; + + await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps, sessionOpts }); + + assert.notCalled(session.getWindowHandles); + assert.notCalled(session.switchToWindow); + }); + }); + + it("should close pages in default browser context", async () => { + const defaultBrowserCtx = mkCDPBrowserCtx_(); + const page1 = mkCDPPage_(); + const page2 = mkCDPPage_(); + defaultBrowserCtx.pages.resolves([page1, page2]); + + cdp.browserContexts.returns([defaultBrowserCtx, incognitoBrowserCtx]); + + const sessionCaps = { browserName: "chrome", browserVersion: "100.0" }; + + await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); + + assert.calledOnceWithExactly(page1.close); + assert.calledOnceWithExactly(page2.close); + assert.notCalled(incognitoPage.close); + }); + + it("should close incognito browser context", async () => { + const defaultBrowserCtx = mkCDPBrowserCtx_(); + cdp.browserContexts.returns([defaultBrowserCtx, incognitoBrowserCtx]); + + const sessionCaps = { browserName: "chrome", browserVersion: "100.0" }; + + await initBrowser_(mkBrowser_({ isolation: true }), { sessionCaps }); + + assert.calledOnceWithExactly(incognitoBrowserCtx.close); + assert.notCalled(defaultBrowserCtx.close); + }); + }); + it("should call prepareBrowser on new browser", async () => { const prepareBrowser = sandbox.stub(); const browser = mkBrowser_({ prepareBrowser }); diff --git a/test/src/browser/utils.js b/test/src/browser/utils.js index 9d07d9dbc..6b74b56e6 100644 --- a/test/src/browser/utils.js +++ b/test/src/browser/utils.js @@ -38,6 +38,7 @@ function createBrowserConfig_(opts = {}) { region: null, headless: null, saveHistory: true, + isolation: false, }); return { @@ -92,9 +93,11 @@ exports.mkSessionStub_ = () => { session.waitUntil = sinon.stub().named("waitUntil").resolves(); session.setTimeout = sinon.stub().named("setTimeout").resolves(); session.setTimeouts = sinon.stub().named("setTimeouts").resolves(); - session.getPuppeteer = sinon.stub().named("getPuppeteer").resolves({}); + session.getPuppeteer = sinon.stub().named("getPuppeteer").resolves(exports.mkCDPStub_()); session.$ = sinon.stub().named("$").resolves(element); session.mock = sinon.stub().named("mock").resolves(exports.mkMockStub_()); + session.getWindowHandles = sinon.stub().named("getWindowHandles").resolves([]); + session.switchToWindow = sinon.stub().named("switchToWindow").resolves(); session.addCommand = sinon.stub().callsFake((name, command, isElement) => { const target = isElement ? element : session; @@ -112,3 +115,27 @@ exports.mkSessionStub_ = () => { return session; }; + +exports.mkCDPStub_ = () => ({ + browserContexts: sinon.stub().named("browserContexts").returns([]), + createIncognitoBrowserContext: sinon + .stub() + .named("createIncognitoBrowserContext") + .resolves(exports.mkCDPBrowserCtx_()), +}); + +exports.mkCDPBrowserCtx_ = () => ({ + newPage: sinon.stub().named("newPage").resolves(exports.mkCDPPage_()), + isIncognito: sinon.stub().named("isIncognito").returns(false), + pages: sinon.stub().named("pages").resolves([]), + close: sinon.stub().named("close").resolves(), +}); + +exports.mkCDPPage_ = () => ({ + target: sinon.stub().named("target").returns(exports.mkCDPTarget_()), + close: sinon.stub().named("close").resolves(), +}); + +exports.mkCDPTarget_ = () => ({ + _targetId: "12345", +}); diff --git a/test/src/config/browser-options.js b/test/src/config/browser-options.js index db80d9bf4..56b49c25c 100644 --- a/test/src/config/browser-options.js +++ b/test/src/config/browser-options.js @@ -1160,8 +1160,8 @@ describe("config browser-options", () => { }); } - ["calibrate", "compositeImage", "resetCursor", "strictTestsOrder", "waitOrientationChange"].forEach(option => - describe(option, () => testBooleanOption(option)), + ["calibrate", "compositeImage", "resetCursor", "strictTestsOrder", "waitOrientationChange", "isolation"].forEach( + option => describe(option, () => testBooleanOption(option)), ); describe("saveHistoryMode", () => { diff --git a/test/src/utils/browser.ts b/test/src/utils/browser.ts new file mode 100644 index 000000000..e0dff715f --- /dev/null +++ b/test/src/utils/browser.ts @@ -0,0 +1,24 @@ +import { isSupportIsolation } from "src/utils/browser"; +import { MIN_CHROME_VERSION_SUPPORT_ISOLATION } from "src/constants/browser"; + +describe("browser-utils", () => { + describe("isSupportIsolation", () => { + describe("should return 'false' if", () => { + it("specified browser is not chrome", () => { + assert.isFalse(isSupportIsolation("firefox")); + }); + + it("specified browser is chrome, but version is not passed", () => { + assert.isFalse(isSupportIsolation("chrome")); + }); + + it(`specified chrome lower than @${MIN_CHROME_VERSION_SUPPORT_ISOLATION}`, () => { + assert.isFalse(isSupportIsolation("chrome", "90.0")); + }); + }); + + it(`should return 'true' if specified chrome@${MIN_CHROME_VERSION_SUPPORT_ISOLATION} or higher`, () => { + assert.isTrue(isSupportIsolation("chrome", `${MIN_CHROME_VERSION_SUPPORT_ISOLATION}.0`)); + }); + }); +}); From 473e96a40537d56f14c0dffd77af0b3adb5108bc Mon Sep 17 00:00:00 2001 From: DudaGod Date: Tue, 17 Oct 2023 23:51:10 +0300 Subject: [PATCH 2/2] feat: run tests in isolated env in chrome@93 and higher by default BREAKING CHANGE: - tests in chrome@93 and higher now run in as isolated environment by default --- README.md | 4 ++-- src/config/browser-options.js | 18 +++++++++++++- test/src/config/browser-options.js | 38 ++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ded0f6f96..8b505e9d3 100644 --- a/README.md +++ b/README.md @@ -853,7 +853,7 @@ Option name | Description `key` | Cloud service access key or secret key. Default value is `null`. `region` | Ability to choose different datacenters for run in cloud service. Default value is `null`. `headless` | Ability to run headless browser in cloud service. Default value is `null`. -`isolation` | Ability to execute tests in isolated clean-state environment ([incognito browser context](https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-createBrowserContext)). Default value is `false`. +`isolation` | Ability to execute tests in isolated clean-state environment ([incognito browser context](https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-createBrowserContext)). Default value is `false`, but `true` for chrome@93 and higher. #### desiredCapabilities **Required.** Used WebDriver [DesiredCapabilities](https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities). For example, @@ -1119,7 +1119,7 @@ Ability to choose different datacenters for run in cloud service. Default value Ability to run headless browser in cloud service. Default value is `null`. #### isolation -Ability to execute tests in isolated clean-state environment ([incognito browser context](https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-createBrowserContext)). It means that `testsPerSession` can be set to `Infinity` in order to speed up tests execution and save browser resources. Currently works only in chrome@93 and higher. Default value is `false`. +Ability to execute tests in isolated clean-state environment ([incognito browser context](https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-createBrowserContext)). It means that `testsPerSession` can be set to `Infinity` in order to speed up tests execution and save browser resources. Currently works only in chrome@93 and higher. Default value is `false`, but `true` for chrome@93 and higher. ### system diff --git a/src/config/browser-options.js b/src/config/browser-options.js index 9846380b6..0c8880226 100644 --- a/src/config/browser-options.js +++ b/src/config/browser-options.js @@ -6,6 +6,7 @@ const defaults = require("./defaults"); const optionsBuilder = require("./options-builder"); const utils = require("./utils"); const { WEBDRIVER_PROTOCOL, DEVTOOLS_PROTOCOL, SAVE_HISTORY_MODE } = require("../constants/config"); +const { isSupportIsolation } = require("../utils/browser"); const is = utils.is; @@ -311,6 +312,21 @@ function buildBrowserOptions(defaultFactory, extra) { key: options.optionalString("key"), region: options.optionalString("region"), headless: options.optionalBoolean("headless"), - isolation: options.boolean("isolation"), + + isolation: option({ + defaultValue: defaultFactory("isolation"), + parseCli: value => utils.parseBoolean(value, "isolation"), + parseEnv: value => utils.parseBoolean(value, "isolation"), + validate: is("boolean", "isolation"), + map: (value, config, currentNode, meta) => { + if (meta.isSpecified) { + return value; + } + + const caps = _.get(currentNode, "desiredCapabilities"); + + return caps && isSupportIsolation(caps.browserName, caps.browserVersion) ? true : value; + }, + }), }); } diff --git a/test/src/config/browser-options.js b/test/src/config/browser-options.js index 56b49c25c..5443da888 100644 --- a/test/src/config/browser-options.js +++ b/test/src/config/browser-options.js @@ -1164,6 +1164,44 @@ describe("config browser-options", () => { option => describe(option, () => testBooleanOption(option)), ); + describe("isolation", () => { + it("should set to 'true' if browser support isolation", () => { + const readConfig = { + browsers: { + b1: mkBrowser_({ + desiredCapabilities: { + browserName: "chrome", + browserVersion: "101.0", + }, + }), + }, + }; + Config.read.returns(readConfig); + + const config = createConfig(); + + assert.isTrue(config.browsers.b1.isolation); + }); + + it("should set to 'false' if browser doesn't support isolation", () => { + const readConfig = { + browsers: { + b1: mkBrowser_({ + desiredCapabilities: { + browserName: "chrome", + browserVersion: "90.0", + }, + }), + }, + }; + Config.read.returns(readConfig); + + const config = createConfig(); + + assert.isFalse(config.browsers.b1.isolation); + }); + }); + describe("saveHistoryMode", () => { it("should throw an error if value is not available", () => { const readConfig = _.set({}, "browsers.b1", mkBrowser_({ saveHistoryMode: "foo" }));