# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you 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. import argparse import os import subprocess from datetime import datetime XVFB_PRE_CMD = "xvfb-run --auto-servernum --server-args='-screen 0, 1280x1024x24' " REPO = os.getenv("GITHUB_REPOSITORY") or "apache/superset" GITHUB_EVENT_NAME = os.getenv("GITHUB_EVENT_NAME") or "push" CYPRESS_RECORD_KEY = os.getenv("CYPRESS_RECORD_KEY") or "" def generate_build_id() -> str: """Generates a build ID based on the current timestamp.""" now = datetime.now() rounded_minute = now.minute - (now.minute % 20) rounded_time = now.replace(minute=rounded_minute, second=0, microsecond=0) return (os.getenv("GITHUB_SHA") or "DUMMY")[:8] + rounded_time.strftime( "%Y%m%d%H%M" ) def run_cypress_for_test_file( test_file: str, retries: int, use_dashboard: bool, group: str, dry_run: bool, i: int ) -> int: """Runs Cypress for a single test file and retries upon failure.""" cypress_cmd = "./node_modules/.bin/cypress run" os.environ["TERM"] = "xterm" os.environ["ELECTRON_DISABLE_GPU"] = "true" build_id = generate_build_id() browser = os.getenv("CYPRESS_BROWSER", "chrome") chrome_flags = "--disable-dev-shm-usage" for attempt in range(retries): # Create Cypress command for a single test file cmd: str = "" if use_dashboard: # If/when we want to use cypress' dashboard feature to record the run group_id = f"matrix{group}-file{i}-{attempt}" cmd = ( f"{XVFB_PRE_CMD} " f'{cypress_cmd} --spec "{test_file}" ' f"--config numTestsKeptInMemory=0 " f"--browser {browser} " f"--record --group {group_id} --tag {REPO},{GITHUB_EVENT_NAME} " f"--ci-build-id {build_id} " f"-- {chrome_flags}" ) else: os.environ.pop("CYPRESS_RECORD_KEY", None) cmd = ( f"{XVFB_PRE_CMD} " f"{cypress_cmd} " f"--browser {browser} " f"--config numTestsKeptInMemory=0 " f'--spec "{test_file}" ' f"-- {chrome_flags}" ) print(f"RUN: {cmd} (Attempt {attempt + 1}/{retries})") if dry_run: # Print the command instead of executing it print(f"DRY RUN: {cmd}") return 0 process = subprocess.Popen( # noqa: S602 cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, ) # Stream stdout in real-time if process.stdout: for stdout_line in iter(process.stdout.readline, ""): print(stdout_line, end="") process.wait() if process.returncode == 0: print(f"Test {test_file} succeeded on attempt {attempt + 1}") return 0 else: print(f"Test {test_file} failed on attempt {attempt + 1}") print(f"Test {test_file} failed after {retries} retries.") return process.returncode def main() -> None: parser = argparse.ArgumentParser( description="Run Cypress tests with retries per test file" ) parser.add_argument( "--use-dashboard", action="store_true", help="Use Cypress Dashboard for parallelization", ) parser.add_argument( "--parallelism", type=int, default=10, help="Number of parallel groups" ) parser.add_argument( "--parallelism-id", type=int, required=True, help="ID of the parallelism group" ) parser.add_argument( "--filter", type=str, required=False, default=None, help="Filter to test" ) parser.add_argument("--group", type=str, default="Default", help="Group name") parser.add_argument( "--retries", type=int, default=3, help="Number of retries per test file" ) parser.add_argument( "--dry-run", action="store_true", help="Print the command instead of executing it", ) args = parser.parse_args() script_dir = os.path.dirname(os.path.abspath(__file__)) cypress_base_path = "superset-frontend/cypress-base/" cypress_base_full_path = os.path.join(script_dir, "../", cypress_base_path) cypress_tests_path = os.path.join(cypress_base_full_path, "cypress/e2e") test_files = [] file_count = 0 for root, _, files in os.walk(cypress_tests_path): for file in files: if file.endswith("test.ts") or file.endswith("test.js"): file_count += 1 test_files.append( os.path.join(root, file).replace(cypress_base_full_path, "") ) print(f"Found {file_count} test files.") # Initialize groups for round-robin distribution groups: dict[int, list[str]] = {i: [] for i in range(args.parallelism)} # Sort test files to ensure deterministic distribution sorted_test_files = sorted(test_files) # Distribute test files in a round-robin manner for index, test_file in enumerate(sorted_test_files): group_index = index % args.parallelism groups[group_index].append(test_file) # Only run tests for the group that matches the parallelism ID group_id = args.parallelism_id spec_list = groups[group_id] # Run each test file independently with retry logic or dry-run processed_file_count: int = 0 for i, test_file in enumerate(spec_list): result = run_cypress_for_test_file( test_file, args.retries, args.use_dashboard, args.group, args.dry_run, i ) if result != 0: print(f"Exiting due to failure in {test_file}") exit(result) processed_file_count += 1 print(f"Ran {processed_file_count} test files successfully.") if __name__ == "__main__": main()