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 #792

Merged
merged 2 commits 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 @@ -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`, but `true` for chrome@93 and higher.

#### desiredCapabilities
**Required.** Used WebDriver [DesiredCapabilities](https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities). For example,
Expand Down Expand Up @@ -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`, but `true` for chrome@93 and higher.

### 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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

В sessionCaps хранится результат ответа браузера. Т.е. он содержит реальные версии браузеров.

version использую в случае если пользователь запускает jsonwp браузер.

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) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Переключение между окнами нужно только в webdriver протоколе. В devtools с этим проблем нет.

const windowIds = await this._session.getWindowHandles();
const incognitoWindowId = windowIds.find(id => id.includes(page.target()._targetId));
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Тут приходится использовать приватное свойство _targetId. Очень странно, что оно не публичное. Мне оно нужно чтобы получить id нового открытого окна в который необходимо переключится. Изначально пытался переключаться в последнее окно из результата getWindowHandles, но оказалось, что есть кейсы когда последнее окно не является только что созданным. Поэтому нашел только такой вариант.

Еще id не матчатся один к одному. В webdriver они называются CDwindow_12345, а в cdp просто 12345. Issue про сделать данное поле публичным заведу в puppeteer и опишу свой кейс использования.

Версия wdio у нас четко зафиксирована поэтому кейсов когда это может сломаться на данный момент нет.


await this._session.switchToWindow(incognitoWindowId);
}

for (const ctx of browserCtxs) {
if (ctx.isIncognito()) {
await ctx.close();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Инкогнито контексты можно сразу закрывать без необходимости явно закрывать все страницы.

continue;
}

for (const page of await ctx.pages()) {
await page.close();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Не инкогнито контексты закрыть невозможно. В данном случае не инкогнито контекстом является дефолтный контекст, который запускается при запуске браузера. Его закрыть нельзя, можно закрыть только страницы.

}
}
}

async _prepareSession() {
await this._setOrientation(this.config.orientation);
await this._setWindowSize(this.config.windowSize);
Expand Down
17 changes: 17 additions & 0 deletions src/config/browser-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -311,5 +312,21 @@ function buildBrowserOptions(defaultFactory, extra) {
key: options.optionalString("key"),
region: options.optionalString("region"),
headless: options.optionalBoolean("headless"),

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;
},
}),
});
}
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 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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Не очень люблю создавать сущности в beforeEach, но иначе некоторые тесты получались довольно жирные.


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 () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А как насчёт других браузеров, поддерживающих cdp? Типа Firefox/webkit

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Так как коммент оставлен к тесту, то не вижу необходимости проверять другие варианты browserName.

Если вопрос был в целом, то webkit вообще не поддерживает CDP. А firefox имеет поддержку только в nightly сборке. Поэтому их поддерживать не стал.

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 @@ -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",
});
Loading
Loading