commit 45f025635d2f92ace7d7d43728ff1158bd8d8fee
parent 5462bb7879189fe38694246590bf9613c85050e3
Author: Beatriz Rizental <beatriz.rizental@gmail.com>
Date: Wed, 19 Jun 2024 09:58:56 +0200
Add CI for Tor Browser
Diffstat:
10 files changed, 516 insertions(+), 6 deletions(-)
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
@@ -1,6 +1,8 @@
stages:
+ - update-container-images
- lint
- test
+ - startup-test
- update-translations
variables:
@@ -11,4 +13,6 @@ include:
- local: '.gitlab/ci/mixins.yml'
- local: '.gitlab/ci/jobs/lint/lint.yml'
- local: '.gitlab/ci/jobs/test/python-test.yml'
+ - local: '.gitlab/ci/jobs/startup-test/startup-test.yml'
+ - local: '.gitlab/ci/jobs/update-containers.yml'
- local: '.gitlab/ci/jobs/update-translations.yml'
diff --git a/.gitlab/ci/containers/base/Containerfile b/.gitlab/ci/containers/base/Containerfile
@@ -0,0 +1,23 @@
+# This image is published in containers.torproject.org/tpo/applications/tor-browser/base
+#
+# Whenever there are changes to this file,
+# they are autopublished on merge to the tpo/applications/tor-browser repository.
+#
+# The image is updated roughly once a month when the tor-browser repository is rebased.
+
+FROM containers.torproject.org/tpo/tpa/base-images/python:trixie
+
+RUN apt-get update && apt-get install -y \
+ git \
+ xvfb
+
+RUN git clone --single-branch --depth 1 https://gitlab.torproject.org/tpo/applications/tor-browser.git
+
+# Bootstrap will download and install all dependencies required for building / linting / etc.
+RUN cd tor-browser && \
+ yes | MOZBUILD_STATE_PATH=/var/tmp/mozbuild ./mach bootstrap --application-choice "Tor Browser for Desktop" && \
+ cd ..
+
+RUN rm -rf tor-browser && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/*
diff --git a/.gitlab/ci/jobs/startup-test/startup-test-android.py b/.gitlab/ci/jobs/startup-test/startup-test-android.py
@@ -0,0 +1,231 @@
+#!/usr/bin/env python3
+import argparse
+import json
+import os
+import sys
+import time
+from datetime import datetime, timedelta
+from enum import Enum
+
+import requests
+
+"""
+This script runs Android tests on BrowserStack using the BrowserStack App Automate Espresso API.
+
+Usage:
+ startup-test-android.py --devices <devices> [--tests <tests>] [--app_file_path <app_file_path>] [--test_file_path <test_file_path>]
+
+Arguments:
+ --devices: Comma-separated list of devices to test on (required).
+ --tests: Comma-separated list of tests to run (optional). If not provided, all tests will run.
+ --app_file_path: Path to the app file (optional). If not provided, yesterday's nightly will be downloaded.
+ --test_file_path: Path to the test file (optional). If not provided, yesterday's nightly will be downloaded.
+
+Environment Variables:
+ BROWSERSTACK_USERNAME: BrowserStack username (required).
+ BROWSERSTACK_API_KEY: BrowserStack API key (required).
+
+Description:
+ - If app and test file paths are not provided, the script downloads the latest nightly build from the Tor Project.
+ - Uploads the app and test files to BrowserStack.
+ - Triggers the test run on the specified devices.
+ - Polls for the test status until completion or timeout.
+ - Prints the test results and exits with an appropriate status code.
+"""
+
+parser = argparse.ArgumentParser(
+ description="Run Android startup tests on BrowserStack."
+)
+parser.add_argument(
+ "--devices",
+ type=str,
+ help="Comma-separated list of devices to test on",
+ required=True,
+)
+parser.add_argument("--tests", type=str, help="Comma-separated list of tests to run")
+parser.add_argument("--app_file_path", type=str, help="Path to the app file")
+parser.add_argument("--test_file_path", type=str, help="Path to the test file")
+
+args = parser.parse_args()
+
+if args.app_file_path:
+ app_file_path = args.app_file_path
+ test_file_path = args.test_file_path
+ if not test_file_path:
+ print(
+ "\033[1;31mIf either app or test file paths are provided, both must be provided.\033[0m"
+ )
+else:
+
+ def download_file(url, dest_path):
+ try:
+ response = requests.get(url, stream=True)
+ response.raise_for_status()
+ with open(dest_path, "wb") as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ f.write(chunk)
+ except Exception as e:
+ print(f"\033[1;31mFailed to download file from {url}.\033[0m")
+ print(e)
+ sys.exit(1)
+
+ yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y.%m.%d")
+ download_url_base = f"https://nightlies.tbb.torproject.org/nightly-builds/tor-browser-builds/tbb-nightly.{yesterday}/nightly-android-aarch64"
+ print(
+ f"No file paths provided, downloading yesterday's nightly from {download_url_base}"
+ )
+
+ app_file_url = f"{download_url_base}/tor-browser-noopt-android-aarch64-tbb-nightly.{yesterday}.apk"
+ test_file_url = (
+ f"{download_url_base}/tor-browser-tbb-nightly.{yesterday}-androidTest.apk"
+ )
+
+ # BrowserStack will fail if there are `.` in the file name other than before the extension.
+ yesterday = yesterday.replace(".", "-")
+ app_file_path = f"/tmp/nightly-{yesterday}.apk"
+ test_file_path = f"/tmp/nightly-test-{yesterday}.apk"
+
+ download_file(app_file_url, app_file_path)
+ download_file(test_file_url, test_file_path)
+
+devices = [device.strip() for device in args.devices.split(",")]
+tests = args.tests.split(",") if args.tests else []
+
+browserstack_username = os.getenv("BROWSERSTACK_USERNAME")
+browserstack_api_key = os.getenv("BROWSERSTACK_API_KEY")
+if not browserstack_username or not browserstack_api_key:
+ print(
+ "\033[1;31mEnvironment variables BROWSERSTACK_USERNAME and BROWSERSTACK_API_KEY must be set.\033[0m"
+ )
+ sys.exit(1)
+
+# Upload app file
+with open(app_file_path, "rb") as app_file:
+ response = requests.post(
+ "https://api-cloud.browserstack.com/app-automate/espresso/v2/app",
+ auth=(browserstack_username, browserstack_api_key),
+ files={"file": app_file},
+ )
+
+if response.status_code != 200:
+ print("\033[1;31mFailed to upload app file.\033[0m")
+ print(response.text)
+ sys.exit(1)
+
+bs_app_url = response.json().get("app_url")
+print("\033[1;32mSuccessfully uploaded app file.\033[0m")
+print(f"App URL: {bs_app_url}")
+
+# Upload test file
+with open(test_file_path, "rb") as test_file:
+ response = requests.post(
+ "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite",
+ auth=(browserstack_username, browserstack_api_key),
+ files={"file": test_file},
+ )
+
+if response.status_code != 200:
+ print("\033[1;31mFailed to upload test file.\033[0m")
+ print(response.text)
+ sys.exit(1)
+
+bs_test_url = response.json().get("test_suite_url")
+print("\033[1;32mSuccessfully uploaded test file.\033[0m")
+print(f"Test URL: {bs_test_url}")
+
+# Trigger tests
+test_params = {
+ "app": bs_app_url,
+ "testSuite": bs_test_url,
+ "devices": devices,
+ "class": tests,
+}
+
+response = requests.post(
+ "https://api-cloud.browserstack.com/app-automate/espresso/v2/build",
+ auth=(browserstack_username, browserstack_api_key),
+ headers={"Content-Type": "application/json"},
+ data=json.dumps(test_params),
+)
+
+if response.status_code != 200:
+ print("\033[1;31mFailed to trigger test run.\033[0m")
+ print(response.text)
+ sys.exit(1)
+
+build_id = response.json().get("build_id")
+print("\033[1;32mSuccessfully triggered test run.\033[0m")
+print(
+ f"Test status also available at: https://app-automate.browserstack.com/builds/{build_id}\n==="
+)
+
+# Poll for status
+POLLING_TIMEOUT = 30 * 60 # 30min
+POLLING_INTERVAL = 30 # 30s
+
+
+class TestStatus(Enum):
+ QUEUED = "queued"
+ RUNNING = "running"
+ ERROR = "error"
+ FAILED = "failed"
+ PASSED = "passed"
+ TIMED_OUT = "timed out"
+ SKIPPED = "skipped"
+
+ @classmethod
+ def from_string(cls, s):
+ try:
+ return cls[s.upper().replace(" ", "_")]
+ except KeyError:
+ raise ValueError(f"\033[1;31m'{s}' is not a valid test status.\033[0m")
+
+ def is_terminal(self):
+ return self not in {TestStatus.QUEUED, TestStatus.RUNNING}
+
+ def is_success(self):
+ return self in {TestStatus.PASSED, TestStatus.SKIPPED}
+
+ def color_print(self):
+ if self == TestStatus.PASSED:
+ return f"\033[1;32m{self.value}\033[0m"
+
+ if self in {TestStatus.ERROR, TestStatus.FAILED, TestStatus.TIMED_OUT}:
+ return f"\033[1;31m{self.value}\033[0m"
+
+ if self == TestStatus.SKIPPED:
+ return f"\033[1;33m{self.value}\033[0m"
+
+ return self.value
+
+
+start_time = time.time()
+elapsed_time = 0
+test_status = None
+while elapsed_time <= POLLING_TIMEOUT:
+ response = requests.get(
+ f"https://api-cloud.browserstack.com/app-automate/espresso/v2/builds/{build_id}",
+ auth=(browserstack_username, browserstack_api_key),
+ )
+
+ if response.status_code != 200:
+ print("\033[1;31mFailed to get test status.\033[0m")
+ print(response.text)
+ sys.exit(1)
+
+ test_status = TestStatus.from_string(response.json().get("status"))
+ if test_status.is_terminal():
+ print(f"===\nTest finished. Result: {test_status.color_print()}")
+ break
+ else:
+ elapsed_time = time.time() - start_time
+ print(f"Test status: {test_status.value} ({elapsed_time:.2f}s)")
+
+ if elapsed_time > POLLING_TIMEOUT:
+ print("===\n\033[1;33mWaited for tests for too long.\033[0m")
+ break
+
+ time.sleep(POLLING_INTERVAL)
+
+if test_status is None or not test_status.is_success():
+ sys.exit(1)
diff --git a/.gitlab/ci/jobs/startup-test/startup-test.py b/.gitlab/ci/jobs/startup-test/startup-test.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+
+import argparse
+import subprocess
+from datetime import datetime, timedelta
+
+PLATFORM_TO_ARCH = {
+ "linux": ["x86_64", "i686"],
+ "macos": ["x86_64", "aarch64"],
+ "windows": ["x86_64", "i686"],
+}
+
+
+class DynamicArchAction(argparse.Action):
+ def __call__(self, parser, namespace, values, option_string=None):
+ platform = getattr(namespace, "platform", None)
+ if not platform:
+ raise argparse.ArgumentError(
+ self, "The --platform argument must be provided before --arch."
+ )
+
+ valid_archs = PLATFORM_TO_ARCH.get(platform, [])
+ if values not in valid_archs:
+ raise argparse.ArgumentError(
+ self,
+ f"Invalid architecture '{values}' for platform '{platform}'. "
+ f"Valid options are: {', '.join(valid_archs)}",
+ )
+ setattr(namespace, self.dest, values)
+
+
+parser = argparse.ArgumentParser(
+ description="Downloads and executes yesterday's build of Tor or Mullvad browser nightly."
+)
+
+parser.add_argument(
+ "--platform",
+ required=True,
+ help="Specify the platform (linux, macos or windows). Must be provided before --arch.",
+ choices=PLATFORM_TO_ARCH.keys(),
+)
+parser.add_argument(
+ "--arch",
+ required=True,
+ help="Specify the architecture (validated dynamically based on --platform).",
+ action=DynamicArchAction,
+)
+parser.add_argument(
+ "--browser",
+ required=True,
+ choices=["tor", "mullvad"],
+ help="Specify the browser (tor or mullvad)",
+)
+
+args = parser.parse_args()
+arch = f"-{args.arch}"
+extra = ""
+
+if args.platform == "linux":
+ archive_extension = "tar.xz"
+ binary = f"Browser/start-{args.browser}-browser"
+elif args.platform == "macos":
+ archive_extension = "dmg"
+ # The URL doesn't include the architecture for MacOS,
+ # because it's a universal build.
+ arch = ""
+ if args.browser == "tor":
+ binary = "Contents/MacOS/firefox"
+ else:
+ binary = "Contents/MacOS/mullvadbrowser"
+elif args.platform == "windows":
+ archive_extension = "exe"
+
+ if args.browser == "tor":
+ extra = "-portable"
+ binary = "Browser/firefox.exe"
+ else:
+ binary = "mullvadbrowser.exe"
+
+yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y.%m.%d")
+
+download_url_base = (
+ "https://nightlies.tbb.torproject.org/nightly-builds/tor-browser-builds"
+)
+if args.browser == "tor":
+ download_url = f"{download_url_base}/tbb-nightly.{yesterday}/nightly-{args.platform}{arch}/{args.browser}-browser-{args.platform}{arch}{extra}-tbb-nightly.{yesterday}.{archive_extension}"
+else:
+ download_url = f"{download_url_base}/tbb-nightly.{yesterday}/mullvadbrowser-nightly-{args.platform}{arch}/{args.browser}-browser-{args.platform}{arch}-tbb-nightly.{yesterday}.{archive_extension}"
+
+subprocess.run(
+ [
+ "python3",
+ "testing/mozharness/scripts/does_it_crash.py",
+ "--run-for",
+ "30",
+ "--thing-url",
+ download_url,
+ "--thing-to-run",
+ binary,
+ ]
+)
diff --git a/.gitlab/ci/jobs/startup-test/startup-test.yml b/.gitlab/ci/jobs/startup-test/startup-test.yml
@@ -0,0 +1,63 @@
+# startup-test-windows:
+# extends: .with-local-repo-pwsh
+# variables:
+# LOCAL_REPO_PATH: "C:\\Users\\windoes\\tor-browser.git"
+# stage: startup-test
+# interruptible: true
+# parallel:
+# matrix:
+# - BROWSER: ["tor", "mullvad"]
+# tags:
+# - x86-win11
+# script:
+# - ./mach python testing/mozbase/setup_development.py
+# - ./mach python .gitlab/ci/jobs/startup-test/startup-test.py --platform windows --arch x86_64 --browser $BROWSER
+# rules:
+# - if: $CI_PIPELINE_SOURCE == "schedule"
+
+# startup-test-macos:
+# extends: .with-local-repo-bash
+# variables:
+# LOCAL_REPO_PATH: "/Users/gitlab-runner/tor-browser.git"
+# stage: startup-test
+# interruptible: true
+# parallel:
+# matrix:
+# - BROWSER: ["tor", "mullvad"]
+# tags:
+# - x86-macos
+# script:
+# - ./mach python testing/mozbase/setup_development.py
+# - ./mach python .gitlab/ci/jobs/startup-test/startup-test.py --platform macos --arch x86_64 --browser $BROWSER
+# rules:
+# - if: $CI_PIPELINE_SOURCE == "schedule"
+
+startup-test-linux:
+ extends: .with-local-repo-bash
+ image: $IMAGE_PATH
+ stage: startup-test
+ interruptible: true
+ parallel:
+ matrix:
+ - BROWSER: ["tor", "mullvad"]
+ tags:
+ - firefox
+ script:
+ - Xvfb :99 -screen 0 1400x900x24 &
+ - export DISPLAY=:99
+ - ./mach python testing/mozbase/setup_development.py
+ - ./mach python .gitlab/ci/jobs/startup-test/startup-test.py --platform linux --arch x86_64 --browser $BROWSER
+ rules:
+ - if: $CI_PIPELINE_SOURCE == "schedule"
+
+startup-test-android:
+ extends: .with-local-repo-bash
+ image: $IMAGE_PATH
+ stage: startup-test
+ interruptible: true
+ tags:
+ - firefox
+ script:
+ - ./mach python .gitlab/ci/jobs/startup-test/startup-test-android.py --devices "Samsung Galaxy S23-13.0, Samsung Galaxy S8-7.0" --tests org.mozilla.fenix.LaunchTest
+ rules:
+ - if: $CI_PIPELINE_SOURCE == "schedule"
diff --git a/.gitlab/ci/jobs/update-containers.yml b/.gitlab/ci/jobs/update-containers.yml
@@ -0,0 +1,12 @@
+build-base-image:
+ stage: update-container-images
+ interruptible: true
+ image: containers.torproject.org/tpo/tpa/base-images/podman:bookworm
+ script:
+ - export TAG="${CI_REGISTRY_IMAGE}/base:latest"
+ - podman login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
+ - podman build --layers=false $IMAGE -t ${TAG} -f .gitlab/ci/containers/base/Containerfile .
+ - |
+ echo -e "\e[33mPushing new image to registry as ${TAG}\e[0m"
+ podman push ${TAG}
+ when: manual
diff --git a/.gitlab/ci/jobs/update-translations.yml b/.gitlab/ci/jobs/update-translations.yml
@@ -13,7 +13,70 @@
- if: ($TRANSLATION_FILES != "" && $FORCE_UPDATE_TRANSLATIONS == "true")
variables:
COMBINED_FILES_JSON: "combined-translation-files.json"
- TRANSLATION_FILES: ''
+ TRANSLATION_FILES: '[
+ {
+ "name": "brand.ftl",
+ "where": ["browser/branding/tb-release", "toolkit/torbutton"],
+ "branding": {
+ "versions": [
+ {
+ "name": "Alpha",
+ "suffix": "_alpha",
+ "where": ["browser/branding/tb-alpha"]
+ },
+ {
+ "name": "Nightly",
+ "suffix": "_nightly",
+ "where": ["browser/branding/tb-nightly"]
+ }
+ ],
+ "ids": [
+ "-brand-short-name",
+ "-brand-full-name"
+ ]
+ },
+ "branch": "tor-browser",
+ "directory": "branding"
+ },
+ {
+ "name": "brand.properties",
+ "where": ["browser/branding/tb-release", "toolkit/torbutton"],
+ "branding": {
+ "versions": [
+ {
+ "name": "Alpha",
+ "suffix": "_alpha",
+ "where": ["browser/branding/tb-alpha"]
+ },
+ {
+ "name": "Nightly",
+ "suffix": "_nightly",
+ "where": ["browser/branding/tb-nightly"]
+ }
+ ],
+ "ids": [
+ "brandShortName",
+ "brandFullName"
+ ]
+ },
+ "branch": "tor-browser"
+ },
+ { "name": "tor-browser.ftl", "branch": "tor-browser" },
+ { "name": "aboutTBUpdate.dtd", "branch": "tor-browser" },
+ { "name": "torbutton.dtd", "branch": "tor-browser" },
+ { "name": "onionLocation.properties", "branch": "tor-browser" },
+ { "name": "settings.properties", "branch": "tor-browser" },
+ { "name": "torbutton.properties", "branch": "tor-browser" },
+ { "name": "torConnect.properties", "branch": "tor-browser" },
+ { "name": "torlauncher.properties", "branch": "tor-browser" },
+ { "name": "base-browser.ftl", "branch": "base-browser" },
+ {
+ "name": "torbrowser_strings.xml",
+ "branch": "fenix-torbrowserstringsxml",
+ "exclude-legacy": true
+ }
+ ]'
+ TRANSLATION_INCLUDE_LEGACY: "true"
combine-en-US-translations:
diff --git a/testing/mozbase/mozinstall/mozinstall/mozinstall.py b/testing/mozbase/mozinstall/mozinstall/mozinstall.py
@@ -393,7 +393,8 @@ def _install_exe(src, dest):
# possibly gets around UAC in vista (still need to run as administrator)
os.environ["__compat_layer"] = "RunAsInvoker"
- cmd = '"%s" /extractdir=%s' % (src, os.path.realpath(dest))
+ cmd = '"%s" /S /D=%s' % (src, os.path.realpath(dest))
+ # cmd = '"%s" /extractdir=%s' % (src, os.path.realpath(dest))
subprocess.check_call(cmd)
diff --git a/testing/mozbase/setup_development.py b/testing/mozbase/setup_development.py
@@ -267,23 +267,28 @@ def main(args=sys.argv[1:]):
os.environ.get("PATH", "").strip(os.path.pathsep),
)
+ current_file_path = os.path.abspath(__file__)
+ topobjdir = os.path.dirname(os.path.dirname(os.path.dirname(current_file_path)))
+ mach = str(os.path.join(topobjdir, "mach"))
+
# install non-mozbase dependencies
# these need to be installed separately and the --no-deps flag
# subsequently used due to a bug in setuptools; see
# https://bugzilla.mozilla.org/show_bug.cgi?id=759836
pypi_deps = dict([(i, j) for i, j in alldeps.items() if i not in unrolled])
for package, version in pypi_deps.items():
- # easy_install should be available since we rely on setuptools
- call(["easy_install", version])
+ # Originally, Mozilla used easy_install here.
+ # That tool is deprecated, therefore we swich to pip.
+ call([sys.executable, mach, "python", "-m", "pip", "install", version])
# install packages required for unit testing
for package in test_packages:
- call(["easy_install", package])
+ call([sys.executable, mach, "python", "-m", "pip", "install", package])
# install extra non-mozbase packages if desired
if options.extra:
for package in extra_packages:
- call(["easy_install", package])
+ call([sys.executable, mach, "python", "-m", "pip", "install", package])
if __name__ == "__main__":
diff --git a/testing/mozharness/scripts/does_it_crash.py b/testing/mozharness/scripts/does_it_crash.py
@@ -111,6 +111,13 @@ class DoesItCrash(BaseScript):
for retry in range(3):
if is_win:
proc.send_signal(signal.CTRL_BREAK_EVENT)
+
+ # Manually kill all processes we spawned,
+ # not sure why this is required, but without it we hang forever.
+ process_name = self.config["thing_to_run"].split("/")[-1]
+ subprocess.run(
+ ["taskkill", "/T", "/F", "/IM", process_name], check=True
+ )
else:
os.killpg(proc.pid, signal.SIGKILL)
try: