Skip to content

Commit

Permalink
feat: ability to run tests in isolated environment
Browse files Browse the repository at this point in the history
  • Loading branch information
DudaGod committed Oct 17, 2023
1 parent eadea3f commit 3cf72f5
Show file tree
Hide file tree
Showing 10 changed files with 276 additions and 5 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions src/browser/existing-browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/config/browser-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,5 +303,6 @@ function buildBrowserOptions(defaultFactory, extra) {
key: options.optionalString("key"),
region: options.optionalString("region"),
headless: options.optionalBoolean("headless"),
isolation: options.boolean("isolation"),
});
}
1 change: 1 addition & 0 deletions src/config/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ module.exports = {
key: null,
region: null,
headless: null,
isolation: false,
};

module.exports.configPaths = [".hermione.conf.ts", ".hermione.conf.js"];
1 change: 1 addition & 0 deletions src/constants/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const MIN_CHROME_VERSION_SUPPORT_ISOLATION = 93;
7 changes: 7 additions & 0 deletions src/utils/browser.ts
Original file line number Diff line number Diff line change
@@ -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;
};
162 changes: 160 additions & 2 deletions test/src/browser/existing-browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 [email protected]",
);
});

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 [email protected]",
);
});
});

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 });
Expand Down
30 changes: 29 additions & 1 deletion test/src/browser/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function createBrowserConfig_(opts = {}) {
region: null,
headless: null,
saveHistory: true,
isolation: false,
});

return {
Expand Down Expand Up @@ -81,8 +82,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;
Expand All @@ -100,3 +104,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",
});
4 changes: 2 additions & 2 deletions test/src/config/browser-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
24 changes: 24 additions & 0 deletions test/src/utils/browser.ts
Original file line number Diff line number Diff line change
@@ -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`));
});
});
});

0 comments on commit 3cf72f5

Please sign in to comment.