| #!/usr/bin/env python3 | |
| # Copyright (C) 2021 The Android Open Source Project | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); | |
| # you may not use this file except in compliance with the License. | |
| # You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| # This script runs the Playwright tests for the UI. It can be run from the root | |
| # directory with `./ui/run-integrationtests` and accepts the same arguments as | |
| # `playwright test` (e.g. `./ui/run-integrationtests --workers=1 | |
| # src/test/wattson.test.ts`). | |
| # | |
| # It makes sure that the build is up to date before | |
| import argparse | |
| import os | |
| import shlex | |
| import subprocess | |
| import sys | |
| UI_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| REPO_ROOT = os.path.dirname(UI_DIR) | |
| # Custom Playwright Docker image name (built from ui/playwright/Dockerfile) | |
| PLAYWRIGHT_IMAGE = 'perfetto-playwright' | |
| PLAYWRIGHT_DOCKERFILE = os.path.join(UI_DIR, 'playwright', 'Dockerfile') | |
| def get_playwright_version(): | |
| """Get the installed Playwright version.""" | |
| result = subprocess.run( | |
| ['./pnpm', 'exec', 'playwright', '--version'], | |
| capture_output=True, | |
| text=True, | |
| cwd=UI_DIR | |
| ) | |
| if result.returncode != 0: | |
| raise RuntimeError(f'Failed to get Playwright version: {result.stderr}') | |
| version_line = result.stdout.strip() | |
| if not version_line.startswith('Version '): | |
| raise RuntimeError(f'Unexpected Playwright version output: {version_line}') | |
| return version_line[len('Version '):] | |
| def build_docker_image(version, use_sudo): | |
| """Build the custom Playwright Docker image with Mesa GL support.""" | |
| image_tag = f'{PLAYWRIGHT_IMAGE}:v{version}' | |
| print(f'Building Docker image: {image_tag}') | |
| build_cmd = [ | |
| 'docker', 'build', | |
| '--build-arg', f'PLAYWRIGHT_VERSION={version}', | |
| '-t', image_tag, | |
| os.path.dirname(PLAYWRIGHT_DOCKERFILE) | |
| ] | |
| if use_sudo: | |
| build_cmd = ['sudo'] + build_cmd | |
| result = subprocess.run(build_cmd) | |
| if result.returncode != 0: | |
| raise RuntimeError(f'Failed to build Docker image') | |
| return image_tag | |
| def is_docker_available(): | |
| """Check if docker is installed and accessible.""" | |
| try: | |
| result = subprocess.run( | |
| ['docker', '--version'], | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| timeout=5 | |
| ) | |
| return result.returncode == 0 | |
| except (subprocess.TimeoutExpired, FileNotFoundError): | |
| return False | |
| def needs_sudo_for_docker(): | |
| """Check if sudo is needed to run docker commands.""" | |
| try: | |
| result = subprocess.run( | |
| ['docker', 'info'], | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| timeout=5 | |
| ) | |
| return result.returncode != 0 | |
| except (subprocess.TimeoutExpired, FileNotFoundError): | |
| return True | |
| def is_docker_rootless(use_sudo): | |
| """Check whether the Docker daemon uses rootless user namespacing.""" | |
| cmd = ['docker', 'info', '--format', '{{json .SecurityOptions}}'] | |
| if use_sudo: | |
| cmd = ['sudo'] + cmd | |
| try: | |
| result = subprocess.run( | |
| cmd, | |
| capture_output=True, | |
| text=True, | |
| timeout=5, | |
| ) | |
| return result.returncode == 0 and 'name=rootless' in result.stdout | |
| except (subprocess.TimeoutExpired, FileNotFoundError): | |
| return False | |
| def run_build(args): | |
| """Run the build script to ensure we have something to test.""" | |
| build_args = [] | |
| if args.no_build: | |
| build_args += ['--no-build'] | |
| if args.no_depscheck: | |
| build_args += ['--no-depscheck'] | |
| return subprocess.call(['ui/build'] + build_args) | |
| def run_native(args): | |
| """Run tests directly on the host machine.""" | |
| print('Warning: Running without Docker. Results may vary across environments.') | |
| if args.rebaseline: | |
| print('Note: Rebaselining outside Docker may cause snapshot mismatches in CI.') | |
| print('') | |
| # Install the chromium through playwright | |
| subprocess.check_call(['./pnpm', 'exec', 'playwright', 'install', 'chromium'], cwd=UI_DIR) | |
| cmd = ['./pnpm', 'exec', 'playwright', 'test'] | |
| if args.interactive: | |
| if args.rebaseline: | |
| print('--interactive and --rebaseline are mutually exclusive') | |
| return 1 | |
| cmd += ['--ui'] | |
| elif args.rebaseline: | |
| cmd += ['--update-snapshots'] | |
| if args.workers: | |
| cmd += ['--workers', args.workers] | |
| cmd += args.filters | |
| env = dict(os.environ.items()) | |
| dev_server_args = [] | |
| if args.out: | |
| out_rel_path = os.path.relpath(args.out, UI_DIR) | |
| env['OUT_DIR'] = out_rel_path | |
| dev_server_args += ['--out', out_rel_path] | |
| env['DEV_SERVER_ARGS'] = ' '.join(dev_server_args) | |
| os.chdir(UI_DIR) | |
| os.execve(cmd[0], cmd, env) | |
| def run_in_docker(args): | |
| """Run tests inside a Docker container with pre-installed Chrome.""" | |
| use_sudo = needs_sudo_for_docker() | |
| rootless = is_docker_rootless(use_sudo) | |
| # Get the Playwright version and build/use matching Docker image | |
| try: | |
| version = get_playwright_version() | |
| image = build_docker_image(version, use_sudo) | |
| except RuntimeError as e: | |
| print(str(e)) | |
| return 1 | |
| # Build the playwright command (wrapped with xvfb-run for virtual display) | |
| # First install browsers to /tmp (the only writable location in the container) | |
| install_cmd = 'PLAYWRIGHT_BROWSERS_PATH=/tmp/ms-playwright ./pnpm exec playwright install chromium' | |
| playwright_cmd_parts = [ | |
| 'unbuffer', 'xvfb-run', '--auto-servernum', | |
| './pnpm', 'exec', 'playwright', 'test' | |
| ] | |
| if args.rebaseline: | |
| playwright_cmd_parts += ['--update-snapshots'] | |
| if args.workers: | |
| playwright_cmd_parts += ['--workers', args.workers] | |
| playwright_cmd_parts += args.filters | |
| playwright_test_cmd = ' '.join(shlex.quote(p) for p in playwright_cmd_parts) | |
| playwright_cmd = f'{install_cmd} && PLAYWRIGHT_BROWSERS_PATH=/tmp/ms-playwright {playwright_test_cmd}' | |
| # Build dev server args (similar to run_native) | |
| dev_server_args = [] | |
| env_args = [] | |
| if args.out: | |
| out_rel_path = os.path.relpath(args.out, UI_DIR) | |
| env_args += ['-e', f'OUT_DIR={out_rel_path}'] | |
| dev_server_args += ['--out', out_rel_path] | |
| if dev_server_args: | |
| env_args += ['-e', f'DEV_SERVER_ARGS={" ".join(dev_server_args)}'] | |
| # With rootless Docker, container root maps to the host user. Running as the | |
| # host's numeric uid instead would map to an unrelated subordinate uid and | |
| # make owner-only build dependencies inaccessible inside the container. | |
| user_args = [] if rootless else ['-u', f'{os.getuid()}:{os.getgid()}'] | |
| docker_cmd = [ | |
| 'docker', 'run', | |
| '--rm', # Remove the container after it exits | |
| '--ipc=host', # Share host IPC/shared memory to prevent Chrome crashes with high worker counts | |
| '-t', # Allocate a pseudo-TTY for better output formatting | |
| '-v', f'{REPO_ROOT}:{REPO_ROOT}', # Mount the repo root into the container | |
| '-w', f'{REPO_ROOT}/ui', # Set working directory to /ui | |
| *user_args, | |
| '-e', 'HOME=/tmp', # Chrome needs a writable home for crashpad | |
| *env_args, | |
| image, | |
| 'bash', '-c', playwright_cmd | |
| ] | |
| if use_sudo: | |
| docker_cmd = ['sudo'] + docker_cmd | |
| print(f'Running Playwright in Docker using {image}') | |
| if docker_cmd[0] == 'sudo': | |
| print('Note: you may be prompted for your password to run Docker with sudo.') | |
| sys.exit(subprocess.call(docker_cmd)) | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument( | |
| '--no-docker', | |
| action='store_true', | |
| help='Run tests on the host machine instead of in Docker') | |
| parser.add_argument( | |
| '--interactive', | |
| '-i', | |
| action='store_true', | |
| help='Run in interactive mode (requires --no-docker)') | |
| parser.add_argument( | |
| '--rebaseline', '-r', action='store_true', help='Rebaseline screenshots') | |
| parser.add_argument('--out', help='out directory') | |
| parser.add_argument('--no-build', action='store_true') | |
| parser.add_argument('--no-depscheck', action='store_true') | |
| parser.add_argument('--workers', help='Number of playwright workers to use') | |
| parser.add_argument('filters', nargs='*') | |
| args = parser.parse_args() | |
| if args.interactive and not args.no_docker: | |
| print('--interactive requires --no-docker (Docker has no display)') | |
| print('Try: ./run-integrationtests --no-docker --interactive') | |
| return 1 | |
| run_build_result = run_build(args) | |
| if run_build_result != 0: | |
| print('Build failed, not running tests') | |
| return run_build_result | |
| if args.no_docker: | |
| return run_native(args) | |
| if not is_docker_available(): | |
| print('Warning: Docker is not installed or not accessible.') | |
| print('Docker is recommended for consistent test results, especially when') | |
| print('rebaselining screenshots.') | |
| print('') | |
| print('To run without Docker, pass --no-docker:') | |
| print(' ./run-integrationtests --no-docker') | |
| print('') | |
| return 1 | |
| return run_in_docker(args) | |
| if __name__ == '__main__': | |
| sys.exit(main()) |