Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ability to run tests in isolated environment #799

Merged
merged 1 commit into from
Oct 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
29 changes: 28 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,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;
Expand All @@ -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",
});
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`));
});
});
});
Loading