From d8909f3f6ac9c63a31ae55c9fb28543faead790e Mon Sep 17 00:00:00 2001 From: DudaGod Date: Mon, 9 Oct 2023 22:21:50 +0300 Subject: [PATCH] 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/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 ++++ 10 files changed, 275 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 6f7cd2100..5c997f3c8 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) @@ -841,6 +842,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, @@ -1105,6 +1107,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 e1ef10cf2..8aa659d9f 100644 --- a/src/config/browser-options.js +++ b/src/config/browser-options.js @@ -303,5 +303,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 d07fa9748..53266298c 100644 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -84,6 +84,7 @@ module.exports = { key: null, region: null, headless: null, + isolation: false, }; module.exports.configPaths = [".hermione.conf.ts", ".hermione.conf.js"]; 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 de59022c7..4788987f4 100644 --- a/test/src/browser/utils.js +++ b/test/src/browser/utils.js @@ -37,6 +37,7 @@ function createBrowserConfig_(opts = {}) { region: null, headless: null, saveHistory: true, + isolation: false, }); return { @@ -81,8 +82,10 @@ 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.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; @@ -100,3 +103,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 fc4b41465..746758055 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`)); + }); + }); +});