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: auto ubuntu packages download for local browsers #1036

Merged
merged 13 commits into from
Dec 17, 2024

Conversation

KuznetsovRoman
Copy link
Member

@KuznetsovRoman KuznetsovRoman commented Nov 29, 2024

CI side (collect-ubuntu-browser-dependencies, is not being built):

  • Download browsers, if necessary
  • Resolve direct shared objects dependencies to apt package names per each ubuntu release
  • Save direct dependencies into "src/browser-installer/ubuntu-packages/autogenerated" (which is being packed)
  • Also saves its cache into "collect-ubuntu-browser-dependencies" in order to speed up next launches

Cache is stored in VCS and is splitted to 2 files:

  • Mutual cache. Ubuntu version independent (processed-browsers-linux.json). Removes the need to download browsers each time
  • Release specific cache. Could be different on different ubuntu versions (shared-objects-map-ubuntu-%.json). Removes the need to run "apt-file search" each time

image

Client side (src/browser-installer/ubuntu-packages, being packed):

  • Resolve direct dependencies to full list of apt packages
  • Download and extract them
  • Set LD_LIBRARY_PATH when launching webdriver so it could populate to browser, so browser would use downloaded packages

@@ -8,7 +8,6 @@ export default {
79: 707231,
80: 722374,
81: 737198,
82: 750023,
Copy link
Member Author

Choose a reason for hiding this comment

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

Removed as API is not responding to request "which version of chromedriver should i use with this version of chrome": https://chromedriver.storage.googleapis.com/LATEST_RELEASE_82

name: Collect ubuntu browser dependencies
on:
schedule:
- cron: 0 0 1 * *
Copy link
Member Author

Choose a reason for hiding this comment

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

once per month

package.json Outdated
@@ -9,10 +9,11 @@
],
"scripts": {
"build": "tsc --build && npm run copy-static && npm run build-bundles",
"copy-static": "copyfiles 'src/browser/client-scripts/*' build",
"copy-static": "copyfiles 'src/browser/client-scripts/*' 'src/**/*.json' build",
Copy link
Member Author

Choose a reason for hiding this comment

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

Also copy these autogenerated json

Copy link
Member

Choose a reason for hiding this comment

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

looks dangerous because it can be any json file, for example, stub for unit-tests

Copy link
Member Author

Choose a reason for hiding this comment

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

Restricted to "src//[!cache]*/autogenerated//*.json"
So, jsons need to be in "autogenerated" directory

const [chromeDriverPath] = await Promise.all([
installChromeDriver(chromeVersion),
installChrome(chromeVersion),
shouldInstallUbuntuPackageDependencies ? installUbuntuPackageDependencies() : null,
Copy link
Member Author

Choose a reason for hiding this comment

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

Ensure ubuntu packages are ready before spawning child process

return await Promise.all([
installChrome(browserVersion, { force }),
shouldInstallWebDriver && installChromeDriver(browserVersion, { force }),
needToInstallUbuntuPackages && installUbuntuPackageDependencies(),
Copy link
Member Author

Choose a reason for hiding this comment

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

Ensure ubuntu package are ready (e.g. for devtools)

Comment on lines 84 to 85
await fs.outputJSON(this._sharedObjectsMapPath, sortObject(sharedObjectsMap), { spaces: 4 });
await fs.outputJSON(this._processedBrowsersCachePath, sortObject(processedBrowsers), { spaces: 4 });
Copy link
Member Author

Choose a reason for hiding this comment

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

Using 4 spaces because the linter wants it

Comment on lines 4 to 5
// Those are couldn't be seen with readelf -d
export const EXTRA_FIREFOX_SHARED_OBJECTS = ["libdbus-glib-1.so.2", "libXt.so.6"];
Copy link
Member Author

Choose a reason for hiding this comment

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

Usually there is a note in dynamic section of an elf file "which libraries does it load", but for a long time, starting from the very first versions, firefox needs these 2 shared objects, and they are not recorded in dynamic section of an elf file


logger.log(`Fetched ${browserVersions.length} browser milestones`);

const browsersToDownload = cache.filterProcessedBrowsers(browserVersions);
Copy link
Member Author

Choose a reason for hiding this comment

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

Browsers are only downloaded once
If we would like to add new ubuntu release, we would not need to download any browser

return "libc6";
}

const relevantPackageName = _.minBy(packages, packageName => calcLevenshtein(sharedObject, packageName)) as string;
Copy link
Member Author

Choose a reason for hiding this comment

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

Using levenshtein in order to resolve shared objects to their main packages, and not just to some package, which is more popular (in example, if libnss3.so is used in firefox, it would return "libnss3" even if firefox is more popular)

Copy link
Member Author

Choose a reason for hiding this comment

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

Directory with ubuntu binaries call wrappers

@@ -0,0 +1,43 @@
name: Collect ubuntu browser dependencies
Copy link
Member Author

Choose a reason for hiding this comment

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

Scheduled action:

  • Collects ubuntu dependencies for new browser versions
  • Creates a PR to add dependency if there is any new library
  • If there are no extra dependencies, do nothing.
  • If there is extra browser, but no new dependencies, creates PR to update cache, so it would not need to download the browser later

@KuznetsovRoman KuznetsovRoman force-pushed the TESTPLANE-352.ubuntu_deps branch 4 times, most recently from dbacdc9 to 6f9aeaf Compare December 1, 2024 20:59
.github/workflows/collect-deps.yml Outdated Show resolved Hide resolved
.github/workflows/collect-deps.yml Outdated Show resolved Hide resolved
.github/workflows/collect-deps.yml Outdated Show resolved Hide resolved
package.json Outdated
@@ -9,10 +9,11 @@
],
"scripts": {
"build": "tsc --build && npm run copy-static && npm run build-bundles",
"copy-static": "copyfiles 'src/browser/client-scripts/*' build",
"copy-static": "copyfiles 'src/browser/client-scripts/*' 'src/**/*.json' build",
Copy link
Member

Choose a reason for hiding this comment

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

looks dangerous because it can be any json file, for example, stub for unit-tests

package.json Outdated Show resolved Hide resolved
async function main(): Promise<void> {
const ubuntuMilestone = await getUbuntuMilestone();

logger.log(`Detected ubuntu release: "${ubuntuMilestone}"`);
Copy link
Member

Choose a reason for hiding this comment

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

can't it be undefined/null here?

Copy link
Member Author

@KuznetsovRoman KuznetsovRoman Dec 4, 2024

Choose a reason for hiding this comment

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

It relies on VERSION_ID in /etc/os-release
If it is ubuntu, VERSION_ID exists and defined
getUbuntuMilestone implies it is ubuntu
So, no

import { getUbuntuMilestone, writeUbuntuPackageDependencies } from "../browser-installer/ubuntu-packages";
import logger from "../utils/logger";

const createResolveSharedObjectToPackageName =
Copy link
Member

Choose a reason for hiding this comment

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

so create or resolve? mb, createSharedObjectToPackageNameResolver?

Copy link
Member Author

Choose a reason for hiding this comment

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

createSharedObjectToPackageNameResolver

Resolver is a noun. We could call what is returned as Resolver only if its object with some method, but its function

This function creates function "resolveSharedObjectToPackageName", so it is called "createResolve..."

};

export const getBinarySharedObjectDependencies = async (binaryPath: string): Promise<string[]> => {
const sharedObjectRegExp = /^\s*\dx\d+\s\(NEEDED\)\s*Shared library: \[(.*)\]/gm;
Copy link
Member

Choose a reason for hiding this comment

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

please, add an example for readElf to understand what is going on here

Copy link
Member Author

Choose a reason for hiding this comment

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

In code section?
There is in test section

while (regExpResult && regExpResult[1]) {
sharedObjectDependencies.push(regExpResult[1]);

regExpResult = sharedObjectRegExp.exec(readElfResult);
Copy link
Member

Choose a reason for hiding this comment

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

how does it work? why does the second call return a different value?

Copy link
Member Author

@KuznetsovRoman KuznetsovRoman Dec 4, 2024

Choose a reason for hiding this comment

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

Works just like it does in C (where you dont expect magic like this)

Because of the "g" flag, regular expression state is saved (in C it was saved in the global variable, i assume in js there is some kind of closure)

describe("getBinarySharedObjectDependencies", () => {
it("should return binary direct shared object deps", async () => {
readElfStub.resolves(`
Dynamic section at offset 0xb00 contains 26 entries:
Copy link
Member

Choose a reason for hiding this comment

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

o_O

@KuznetsovRoman KuznetsovRoman force-pushed the TESTPLANE-352.ubuntu_deps branch from 642e41f to 4c3fd27 Compare December 6, 2024 02:23
Comment on lines 18 to 23

const [chromeDriverPath] = await Promise.all([
const [chromeDriverPath, randomPort, chromeDriverEnv] = await Promise.all([
installChromeDriver(chromeVersion),
installChrome(chromeVersion),
shouldInstallUbuntuPackageDependencies ? installUbuntuPackageDependencies() : null,
getPort(),
isUbuntu()
.then(isUbuntu => (isUbuntu ? getUbuntuLinkerEnv() : null))
.then(extraEnv => (extraEnv ? { ...process.env, ...extraEnv } : process.env)),
]);
Copy link
Member Author

Choose a reason for hiding this comment

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

Now we dont install browser and ubuntu packages here, and instead we assume it is already installed by "browser-installer/run.ts", which runs "browser-installer/install.ts" before calling "run*Driver"

Comment on lines 5 to +12
export const runBrowserDriver = async (
driverName: SupportedDriver,
browserName: SupportedBrowser,
browserVersion: string,
{ debug = false } = {},
): Promise<{ gridUrl: string; process: ChildProcess; port: number }> => {
switch (driverName) {
case Driver.CHROMEDRIVER:
const installBrowserOpts = { shouldInstallWebDriver: true, shouldInstallUbuntuPackages: true };

await installBrowser(browserName, browserVersion, installBrowserOpts);
Copy link
Member Author

Choose a reason for hiding this comment

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

As now we need to install browser before running its driver, we expect "browserName" instead of "driverName" in order to pass "browserName" to "installBrowser"

export const getDriverNameForBrowserName = (browserName: SupportedBrowser): SupportedDriver | null => {
if (browserName === Browser.CHROME || browserName === Browser.CHROMIUM) {
return Driver.CHROMEDRIVER;
export const getNormalizedBrowserName = (
Copy link
Member Author

Choose a reason for hiding this comment

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

As now we dont map "browserName" to "driverName" for "runBrowserDriver", we dont need "getDriverNameForBrowserName", and instead i created this function, so in "installBrowser" we can be sure it only receives valid "browserName" of SupportedBrowser type

Copy link
Member Author

Choose a reason for hiding this comment

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

This way we can handle cases with invalid browser name outside of "install", so we know, what exactly user wanted to do, and we can provide relevant error message:

  • invalid browser name was used in order to run local browser
  • invalid browser name was used in order to download a browser

Comment on lines 19 to 27
async getWebdriver(
browserName: SupportedBrowser,
browserVersion: string,
browserName?: string,
browserVersion?: string,
{ debug = false } = {},
): ReturnType<typeof this.createWebdriverProcess> {
const driverName = getDriverNameForBrowserName(browserName);
const browserNameNormalized = getNormalizedBrowserName(browserName);

if (!driverName) {
if (!browserNameNormalized) {
throw new Error(
Copy link
Member Author

Choose a reason for hiding this comment

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

As we now dont want to map "browserName" to "driverName", we still need to check if "browserName" is valid (because this method is called inside NewBrowser with its config's browserName, which is user provided value, which can be invalid)

Comment on lines 240 to +241
const executablePath = await installBrowser(
this._config.desiredCapabilities?.browserName as SupportedBrowser,
this._config.desiredCapabilities?.browserVersion as string,
normalizedBrowserName,
Copy link
Member Author

Choose a reason for hiding this comment

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

"installBrowser" now only wants normalized browser name (so we could pull out "invalid browser name" handling in order to throw relevant errors)

this._config.desiredCapabilities?.browserVersion as string,
normalizedBrowserName,
this._config.desiredCapabilities?.browserVersion,
{ shouldInstallWebDriver: false, shouldInstallUbuntuPackages: true },
Copy link
Member Author

Choose a reason for hiding this comment

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

Explicitly setting "should install" options, as they are all set to "false" now by default.

In "_addExecutablePath" we dont need to install webdriver as user might want to run browser in "devtools" protocol

@KuznetsovRoman KuznetsovRoman force-pushed the TESTPLANE-352.ubuntu_deps branch 2 times, most recently from 907c990 to 8c6d6c6 Compare December 9, 2024 23:51
Comment on lines +19 to +22
const downloadProgressCallback: DownloadProgressCallback = (done, total = 100) => {
if (!bar) {
const totalMB = Math.round((totalBytes / BYTES_PER_MEGABYTE) * 100) / 100;
bar = progressBar.create(totalMB, 0, { filename: `${browserName}@${browserVersion}` });
bar = progressBar.create(100, 0, { filename: `${browserName}@${browserVersion}` });
Copy link
Member Author

Choose a reason for hiding this comment

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

Now use percents as more versatile progress bar unit.
This way we can also use the same progress bar for ubuntu packages download

@@ -129,13 +142,42 @@ const getCacheDir = (envValueOverride = process.env.TESTPLANE_BROWSERS_PATH): st
export const getRegistryPath = (envValueOverride?: string): string =>
path.join(getCacheDir(envValueOverride), "registry.json");

export const readRegistry = (registryPath: string): Registry => {
Copy link
Member Author

Choose a reason for hiding this comment

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

I decided to extend registry so it would store not only binary paths, but could store anything else
This way we can manage os packages by this registry file too.

@KuznetsovRoman KuznetsovRoman force-pushed the TESTPLANE-352.ubuntu_deps branch from 8c6d6c6 to bd17127 Compare December 10, 2024 10:54
src/browser-installer/install.ts Outdated Show resolved Hide resolved
return Driver.CHROMEDRIVER;
export const getNormalizedBrowserName = (
browserName?: string,
): Exclude<SupportedBrowser, typeof Browser.CHROMIUM> | null => {
Copy link
Member

Choose a reason for hiding this comment

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

why do we need exclude here?

Copy link
Member Author

@KuznetsovRoman KuznetsovRoman Dec 16, 2024

Choose a reason for hiding this comment

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

We dont need to, i just wanted to show "chromium can't be returned here", because chromium maps to chrome, as installing "chrome" and "chromium" has a single entry point, being "chrome", as "chromium" is not a valid W3C browserName

test/src/browser-installer/run.ts Outdated Show resolved Hide resolved
src/browser-installer/install.ts Outdated Show resolved Hide resolved
src/browser-installer/utils.ts Outdated Show resolved Hide resolved
src/browser-installer/registry/index.ts Outdated Show resolved Hide resolved
@KuznetsovRoman KuznetsovRoman force-pushed the TESTPLANE-352.ubuntu_deps branch 2 times, most recently from 27c49dd to e43d792 Compare December 17, 2024 00:35
@KuznetsovRoman KuznetsovRoman force-pushed the TESTPLANE-352.ubuntu_deps branch from e43d792 to bde19e0 Compare December 17, 2024 00:38
@KuznetsovRoman KuznetsovRoman merged commit 25895ea into master Dec 17, 2024
2 checks passed
@KuznetsovRoman KuznetsovRoman deleted the TESTPLANE-352.ubuntu_deps branch December 17, 2024 01:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants