diff --git a/.github/workflows/build_and_push_docker_image.yml b/.github/workflows/build_and_push_docker_image.yml index 15fc192fbe..39d226468a 100644 --- a/.github/workflows/build_and_push_docker_image.yml +++ b/.github/workflows/build_and_push_docker_image.yml @@ -46,6 +46,10 @@ on: required: false type: boolean default: true + fast_rebuild: + required: false + type: boolean + default: false outputs: build_result: description: 'The result of build_and_push job.' @@ -158,6 +162,7 @@ jobs: file: .venv/lib/python3.12/site-packages/dependencies/dockerfiles/${{ inputs.dockerfile }} tags: gcr.io/${{ vars.PROJECT_NAME }}/${{ inputs.image_name }}:${{ github.run_id }} cache-from: type=gha + cache-to: type=gha,mode=max outputs: type=image,compression=zstd,force-compression=true build-args: | DEVICE=${{ inputs.device }} diff --git a/.github/workflows/ci_pipeline.yml b/.github/workflows/ci_pipeline.yml index 74b695ae95..0759fcce0b 100644 --- a/.github/workflows/ci_pipeline.yml +++ b/.github/workflows/ci_pipeline.yml @@ -30,6 +30,16 @@ on: description: 'The result of all_tests_passed and all_notebooks_passed jobs.' value: ${{ (jobs.all_tests_passed.result == 'success' && jobs.all_notebooks_passed.result == 'success') && 'success' || 'failure' }} workflow_dispatch: + inputs: + mode: + description: 'Test execution mode: build-all (default), test-only (skip package build and run tests), or fast-rebuild' + required: false + default: 'build-all' + type: choice + options: + - build-all + - test-only + - fast-rebuild schedule: # Run the job every 4 hours - cron: '0 */4 * * *' @@ -142,6 +152,7 @@ jobs: # Run if either tests or notebooks need to run; on PRs, gate on code quality + docs passing if: | always() && + inputs.mode != 'test-only' && (needs.analyze_code_changes.outputs.run_tests == 'true' || needs.analyze_code_changes.outputs.run_notebooks == 'true') && (github.event_name != 'pull_request' || (needs.code_quality_check.result == 'success' && needs.docs_build_check.result == 'success')) uses: ./.github/workflows/build_package.yml @@ -156,7 +167,7 @@ jobs: if: | always() && needs.analyze_code_changes.outputs.run_notebooks == 'true' && - needs.build_and_upload_maxtext_package.result == 'success' + (inputs.mode == 'test-only' || needs.build_and_upload_maxtext_package.result == 'success') uses: ./.github/workflows/run_jupyter_notebooks.yml strategy: fail-fast: false @@ -165,7 +176,7 @@ jobs: device_name: v6e-8 base_image: maxtext-unit-test-tpu:py312 cloud_runner: linux-x86-ct6e-180-8tpu - maxtext_sha: ${{ needs.build_and_upload_maxtext_package.outputs.maxtext_sha }} + maxtext_sha: ${{ needs.build_and_upload_maxtext_package.outputs.maxtext_sha || inputs.maxtext_sha || github.sha }} secrets: HF_TOKEN: ${{ secrets.HF_TOKEN }} @@ -175,12 +186,12 @@ jobs: if: | always() && needs.analyze_code_changes.outputs.run_tests == 'true' && - needs.build_and_upload_maxtext_package.result == 'success' + (inputs.mode == 'test-only' || needs.build_and_upload_maxtext_package.result == 'success') runs-on: ubuntu-latest outputs: total_workers: ${{ steps.set-params.outputs.total_workers }} worker_groups: ${{ steps.set-params.outputs.worker_groups }} - maxtext_sha: ${{ needs.build_and_upload_maxtext_package.outputs.maxtext_sha }} + maxtext_sha: ${{ needs.build_and_upload_maxtext_package.outputs.maxtext_sha || inputs.maxtext_sha || github.sha }} steps: - id: set-params name: Formalize Test Suite Parameters diff --git a/.github/workflows/gpu_nightly_images_pipeline.yml b/.github/workflows/gpu_nightly_images_pipeline.yml index 41d8a9f2aa..47d57d98d0 100644 --- a/.github/workflows/gpu_nightly_images_pipeline.yml +++ b/.github/workflows/gpu_nightly_images_pipeline.yml @@ -28,6 +28,15 @@ on: required: false type: string default: "" + mode: + description: 'Docker test execution mode: build-all (rebuild image and test), test-only (run tests against existing image without rebuild), or fast-rebuild (rebuild code only)' + required: false + default: 'build-all' + type: choice + options: + - build-all + - test-only + - fast-rebuild permissions: contents: read @@ -44,6 +53,7 @@ jobs: build_and_push_docker_images: name: Build ${{ matrix.name }} Docker Image needs: build_maxtext_package + if: inputs.mode != 'test-only' strategy: fail-fast: false matrix: @@ -65,6 +75,7 @@ jobs: dockerfile: maxtext_gpu_dependencies.Dockerfile maxtext_sha: ${{ needs.build_maxtext_package.outputs.maxtext_sha }} include_test_assets: true + fast_rebuild: ${{ inputs.mode == 'fast-rebuild' }} secrets: HF_TOKEN: ${{ secrets.HF_TOKEN }} diff --git a/.github/workflows/tpu_nightly_images_pipeline.yml b/.github/workflows/tpu_nightly_images_pipeline.yml index 55ee96dafc..8d7f4692ac 100644 --- a/.github/workflows/tpu_nightly_images_pipeline.yml +++ b/.github/workflows/tpu_nightly_images_pipeline.yml @@ -28,6 +28,15 @@ on: required: false type: string default: "" + mode: + description: 'Docker test execution mode: build-all (rebuild image and test), test-only (run tests against existing image without rebuild), or fast-rebuild (rebuild code only)' + required: false + default: 'build-all' + type: choice + options: + - build-all + - test-only + - fast-rebuild permissions: contents: read @@ -44,6 +53,7 @@ jobs: build_and_push_docker_images: name: Build ${{ matrix.name }} Docker Image needs: build_maxtext_package + if: inputs.mode != 'test-only' strategy: fail-fast: false matrix: @@ -70,12 +80,14 @@ jobs: maxtext_sha: ${{ needs.build_maxtext_package.outputs.maxtext_sha }} include_test_assets: true run_tests: false + fast_rebuild: ${{ inputs.mode == 'fast-rebuild' }} secrets: HF_TOKEN: ${{ secrets.HF_TOKEN }} run_ci_tests: name: Run ${{ matrix.name }} CI Tests needs: build_and_push_docker_images + if: always() && (inputs.mode == 'test-only' || needs.build_and_push_docker_images.result == 'success') strategy: fail-fast: false matrix: @@ -92,7 +104,7 @@ jobs: uses: ./.github/workflows/run_ci_tests.yml with: image_name: ${{ inputs.image_suffix != '' && format('{0}_{1}', matrix.image_name, inputs.image_suffix) || matrix.image_name }} - image_tag: ${{ github.run_id }} + image_tag: ${{ inputs.mode == 'test-only' && 'latest' || github.run_id }} device: tpu workflow: ${{ matrix.workflow }} diff --git a/docs/tutorials/build_maxtext.md b/docs/tutorials/build_maxtext.md index 4b83bd048a..d458b7fa9e 100644 --- a/docs/tutorials/build_maxtext.md +++ b/docs/tutorials/build_maxtext.md @@ -146,6 +146,31 @@ If you get a `command not found: upload_maxtext_docker_image` error, it means yo **Note on Permissions:** You will need the [**Artifact Registry Writer**](https://docs.cloud.google.com/artifact-registry/docs/access-control#permissions) role to push Docker images to Artifact Registry. If you don't have this permission, contact your project administrator to grant you this role through "Google Cloud Console -> IAM -> Grant access". ```` +## Re-Running Tests Without Rebuilding Images + +When MaxText tests fail or flake, users often want to re-run their tests with the same dependencies (or even the same code) without waiting to rebuild the Docker image from scratch. + +In an ideal setup, users can choose between three execution modes using `run_maxtext_docker_test` (or `bash src/dependencies/scripts/run_docker_test.sh`): + +1. **Re-run all steps including building image**: + ```bash + run_maxtext_docker_test --mode=build-all --command="python3 -m pytest -vv tests/unit/" + ``` +2. **Re-run just the tests, using the same image (including same code)**: + ```bash + run_maxtext_docker_test --mode=test-only --command="python3 -m pytest -vv tests/unit/" + ``` +3. **Rebuild the image using the same dependencies, only new code (fast rebuild)**: + ```bash + run_maxtext_docker_test --mode=fast-rebuild --command="python3 -m pytest -vv tests/unit/" + ``` + +### XPK Workload Option + +When running benchmarks or tests on XPK using `benchmarks/maxtext_xpk_runner.py`: +- Set `reuse_image = True` in `WorkloadConfig` to re-run tests using an existing `--docker-image` without rebuilding or deleting any intermediate images. +- Set `fast_rebuild = True` in `WorkloadConfig` to fast-rebuild only the runner image over cached dependencies (`docker_upload_runner.sh FAST_REBUILD=true`). + ## Troubleshooting 1. If you see the following error while building or uploading your Docker image, try adding the listed file path to `.dockerignore`. Do not include the `./` prefix in the `.dockerignore` file: diff --git a/pyproject.toml b/pyproject.toml index 4b7949c50c..14dda2735e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,4 +48,5 @@ install_cuda12_pre_train_extra_deps = "dependencies.scripts.install_pre_train_ex install_tpu_post_train_extra_deps = "dependencies.scripts.install_post_train_extra_deps:main" build_maxtext_docker_image = "dependencies.scripts.build_maxtext_docker_image:main" upload_maxtext_docker_image = "dependencies.scripts.upload_maxtext_docker_image:main" +run_maxtext_docker_test = "dependencies.scripts.run_docker_test:main" run_tutorial = "dependencies.scripts.run_tutorial:main" diff --git a/src/dependencies/scripts/build_upload_colocated_python_image.sh b/src/dependencies/scripts/build_upload_colocated_python_image.sh index f208f7b85f..c0aaf9cdab 100644 --- a/src/dependencies/scripts/build_upload_colocated_python_image.sh +++ b/src/dependencies/scripts/build_upload_colocated_python_image.sh @@ -125,8 +125,13 @@ FULL_BASE_IMAGE="${BASE_IMAGE_REPO}:${BASE_IMAGE_TAG}" echo " Found latest compatible base image: ${FULL_BASE_IMAGE}" # --- Step 3: Build the Docker image using --build-arg --- +CACHE_FLAG="--no-cache" +if [[ "${FAST_REBUILD:-false}" == "true" || "${NO_CACHE:-true}" == "false" ]]; then + CACHE_FLAG="" + echo "Fast rebuild enabled: reusing Docker layer cache." +fi echo "$(date): Running docker build with local tag '${LOCAL_IMAGE_NAME}' using ${TMP_DOCKERFILE}..." -docker build --no-cache \ +docker build ${CACHE_FLAG} \ -f ${DOCKERFILE_TEMPLATE} \ --build-arg BASE_IMAGE="${FULL_BASE_IMAGE}" \ -t "${LOCAL_IMAGE_NAME}" \ @@ -140,7 +145,11 @@ echo "$(date): Pushing '${IMAGE_LOCATION}'..." docker push "${IMAGE_LOCATION}" # --- Step 5: Cleanup --- -echo "$(date): Cleaning up local tag '${LOCAL_IMAGE_NAME}'..." -docker image rm "${LOCAL_IMAGE_NAME}" +if [[ "${CLEANUP_IMAGE:-true}" == "true" ]]; then + echo "$(date): Cleaning up local tag '${LOCAL_IMAGE_NAME}'..." + docker image rm "${LOCAL_IMAGE_NAME}" +else + echo "$(date): Preserving local tag '${LOCAL_IMAGE_NAME}' (CLEANUP_IMAGE=false)..." +fi echo "$(date): Build and push complete for ${IMAGE_LOCATION}" diff --git a/src/dependencies/scripts/docker_upload_runner.sh b/src/dependencies/scripts/docker_upload_runner.sh index f7b98a4581..800ea7bf9a 100644 --- a/src/dependencies/scripts/docker_upload_runner.sh +++ b/src/dependencies/scripts/docker_upload_runner.sh @@ -116,7 +116,13 @@ if ! docker image inspect "${LOCAL_IMAGE_NAME}" &> /dev/null; then exit 1 fi -docker build --no-cache --build-arg BASEIMAGE=${LOCAL_IMAGE_NAME} \ +CACHE_FLAG="--no-cache" +if [[ "${FAST_REBUILD:-false}" == "true" || "${NO_CACHE:-true}" == "false" ]]; then + CACHE_FLAG="" + echo "Fast rebuild enabled: reusing Docker layer cache for runner image build." +fi + +docker build ${CACHE_FLAG} --build-arg BASEIMAGE=${LOCAL_IMAGE_NAME} \ --build-arg PACKAGE_DIR=${PACKAGE_DIR} \ -f "$PACKAGE_DIR"'/dependencies/dockerfiles/maxtext_runner.Dockerfile' \ -t ${LOCAL_IMAGE_NAME_RUNNER} . diff --git a/src/dependencies/scripts/run_docker_test.py b/src/dependencies/scripts/run_docker_test.py new file mode 100644 index 0000000000..130c3309bd --- /dev/null +++ b/src/dependencies/scripts/run_docker_test.py @@ -0,0 +1,36 @@ +# Copyright 2026 Google LLC +# +# 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 +# +# https://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. + +"""Wrapper to run run_docker_test.sh from pip install or module execution.""" + +import os +import sys + + +def main(): + current_dir = os.path.dirname(os.path.abspath(__file__)) + repo_root = os.path.abspath(os.path.join(current_dir, "..", "..")) + # Use relative path for Docker + os.environ["PACKAGE_DIR"] = os.path.relpath(repo_root, os.getcwd()) + + script_path = os.path.join(current_dir, "run_docker_test.sh") + if not os.path.exists(script_path): + raise FileNotFoundError(f"Script not found at {script_path}") + + cmd = ["bash", script_path] + sys.argv[1:] + os.execvp("bash", cmd) + + +if __name__ == "__main__": + main() diff --git a/src/dependencies/scripts/run_docker_test.sh b/src/dependencies/scripts/run_docker_test.sh new file mode 100755 index 0000000000..5e338eb66d --- /dev/null +++ b/src/dependencies/scripts/run_docker_test.sh @@ -0,0 +1,235 @@ +#!/bin/bash +# Copyright 2023–2026 Google LLC +# +# 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 +# +# https://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 MaxText unit, integration, or end-to-end tests in Docker containers +# while allowing users to choose whether to build all images, perform a fast rebuild +# of only code layers, or re-run tests using an existing image. +# +# Supported modes: +# 1. build-all : Re-run all steps including building the base dependency image and runner image. +# 2. test-only : Re-run just the tests using the existing image (no build, fast re-run). +# 3. fast-rebuild : Rebuild the runner image using cached dependencies, updating only code. + +set -e + +# Default parameter values +PACKAGE_DIR="${PACKAGE_DIR:-src}" +MODE="${MODE:-build-all}" +COMMAND="${COMMAND:-python3 -m pytest -vv tests/unit/}" +BASE_IMAGE="${BASE_IMAGE:-maxtext_base_image}" +IMAGE_NAME="${IMAGE_NAME:-maxtext_base_image__runner}" +DEVICE="${DEVICE:-tpu}" +BUILD_MODE="${BUILD_MODE:-stable}" +CLEANUP_IMAGE="${CLEANUP_IMAGE:-false}" +DOCKER_RUN_FLAGS="${DOCKER_RUN_FLAGS:-}" + +# Parse command line flags and key=value arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -m|--mode) + MODE="$2" + shift 2 + ;; + --mode=*) + MODE="${1#*=}" + shift 1 + ;; + -c|--command) + COMMAND="$2" + shift 2 + ;; + --command=*) + COMMAND="${1#*=}" + shift 1 + ;; + -i|--image) + IMAGE_NAME="$2" + shift 2 + ;; + --image=*) + IMAGE_NAME="${1#*=}" + shift 1 + ;; + --base-image=*) + BASE_IMAGE="${1#*=}" + shift 1 + ;; + --cleanup) + CLEANUP_IMAGE="true" + shift 1 + ;; + --no-cleanup) + CLEANUP_IMAGE="false" + shift 1 + ;; + --cleanup=*) + CLEANUP_IMAGE="${1#*=}" + shift 1 + ;; + *=*) + IFS='=' read -r KEY VALUE <<< "$1" + export "${KEY}"="${VALUE}" + shift 1 + ;; + -h|--help) + echo "Usage: $0 [OPTIONS] [KEY=VALUE...]" + echo "" + echo "Options:" + echo " -m, --mode Execution mode:" + echo " build-all : Build dependency image + runner image + run tests" + echo " test-only : Run tests using existing image (no rebuild)" + echo " fast-rebuild : Rebuild only runner code layer + run tests" + echo " -c, --command Command to execute inside Docker container" + echo " (default: 'python3 -m pytest -vv tests/unit/')" + echo " -i, --image Runner Docker image name (default: maxtext_base_image__runner)" + echo " --base-image Base dependency Docker image name (default: maxtext_base_image)" + echo " --cleanup= Remove image after test run (default: false)" + echo "" + exit 0 + ;; + *) + echo "ERROR: Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +# Normalize mode string +case "${MODE}" in + build-all|rebuild-all|all|1) + MODE="build-all" + ;; + test-only|reuse-image|no-build|2) + MODE="test-only" + ;; + fast-rebuild|rebuild-code|code-only|3) + MODE="fast-rebuild" + ;; + *) + echo "ERROR: Invalid mode '${MODE}'. Supported modes: build-all, test-only, fast-rebuild." >&2 + exit 1 + ;; +esac + +echo "==========================================================" +echo "MaxText Docker Test Runner" +echo "==========================================================" +echo "Mode : ${MODE}" +echo "Runner Image : ${IMAGE_NAME}" +echo "Base Image : ${BASE_IMAGE}" +echo "Test Command : ${COMMAND}" +echo "Cleanup Image : ${CLEANUP_IMAGE}" +echo "==========================================================" + +# Check for Docker daemon access +if ! docker info > /dev/null 2>&1; then + echo "ERROR: Permission denied while trying to connect to the Docker daemon." >&2 + echo "Please check your Docker permissions (sudo, docker group, etc.)." >&2 + exit 1 +fi + +build_base_image() { + echo "----------------------------------------------------------" + echo "Step 1: Building base dependency image '${BASE_IMAGE}'..." + echo "----------------------------------------------------------" + if [[ -f "${PACKAGE_DIR}/dependencies/scripts/docker_build_dependency_image.sh" ]]; then + bash "${PACKAGE_DIR}/dependencies/scripts/docker_build_dependency_image.sh" \ + DEVICE="${DEVICE}" MODE="${BUILD_MODE}" LOCAL_IMAGE_NAME="${BASE_IMAGE}" + else + echo "ERROR: Base image build script not found under ${PACKAGE_DIR}/dependencies/scripts/" >&2 + exit 1 + fi +} + +build_runner_image() { + local use_cache="$1" + echo "----------------------------------------------------------" + echo "Step 2: Building runner image '${IMAGE_NAME}' from '${BASE_IMAGE}'..." + echo " Cache enabled: ${use_cache}" + echo "----------------------------------------------------------" + if ! docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then + echo "ERROR: Base dependency image '${BASE_IMAGE}' not found locally." >&2 + echo " Please run with '--mode=build-all' first to build the base dependencies." >&2 + exit 1 + fi + + local cache_args=() + if [[ "${use_cache}" == "false" ]]; then + cache_args+=("--no-cache") + fi + + docker build "${cache_args[@]}" \ + --build-arg BASEIMAGE="${BASE_IMAGE}" \ + --build-arg PACKAGE_DIR="${PACKAGE_DIR}" \ + -f "${PACKAGE_DIR}/dependencies/dockerfiles/maxtext_runner.Dockerfile" \ + -t "${IMAGE_NAME}" . +} + +case "${MODE}" in + build-all) + build_base_image + build_runner_image "false" + ;; + fast-rebuild) + build_runner_image "true" + ;; + test-only) + echo "----------------------------------------------------------" + echo "Skipping image build. Verifying runner image '${IMAGE_NAME}' exists..." + echo "----------------------------------------------------------" + if ! docker image inspect "${IMAGE_NAME}" >/dev/null 2>&1; then + echo "ERROR: Runner image '${IMAGE_NAME}' not found locally." >&2 + echo " Cannot re-run tests without an existing image." >&2 + echo " Use '--mode=build-all' or '--mode=fast-rebuild' to create the image first." >&2 + exit 1 + fi + ;; +esac + +echo "----------------------------------------------------------" +echo "Step 3: Running test command inside container '${IMAGE_NAME}'..." +echo "----------------------------------------------------------" + +set +e +docker run --rm -it --network host --privileged \ + -e MAXTEXT_REPO_ROOT=/deps \ + -e MAXTEXT_PKG_DIR=/deps/src/maxtext \ + -v "$(pwd):/deps" \ + ${DOCKER_RUN_FLAGS} \ + "${IMAGE_NAME}" \ + bash -c "${COMMAND}" +TEST_EXIT_CODE=$? +set -e + +echo "----------------------------------------------------------" +if [[ "${TEST_EXIT_CODE}" -eq 0 ]]; then + echo "TEST RESULT: SUCCESS (Exit code 0)" +else + echo "TEST RESULT: FAILURE (Exit code ${TEST_EXIT_CODE})" +fi + +if [[ "${CLEANUP_IMAGE}" == "true" ]]; then + echo "Cleaning up Docker image '${IMAGE_NAME}' as requested..." + docker image rm "${IMAGE_NAME}" 2>/dev/null || true +else + echo "----------------------------------------------------------" + echo "NOTICE: Docker image '${IMAGE_NAME}' has been preserved." + echo " - To re-run tests without rebuilding image : bash $0 --mode=test-only" + echo " - To rebuild only code layers (fast rebuild): bash $0 --mode=fast-rebuild" + echo " - To rebuild all dependencies from scratch : bash $0 --mode=build-all" + echo "----------------------------------------------------------" +fi + +exit "${TEST_EXIT_CODE}" diff --git a/tests/unit/run_docker_test_test.py b/tests/unit/run_docker_test_test.py new file mode 100644 index 0000000000..de10688d62 --- /dev/null +++ b/tests/unit/run_docker_test_test.py @@ -0,0 +1,93 @@ +# Copyright 2026 Google LLC +# +# 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 +# +# https://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. + +"""Unit tests for MaxText Docker testing scripts and XPK reuse-image / fast-rebuild options.""" + +import os +import subprocess +import unittest +from unittest import mock + +import pytest + +@pytest.mark.cpu_only +class RunDockerTestTest(unittest.TestCase): + """Tests for run_docker_test.sh / run_docker_test.py CLI options.""" + + def test_run_docker_test_help(self): + current_dir = os.path.dirname(os.path.abspath(__file__)) + repo_root = os.path.abspath(os.path.join(current_dir, "..", "..")) + script_path = os.path.join(repo_root, "src", "dependencies", "scripts", "run_docker_test.sh") + + result = subprocess.run( + ["bash", script_path, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0) + self.assertIn("build-all", result.stdout) + self.assertIn("test-only", result.stdout) + self.assertIn("fast-rebuild", result.stdout) + + def test_run_docker_test_invalid_mode(self): + current_dir = os.path.dirname(os.path.abspath(__file__)) + repo_root = os.path.abspath(os.path.join(current_dir, "..", "..")) + script_path = os.path.join(repo_root, "src", "dependencies", "scripts", "run_docker_test.sh") + + result = subprocess.run( + ["bash", script_path, "--mode=invalid_mode"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Invalid mode", result.stderr) + + def test_run_docker_test_fast_rebuild_mode(self): + current_dir = os.path.dirname(os.path.abspath(__file__)) + repo_root = os.path.abspath(os.path.join(current_dir, "..", "..")) + script_path = os.path.join(repo_root, "src", "dependencies", "scripts", "run_docker_test.sh") + + result = subprocess.run( + ["bash", script_path, "--mode=fast-rebuild", "--base-image=non_existent_base_img_999"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertIn("Mode : fast-rebuild", result.stdout) + self.assertIn("not found locally", result.stderr) + + def test_run_docker_test_test_only_mode(self): + current_dir = os.path.dirname(os.path.abspath(__file__)) + repo_root = os.path.abspath(os.path.join(current_dir, "..", "..")) + script_path = os.path.join(repo_root, "src", "dependencies", "scripts", "run_docker_test.sh") + + result = subprocess.run( + ["bash", script_path, "--mode=test-only", "--image=non_existent_test_img_456"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertIn("Mode : test-only", result.stdout) + self.assertIn("Skipping image build.", result.stdout) + self.assertIn("not found locally", result.stderr) + + +if __name__ == "__main__": + unittest.main()