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 10, 2023
1 parent 83e33e0 commit 1c0d736
Show file tree
Hide file tree
Showing 9 changed files with 244 additions and 5 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,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 +1106,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,8 @@ 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 { CHROME_VERSION_SUPPORT_ISOLATION } = require("../constants/browser");

const OPTIONAL_SESSION_OPTS = ["transformRequest", "transformResponse"];

Expand All @@ -38,6 +40,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 +205,48 @@ 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;
const browserVersionMajor = (browserVersion || version).split(".")[0];

if (browserName !== "chrome" || browserVersionMajor < CHROME_VERSION_SUPPORT_ISOLATION) {
logger.warn(
`WARN: test isolation works only with chrome@${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 @@ -311,5 +311,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 @@ -90,6 +90,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/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export interface CommonConfig {

system: SystemConfig;
headless: boolean | null;
isolation: boolean;
}

export interface SetsConfig {
Expand Down
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 CHROME_VERSION_SUPPORT_ISOLATION = 93;
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 { 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@${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@${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@${CHROME_VERSION_SUPPORT_ISOLATION} and higher, ` +
"but got [email protected]",
);
});
});

it("should create incognito browser context", async () => {
const sessionCaps = { browserName: "chrome", version: "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", version: "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", version: "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", version: "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", version: "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", version: "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", version: "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 @@ -38,6 +38,7 @@ function createBrowserConfig_(opts = {}) {
region: null,
headless: null,
saveHistory: true,
isolation: false,
});

return {
Expand Down Expand Up @@ -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;
Expand All @@ -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",
});
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

0 comments on commit 1c0d736

Please sign in to comment.