tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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:
M.gitlab-ci.yml | 4++++
A.gitlab/ci/containers/base/Containerfile | 23+++++++++++++++++++++++
A.gitlab/ci/jobs/startup-test/startup-test-android.py | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A.gitlab/ci/jobs/startup-test/startup-test.py | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A.gitlab/ci/jobs/startup-test/startup-test.yml | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A.gitlab/ci/jobs/update-containers.yml | 12++++++++++++
M.gitlab/ci/jobs/update-translations.yml | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtesting/mozbase/mozinstall/mozinstall/mozinstall.py | 3++-
Mtesting/mozbase/setup_development.py | 13+++++++++----
Mtesting/mozharness/scripts/does_it_crash.py | 7+++++++
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: