From b3905e2314a0b33513fbac25a44339791ff2f6d1 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sat, 14 Mar 2026 14:23:38 +0100 Subject: [PATCH 01/19] Use download_thanks in sbin installer Follow-up to fe332f7 (fix sublime text 4 download link). Align the sbin Sublime Text installer with the ST4 URL fix already used in other setup paths. Using /download can return placeholder URLs (for example ${version}), which breaks archive downloads in local/docker runs. --- sbin/install_sublime_text.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sbin/install_sublime_text.sh b/sbin/install_sublime_text.sh index f1209611..566ec13e 100644 --- a/sbin/install_sublime_text.sh +++ b/sbin/install_sublime_text.sh @@ -37,7 +37,7 @@ if [ $SUBLIME_TEXT_VERSION -ge 4 ] && [ "$SUBLIME_TEXT_ARCH" != "x64" ]; then fi if [ $SUBLIME_TEXT_VERSION -ge 4 ]; then - STWEB="https://www.sublimetext.com/download" + STWEB="https://www.sublimetext.com/download_thanks" else STWEB="https://www.sublimetext.com/$SUBLIME_TEXT_VERSION" fi From f852c371c34b2a25c996c5b641b20765bf27e286 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sat, 14 Mar 2026 14:26:53 +0100 Subject: [PATCH 02/19] Polish installer wording and exit codes This is a semantics and wording cleanup, not a bug fix. When Sublime Text is already present, the installer now reports a clearer message and exits with status 0. Returning success in this state matches common "ensure installed" script conventions and improves idempotent automation behavior. --- sbin/install_sublime_text.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sbin/install_sublime_text.sh b/sbin/install_sublime_text.sh index 566ec13e..604786b1 100644 --- a/sbin/install_sublime_text.sh +++ b/sbin/install_sublime_text.sh @@ -117,8 +117,8 @@ if [ $(uname) = 'Darwin' ]; then pkill 'plugin_host' || true sleep 2 else - echo "Sublime Text was installed already!" - exit 1 + echo "Sublime Text is already installed." + exit 0 fi else if [ -z $(which subl) ]; then @@ -171,7 +171,7 @@ else pkill 'plugin_host' || true sleep 2 else - echo "Sublime Text was installed already!" - exit 1 + echo "Sublime Text is already installed." + exit 0 fi fi From 92b8003235382e2a0ec4f291b0372ab83a30246c Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sat, 14 Mar 2026 15:04:45 +0100 Subject: [PATCH 03/19] Enrich schedule.json entries for unit_testing Scheduler-driven runs previously forwarded only a small fixed subset of arguments to the `unit_testing` command. Allow schedule entries to include selected unit_testing option keys and forward them to `unit_testing`. This enables per-run overrides (for example pattern/tests_dir/failfast and reload_package_on_testing) without editing unittesting.json. --- unittesting/scheduler.py | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/unittesting/scheduler.py b/unittesting/scheduler.py index aaa1b0a7..edb94dcf 100644 --- a/unittesting/scheduler.py +++ b/unittesting/scheduler.py @@ -42,14 +42,31 @@ def save(self, data, indent=4): class Unit: + UNIT_TESTING_OPTION_KEYS = ( + "capture_console", + "condition_timeout", + "deferred", + "failfast", + "generate_html_report", + "generate_xml_report", + "pattern", + "reload_package_on_testing", + "tests_dir", + "verbosity", + "warnings", + ) + def __init__(self, s): self.package = s["package"] - self.output = s.get("output", None) + self.output = s.get("output") self.syntax_test = s.get("syntax_test", False) self.syntax_compatibility = s.get("syntax_compatibility", False) self.color_scheme_test = s.get("color_scheme_test", False) self.coverage = s.get("coverage", False) + self.unit_testing_options = { + key: s[key] for key in self.UNIT_TESTING_OPTION_KEYS if key in s + } def run(self): if self.color_scheme_test: @@ -67,15 +84,17 @@ def run(self): {"package": self.package, "output": self.output}, ) else: - sublime.active_window().run_command( - "unit_testing", - { - "package": self.package, - "output": self.output, - "coverage": self.coverage, - "generate_xml_report": True, - }, - ) + sublime.active_window().run_command("unit_testing", self.unit_testing_args()) + + def unit_testing_args(self): + args = { + "package": self.package, + "output": self.output, + "coverage": self.coverage, + "generate_xml_report": True, + } + args.update(self.unit_testing_options) + return args def run_scheduler(): From d4a880f51d58ebb611ea6d7139b495a157243d3a Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sat, 14 Mar 2026 16:07:04 +0100 Subject: [PATCH 04/19] Make scheduler delay configurable via env Keep the existing 2000ms default to preserve conservative behavior, while allowing local/container runs to experiment with lower values via UNITTESTING_SCHEDULER_DELAY_MS. In fact, "0" works fine on my local machine. --- unittesting/scheduler.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/unittesting/scheduler.py b/unittesting/scheduler.py index edb94dcf..871c2171 100644 --- a/unittesting/scheduler.py +++ b/unittesting/scheduler.py @@ -98,5 +98,11 @@ def unit_testing_args(self): def run_scheduler(): - # delay schedule initialization and execution - sublime.set_timeout(lambda: Scheduler().run(), 2000) + # Delay schedule execution. In practice, a single queue tick (`0`) seems + # sufficient once Sublime starts draining callbacks. + try: + delay = int(os.environ.get("UNITTESTING_SCHEDULER_DELAY_MS", "2000")) + except ValueError: + delay = 2000 + + sublime.set_timeout(lambda: Scheduler().run(), delay) From dd2f9c9764da5b2560e872b6e82fa847b8923394 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sat, 14 Mar 2026 16:16:31 +0100 Subject: [PATCH 05/19] Improve local Docker test workflow Document and harden the local containerized test path used by run_tests.py and docker entrypoints. This updates the docker/sbin workflow to better support repeated local runs with cache reuse, package refresh, and per-run test selection options. It also clarifies that sbin scripts remain relevant for local container automation even though GitHub CI prefers actions/*. --- README.md | 40 ++++ docker/Dockerfile | 14 +- docker/README.md | 43 +++- docker/docker.sh | 8 +- docker/entrypoint.sh | 30 ++- docker/run_tests.py | 342 ++++++++++++++++++++++++++++++++ sbin/.python-version | 1 + sbin/README.md | 7 +- sbin/ci.sh | 21 +- sbin/install_package_control.sh | 14 +- sbin/run_tests.py | 39 +++- 11 files changed, 518 insertions(+), 41 deletions(-) create mode 100644 docker/run_tests.py create mode 100644 sbin/.python-version diff --git a/README.md b/README.md index 37048e1d..52302a3e 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,46 @@ so that ctrl+b would invoke the testing action. ``` +### Headless container runner + +To run tests without affecting your interactive Sublime Text session, +use the bundled `docker/run_tests.py` script. + +```sh +# run all tests from current package root +uv run docker/run_tests.py . + +# run just one test file (faster) +uv run docker/run_tests.py . --file tests/test_example.py +``` + +This script runs tests in a Docker container (headless), streams output to +stdout/stderr and keeps a cache volume so repeated runs are fast. + +By default it: + +- builds `unittesting-local` image from `./docker` if missing +- mounts your repo as `/project` +- runs UnitTesting through the same CI shell entrypoints +- stores Sublime install/cache in docker volume `unittesting-home` +- synchronizes only changed files into `Packages/` using `rsync` + +Useful options: + +- `--file tests/test_foo.py` +- `--pattern test_foo.py --tests-dir tests/subdir` +- `--coverage` +- `--failfast` +- `--docker-image ` +- `--no-cache-volume` +- `--scheduler-delay-ms 300` (lower can be faster) + +> [!TIP] +> +> This is useful for editor build systems and for AI agents, +> because test runs no longer commandeer your active editor window. + + ## GitHub Actions Unittesting provides the following GitHub Actions, which can be combined diff --git a/docker/Dockerfile b/docker/Dockerfile index 95604f7f..28b39724 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -3,8 +3,8 @@ FROM ubuntu:latest USER root RUN apt-get update RUN apt-get install --no-install-recommends sudo apt-utils -y -RUN apt-get install --no-install-recommends python software-properties-common -y -RUN apt-get install --no-install-recommends git curl xvfb -y +RUN apt-get install --no-install-recommends python3 python-is-python3 software-properties-common -y +RUN apt-get install --no-install-recommends git curl xvfb rsync -y RUN apt-get install --no-install-recommends libglib2.0-0 libgtk-3-0 -y RUN apt-get install --no-install-recommends psmisc -y RUN apt-get install --no-install-recommends locales locales-all -y @@ -15,17 +15,15 @@ RUN if [ "$arch" = "i386" ]; then apt-get update; fi RUN if [ "$arch" = "i386" ]; then apt-get install --no-install-recommends libc6:i386 libncurses5:i386 libstdc++6:i386 -y; fi RUN if [ "$arch" = "i386" ]; then apt-get install --no-install-recommends libglib2.0-0:i386 libgtk-3-0:i386 libx11-6:i386 -y; fi -ENV LANG en_US.UTF-8 -ENV LANGUAGE en_US.UTF-8 +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US.UTF-8 ENV DISPLAY=:1 COPY xvfb /etc/init.d/xvfb -RUN chmod +x /etc/init.d/xvfb COPY docker.sh /docker.sh -RUN chmod +x /docker.sh COPY entrypoint.sh /entrypoint.sh -RUN chmod 666 /entrypoint.sh -RUN chmod +x /entrypoint.sh +RUN sed -i 's/\r$//' /etc/init.d/xvfb /docker.sh /entrypoint.sh +RUN chmod +x /etc/init.d/xvfb /docker.sh /entrypoint.sh WORKDIR /project ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/README.md b/docker/README.md index 254c80ea..ba09de8d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,16 +1,43 @@ # Docker image for Sublime Text UnitTesting +## Recommended usage + +Use the docker wrapper script: + +```sh +uv run docker/run_tests.py /path/to/package +uv run docker/run_tests.py /path/to/package --file tests/test_example.py +``` + +It builds/uses a local image, mounts the package at `/project`, runs tests +headlessly, and keeps a cache volume for fast reruns. + +## Manual docker usage -## From Docker Hub ```sh -# cd to package -docker run --rm -it -e PACKAGE=$PACKAGE -v $PWD:/project sublimetext/unittesting +# build from UnitTesting/docker +docker build -t unittesting-local . + +# run from package root +docker run --rm -it \ + -e PACKAGE=$PACKAGE \ + -v $PWD:/project \ + -v unittesting-home:/root \ + unittesting-local run_tests ``` -## Build image from scratch +## Fast reruns + +The container entrypoint writes a marker in `/root/.cache/unittesting`. +With `-v unittesting-home:/root`, bootstrap/install runs once and later runs +only refresh your package files and execute tests. + +## Run a single test file + ```sh -# cd to UnitTesting/docker -docker build -t unittesting . -# cd to package -docker run --rm -it -e PACKAGE=$PACKAGE -v $PWD:/project unittesting +docker run --rm -it \ + -e PACKAGE=$PACKAGE \ + -v $PWD:/project \ + -v unittesting-home:/root \ + unittesting-local run_tests --tests-dir tests --pattern test_example.py ``` diff --git a/docker/docker.sh b/docker/docker.sh index bf6b9b0f..25c880da 100644 --- a/docker/docker.sh +++ b/docker/docker.sh @@ -4,9 +4,13 @@ set -e BASEDIR=`dirname $0` -CISH="/tmp/ci.sh" +UNITTESTING_SOURCE=${UNITTESTING_SOURCE:-/unittesting} +CISH="$UNITTESTING_SOURCE/sbin/ci.sh" if [ ! -f "$CISH" ]; then - curl -s -L https://raw.githubusercontent.com/SublimeText/UnitTesting/master/sbin/ci.sh -o "$CISH" + CISH="/tmp/ci.sh" + if [ ! -f "$CISH" ]; then + curl -s -L https://raw.githubusercontent.com/SublimeText/UnitTesting/master/sbin/ci.sh -o "$CISH" + fi fi if [ -z "$PACKAGE" ]; then diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a050893e..a7bded18 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -5,7 +5,31 @@ echo PACKAGE = $PACKAGE set -e PATH="$HOME/.local/bin:$PATH" +BOOTSTRAP_MARKER="$HOME/.cache/unittesting/bootstrap.done" + sudo sh -e /etc/init.d/xvfb start -/docker.sh bootstrap -/docker.sh install_package_control -/docker.sh run_tests --coverage + +UNITTESTING_SOURCE=${UNITTESTING_SOURCE:-/unittesting} +if [ -d "$UNITTESTING_SOURCE/sbin" ]; then + # Ensure UnitTesting comes from the local checkout running this script, + # so first runs do not depend on tagged upstream releases. + (cd "$UNITTESTING_SOURCE" && PACKAGE=UnitTesting /docker.sh copy_tested_package overwrite) +fi + +if [ ! -f "$BOOTSTRAP_MARKER" ]; then + # Bootstrap from a neutral cwd to avoid Windows worktree .git indirection + # paths breaking git commands inside the Linux container. + (cd /tmp && /docker.sh bootstrap skip_package_copy) + /docker.sh install_package_control + mkdir -p "$(dirname "$BOOTSTRAP_MARKER")" + touch "$BOOTSTRAP_MARKER" +fi + +# Always refresh checked-out package into Packages/ +/docker.sh copy_tested_package overwrite + +if [ "$#" -gt 0 ]; then + /docker.sh "$@" +else + /docker.sh run_tests --coverage +fi diff --git a/docker/run_tests.py b/docker/run_tests.py new file mode 100644 index 00000000..0179c0fd --- /dev/null +++ b/docker/run_tests.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""Run Sublime Text UnitTesting in a Docker container. + +Examples: + uv run docker/run_tests.py . + uv run docker/run_tests.py . --file tests/test_main.py +""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import shutil +import subprocess +import sys +from pathlib import Path + + +DEFAULT_IMAGE = "unittesting-local" +DEFAULT_CACHE_VOLUME = "unittesting-home" + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + ensure_docker() + + package_root = args.package_root.resolve() + if not package_root.is_dir(): + print(f"Error: package root does not exist: {package_root}", file=sys.stderr) + return 2 + + unit_testing_root = Path(__file__).resolve().parent.parent + if not unit_testing_root.is_dir(): + print( + f"Error: UnitTesting root does not exist: {unit_testing_root}", + file=sys.stderr, + ) + return 2 + + package_name = args.package_name or package_root.name + tests_dir, pattern = resolve_test_target( + package_root, args.file, args.tests_dir, args.pattern + ) + + image = args.docker_image + maybe_build_image(image, args) + + if args.pull: + run_checked(["docker", "pull", image]) + + if args.cache_volume: + ensure_docker_volume(args.cache_volume) + + command = build_docker_run_command( + package_root=package_root, + unit_testing_root=unit_testing_root, + package_name=package_name, + image=image, + cache_volume=args.cache_volume, + sublime_text_version=args.sublime_text_version, + scheduler_delay_ms=args.scheduler_delay_ms, + coverage=args.coverage, + failfast=args.failfast, + tests_dir=tests_dir, + pattern=pattern, + ) + + print(f"Package root: {package_root}") + print(f"Package name: {package_name}") + print(f"Docker image: {image}") + print(f"Scheduler delay: {args.scheduler_delay_ms}ms") + if args.cache_volume: + print(f"Cache volume: {args.cache_volume}") + if tests_dir and pattern: + print(f"Test target: {tests_dir}/{pattern}") + + return subprocess.call(command) + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run UnitTesting headlessly in Docker." + ) + + parser.add_argument( + "package_root", + nargs="?", + default=".", + type=Path, + help="Path to the package root (default: current directory).", + ) + parser.add_argument("--file", help="Run only tests from this file.") + parser.add_argument("--pattern", help="Custom unittest discovery pattern.") + parser.add_argument("--tests-dir", help="Custom tests directory.") + parser.add_argument("--package-name", help="Override package name.") + parser.add_argument("--coverage", action="store_true", help="Enable coverage.") + parser.add_argument("--failfast", action="store_true", help="Stop on first failure.") + + parser.add_argument( + "--docker-image", + default=DEFAULT_IMAGE, + help=f"Docker image to run (default: {DEFAULT_IMAGE}).", + ) + parser.add_argument( + "--cache-volume", + default=DEFAULT_CACHE_VOLUME, + help=( + "Docker volume mounted at /root to cache Sublime setup " + f"(default: {DEFAULT_CACHE_VOLUME})." + ), + ) + parser.add_argument( + "--no-cache-volume", + dest="cache_volume", + action="store_const", + const=None, + help="Disable persistent cache volume.", + ) + parser.add_argument( + "--sublime-text-version", + type=int, + default=4, + help="Sublime Text major version inside container.", + ) + parser.add_argument( + "--scheduler-delay-ms", + type=int, + default=300, + help="Delay before running scheduled tests inside Sublime (default: 300).", + ) + parser.add_argument( + "--pull", + action="store_true", + help="Pull docker image before running.", + ) + + parser.add_argument( + "--build-image", + action="store_true", + help="Force rebuild of local docker image from script directory.", + ) + parser.add_argument( + "--build-if-missing", + dest="build_if_missing", + action="store_true", + default=True, + help="Build image from script directory if missing (default: true).", + ) + parser.add_argument( + "--no-build-if-missing", + dest="build_if_missing", + action="store_false", + help="Do not auto-build image if missing.", + ) + + args = parser.parse_args(argv) + + if args.file and args.pattern: + parser.error("--file and --pattern are mutually exclusive") + + return args + + +def ensure_docker() -> None: + if not shutil.which("docker"): + raise SystemExit("Error: docker executable not found in PATH") + + +def maybe_build_image(image: str, args: argparse.Namespace) -> None: + context_dir = Path(__file__).resolve().parent + if not context_dir.is_dir(): + raise SystemExit(f"Error: missing docker build context: {context_dir}") + + image_exists = docker_image_exists(image) + context_changed = image_exists and docker_context_changed(context_dir, image) + + should_build = args.build_image + should_build = should_build or (args.build_if_missing and not image_exists) + should_build = should_build or context_changed + + if not should_build: + return + + if context_changed and not args.build_image: + print("Docker context changed since last image build, rebuilding...") + + print(f"Building docker image '{image}' from {context_dir} ...") + run_checked(["docker", "build", "-t", image, str(context_dir)]) + + +def docker_image_exists(image: str) -> bool: + result = subprocess.run( + ["docker", "image", "inspect", image], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return result.returncode == 0 + + +def docker_context_changed(context_dir: Path, image: str) -> bool: + image_created = docker_image_created_at(image) + if image_created is None: + return True + + context_mtime = newest_mtime(context_dir) + return context_mtime > image_created + + +def docker_image_created_at(image: str) -> float | None: + result = subprocess.run( + ["docker", "image", "inspect", image, "--format", "{{.Created}}"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + if result.returncode != 0: + return None + + created = result.stdout.strip() + if not created: + return None + + # Example: 2026-03-13T21:47:06.123456789Z + if created.endswith("Z"): + created = created[:-1] + if "." in created: + created = created.split(".", 1)[0] + + try: + dt = datetime.strptime(created, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc) + except ValueError: + return None + + return dt.timestamp() + + +def newest_mtime(path: Path) -> float: + latest = path.stat().st_mtime + for child in path.rglob("*"): + try: + mtime = child.stat().st_mtime + except OSError: + continue + if mtime > latest: + latest = mtime + return latest + + +def ensure_docker_volume(name: str) -> None: + result = subprocess.run( + ["docker", "volume", "inspect", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if result.returncode == 0: + return + + run_checked(["docker", "volume", "create", name]) + + +def resolve_test_target( + package_root: Path, + test_file: str | None, + tests_dir: str | None, + pattern: str | None, +) -> tuple[str | None, str | None]: + if not test_file: + return tests_dir, pattern + + file_path = Path(test_file) + if not file_path.is_absolute(): + file_path = package_root / file_path + file_path = file_path.resolve() + + if not file_path.is_file(): + raise SystemExit(f"Error: test file does not exist: {file_path}") + + try: + rel_file_path = file_path.relative_to(package_root) + except ValueError: + raise SystemExit(f"Error: file is outside package root: {file_path}") + + rel_parent = rel_file_path.parent.as_posix() + resolved_tests_dir = rel_parent if rel_parent else "." + resolved_pattern = rel_file_path.name + return resolved_tests_dir, resolved_pattern + + +def build_docker_run_command( + package_root: Path, + unit_testing_root: Path, + package_name: str, + image: str, + cache_volume: str | None, + sublime_text_version: int, + scheduler_delay_ms: int, + coverage: bool, + failfast: bool, + tests_dir: str | None, + pattern: str | None, +) -> list[str]: + command = ["docker", "run", "--rm", "-t"] + if sys.stdin.isatty(): + command.append("-i") + + command.extend(["-e", f"PACKAGE={package_name}"]) + command.extend(["-e", f"SUBLIME_TEXT_VERSION={sublime_text_version}"]) + command.extend(["-e", f"UNITTESTING_SCHEDULER_DELAY_MS={scheduler_delay_ms}"]) + command.extend(["-e", "UNITTESTING_SOURCE=/unittesting"]) + command.extend(["-e", "PYTHONUNBUFFERED=1"]) + command.extend(["-v", f"{package_root}:/project"]) + command.extend(["-v", f"{unit_testing_root}:/unittesting"]) + + if cache_volume: + command.extend(["-v", f"{cache_volume}:/root"]) + + command.append(image) + command.append("run_tests") + + if coverage: + command.append("--coverage") + + if failfast: + command.append("--failfast") + + if tests_dir: + command.extend(["--tests-dir", tests_dir]) + + if pattern: + command.extend(["--pattern", pattern]) + + return command + + +def run_checked(command: list[str]) -> None: + result = subprocess.run(command) + if result.returncode != 0: + raise SystemExit(result.returncode) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sbin/.python-version b/sbin/.python-version new file mode 100644 index 00000000..cc1923a4 --- /dev/null +++ b/sbin/.python-version @@ -0,0 +1 @@ +3.8 diff --git a/sbin/README.md b/sbin/README.md index fa672268..b82ab3d2 100644 --- a/sbin/README.md +++ b/sbin/README.md @@ -1,3 +1,6 @@ -## Deprecation +## Status -Scripts in this directory are deprecated and not maintained. Github [actions](https://github.com/SublimeText/UnitTesting/actions) are now the preferred way to use UnitTesting on GitHub. +For GitHub CI, prefer the official composite [actions](https://github.com/SublimeText/UnitTesting/actions). + +These scripts are still used for local/containerized automation (for example the Docker runner path used by `docker/run_tests.py`). +They should be treated as supported for that workflow. diff --git a/sbin/ci.sh b/sbin/ci.sh index 5f7fa900..b0ae90a8 100644 --- a/sbin/ci.sh +++ b/sbin/ci.sh @@ -116,14 +116,27 @@ cloneRepositoryTag() { CopyTestedPackage() { local OverwriteExisting="$1" + + if [ ! -d "$STP/$PACKAGE" ]; then + mkdir -p "$STP/$PACKAGE" + fi + + if [ -n "$OverwriteExisting" ] && command -v rsync >/dev/null 2>&1; then + echo "sync package into sublime package directory" + rsync -a --delete --exclude .git ./ "$STP/$PACKAGE/" + return + fi + if [ -d "$STP/$PACKAGE" ] && [ -n "$OverwriteExisting" ]; then rm -rf "${STP:?}/${PACKAGE:?}" + mkdir -p "$STP/$PACKAGE" fi - if [ ! -d "$STP/$PACKAGE" ]; then + + if [ ! -f "$STP/$PACKAGE/.package_copied" ] || [ -n "$OverwriteExisting" ]; then # symlink does not play well with coverage - echo "copy the package to sublime package directory" - mkdir -p "$STP/$PACKAGE" - cp -r ./ "$STP/$PACKAGE" + echo "copy package into sublime package directory" + cp -r ./. "$STP/$PACKAGE/" + touch "$STP/$PACKAGE/.package_copied" fi } diff --git a/sbin/install_package_control.sh b/sbin/install_package_control.sh index 8d7b8297..c5e3cb5c 100644 --- a/sbin/install_package_control.sh +++ b/sbin/install_package_control.sh @@ -49,17 +49,21 @@ fi if [ ! -f "$STP/User/Package Control.sublime-settings" ]; then echo creating Package Control.sublime-settings + [ ! -d "$STP/User" ] && mkdir -p "$STP/User" # make sure Pakcage Control does not complain echo '{"auto_upgrade": false, "ignore_vcs_packages": true, "remove_orphaned": false, "submit_usage": false }' > "$STP/User/Package Control.sublime-settings" fi PCH_PATH="$STP/0_install_package_control_helper" +BASE=`dirname "$0"` -if [ ! -d "$PCH_PATH" ]; then - mkdir -p "$PCH_PATH" - BASE=`dirname "$0"` - cp "$BASE/pc_helper.py" "$PCH_PATH/pc_helper.py" +mkdir -p "$PCH_PATH" +cp "$BASE/pc_helper.py" "$PCH_PATH/pc_helper.py" + +if [ -f "$BASE/.python-version" ]; then cp "$BASE/.python-version" "$PCH_PATH/.python-version" +else + cp "$BASE/../.python-version" "$PCH_PATH/.python-version" fi @@ -70,7 +74,7 @@ for i in {1..3}; do subl & - ENDTIME=$(( $(date +%s) + 60 )) + ENDTIME=$(( $(date +%s) + 120 )) while [ ! -f "$PCH_PATH/success" ] && [ $(date +%s) -lt $ENDTIME ] ; do printf "." sleep 5 diff --git a/sbin/run_tests.py b/sbin/run_tests.py index 6cd07a99..381a04b8 100644 --- a/sbin/run_tests.py +++ b/sbin/run_tests.py @@ -55,19 +55,24 @@ def create_schedule(package, output_file, default_schedule): except Exception: pass - if not any(s['package'] == package for s in schedule): - print('Schedule:') - for k, v in default_schedule.items(): - print(' %s: %s' % (k, v)) + print('Schedule:') + for k, v in default_schedule.items(): + print(' %s: %s' % (k, v)) + for idx, item in enumerate(schedule): + if item.get('package') == package: + schedule[idx] = default_schedule + break + else: schedule.append(default_schedule) with open(SCHEDULE_FILE_PATH, 'w') as f: f.write(json.dumps(schedule, ensure_ascii=False, indent=True)) -def wait_for_output(path, schedule, timeout=10): +def wait_for_output(path, schedule, timeout=10, poll_interval=0.2): start_time = time.time() + last_dot = 0 needs_newline = False def check_has_timed_out(): @@ -80,16 +85,19 @@ def check_is_output_available(): pass while not check_is_output_available(): - print(".", end="") - sys.stdout.flush() - needs_newline = True + now = time.time() + if now - last_dot >= 1: + print(".", end="") + sys.stdout.flush() + needs_newline = True + last_dot = now if check_has_timed_out(): print() delete_file_if_exists(schedule) raise ValueError('timeout') - time.sleep(1) + time.sleep(poll_interval) else: if needs_newline: print() @@ -123,6 +131,7 @@ def check_is_done(result): result = f.read() print(result, end="") + sys.stdout.flush() # Keep checking while we don't have a definite result. success = check_is_success(result) @@ -191,6 +200,9 @@ def main(default_schedule_info): parser.add_option('--syntax-compatibility', action='store_true') parser.add_option('--color-scheme-test', action='store_true') parser.add_option('--coverage', action='store_true') + parser.add_option('--pattern') + parser.add_option('--tests-dir') + parser.add_option('--failfast', action='store_true') options, remainder = parser.parse_args() @@ -208,4 +220,13 @@ def main(default_schedule_info): 'coverage': coverage, } + if options.pattern: + default_schedule_info['pattern'] = options.pattern + + if options.tests_dir: + default_schedule_info['tests_dir'] = options.tests_dir + + if options.failfast: + default_schedule_info['failfast'] = True + main(default_schedule_info) From 2da7524c2379defb6131d60b8dfedeef718be387 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 15 Mar 2026 09:45:22 +0100 Subject: [PATCH 06/19] Harden CRLF handling for Docker shell scripts Enforce LF line endings for docker and sbin shell scripts via .gitattributes so Linux container execution remains reliable. Also normalize ci.sh when sourced from mounted UnitTesting checkouts and normalize copied UnitTesting sbin scripts in the container as a runtime safety net for existing Windows working trees. --- .gitattributes | 6 +++++- docker/docker.sh | 13 +++++++------ docker/entrypoint.sh | 12 ++++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.gitattributes b/.gitattributes index 188fb2a6..a89755ea 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,11 +2,15 @@ # files detected as binary untouched. * text=auto +# Shell scripts used in Linux containers must stay LF in the working tree. +/docker/*.sh text eol=lf +/docker/xvfb text eol=lf +/sbin/*.sh text eol=lf + # Files and directories with the attribute export-ignore won’t be added to # archive files. See http://git-scm.com/docs/gitattributes for details. # Git -/.gitattributes export-ignore /.github/ export-ignore /.gitignore export-ignore diff --git a/docker/docker.sh b/docker/docker.sh index 25c880da..c08aa56d 100644 --- a/docker/docker.sh +++ b/docker/docker.sh @@ -5,12 +5,13 @@ set -e BASEDIR=`dirname $0` UNITTESTING_SOURCE=${UNITTESTING_SOURCE:-/unittesting} -CISH="$UNITTESTING_SOURCE/sbin/ci.sh" -if [ ! -f "$CISH" ]; then - CISH="/tmp/ci.sh" - if [ ! -f "$CISH" ]; then - curl -s -L https://raw.githubusercontent.com/SublimeText/UnitTesting/master/sbin/ci.sh -o "$CISH" - fi +SOURCE_CISH="$UNITTESTING_SOURCE/sbin/ci.sh" +CISH="/tmp/ci.sh" +if [ -f "$SOURCE_CISH" ]; then + # Normalize CRLF from mounted Windows checkouts. + sed 's/\r$//' "$SOURCE_CISH" > "$CISH" +elif [ ! -f "$CISH" ]; then + curl -s -L https://raw.githubusercontent.com/SublimeText/UnitTesting/master/sbin/ci.sh -o "$CISH" fi if [ -z "$PACKAGE" ]; then diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a7bded18..e5bacf6a 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -10,10 +10,22 @@ BOOTSTRAP_MARKER="$HOME/.cache/unittesting/bootstrap.done" sudo sh -e /etc/init.d/xvfb start UNITTESTING_SOURCE=${UNITTESTING_SOURCE:-/unittesting} +SUBLIME_TEXT_VERSION=${SUBLIME_TEXT_VERSION:-4} +if [ "$SUBLIME_TEXT_VERSION" -ge 4 ]; then + ST_PACKAGES_DIR="$HOME/.config/sublime-text/Packages" +else + ST_PACKAGES_DIR="$HOME/.config/sublime-text-$SUBLIME_TEXT_VERSION/Packages" +fi + if [ -d "$UNITTESTING_SOURCE/sbin" ]; then # Ensure UnitTesting comes from the local checkout running this script, # so first runs do not depend on tagged upstream releases. (cd "$UNITTESTING_SOURCE" && PACKAGE=UnitTesting /docker.sh copy_tested_package overwrite) + + # Normalize CRLF in shell scripts copied from Windows workspaces. + if [ -d "$ST_PACKAGES_DIR/UnitTesting/sbin" ]; then + find "$ST_PACKAGES_DIR/UnitTesting/sbin" -type f -name "*.sh" -exec sed -i 's/\r$//' {} + + fi fi if [ ! -f "$BOOTSTRAP_MARKER" ]; then From b98141cbc3adabd01ba63140d06a4f3811d4e457 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sat, 14 Mar 2026 17:09:19 +0100 Subject: [PATCH 07/19] Default docker scheduler delay to zero Set docker/run_tests.py --scheduler-delay-ms default to 0 to match observed local behavior where immediate scheduling is sufficient. Keep the option so callers can raise the delay explicitly if needed. --- README.md | 2 +- docker/run_tests.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 52302a3e..a63c5ee5 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Useful options: - `--failfast` - `--docker-image ` - `--no-cache-volume` -- `--scheduler-delay-ms 300` (lower can be faster) +- `--scheduler-delay-ms 0` (default) > [!TIP] > diff --git a/docker/run_tests.py b/docker/run_tests.py index 0179c0fd..018a8ffc 100644 --- a/docker/run_tests.py +++ b/docker/run_tests.py @@ -125,8 +125,8 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: parser.add_argument( "--scheduler-delay-ms", type=int, - default=300, - help="Delay before running scheduled tests inside Sublime (default: 300).", + default=0, + help="Delay before running scheduled tests inside Sublime (default: 0).", ) parser.add_argument( "--pull", From 81029d864a5e5a359bff672bcd9fb6e6f250918d Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 15 Mar 2026 10:16:27 +0100 Subject: [PATCH 08/19] Set default git identity inside Docker runner Configure a global git user.name and user.email in the local Docker entrypoint when they are missing. This makes local container runs behave more like GitHub CI for tests that create commits, while still allowing overrides via existing git config or UNITTESTING_GIT_USER_NAME/UNITTESTING_GIT_USER_EMAIL. --- docker/entrypoint.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index e5bacf6a..fb448a25 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -7,7 +7,20 @@ set -e PATH="$HOME/.local/bin:$PATH" BOOTSTRAP_MARKER="$HOME/.cache/unittesting/bootstrap.done" +ensure_git_identity() { + # Align local container behavior with typical CI runners where git + # identity is configured for tests that create commits. + if ! git config --global user.name >/dev/null 2>&1; then + git config --global user.name "${UNITTESTING_GIT_USER_NAME:-UnitTesting CI}" + fi + + if ! git config --global user.email >/dev/null 2>&1; then + git config --global user.email "${UNITTESTING_GIT_USER_EMAIL:-unittesting@example.invalid}" + fi +} + sudo sh -e /etc/init.d/xvfb start +ensure_git_identity UNITTESTING_SOURCE=${UNITTESTING_SOURCE:-/unittesting} SUBLIME_TEXT_VERSION=${SUBLIME_TEXT_VERSION:-4} From 52de79e93b8aaca1a125e4e8ee2043136f888eff Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 15 Mar 2026 10:18:44 +0100 Subject: [PATCH 09/19] Set TRAVIS_OS_NAME fallback in Docker runner Export TRAVIS_OS_NAME from uname when unset so older test suites that still use Travis-style platform checks behave consistently in local container runs. This keeps existing values intact if TRAVIS_OS_NAME is already provided. --- docker/entrypoint.sh | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index fb448a25..faf710db 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -19,8 +19,28 @@ ensure_git_identity() { fi } +ensure_ci_platform_compat() { + # Some test suites still key off Travis-style OS markers. + if [ -n "$TRAVIS_OS_NAME" ]; then + return + fi + + case "$(uname -s)" in + Linux*) + export TRAVIS_OS_NAME=linux + ;; + Darwin*) + export TRAVIS_OS_NAME=osx + ;; + CYGWIN*|MINGW*|MSYS*) + export TRAVIS_OS_NAME=windows + ;; + esac +} + sudo sh -e /etc/init.d/xvfb start ensure_git_identity +ensure_ci_platform_compat UNITTESTING_SOURCE=${UNITTESTING_SOURCE:-/unittesting} SUBLIME_TEXT_VERSION=${SUBLIME_TEXT_VERSION:-4} From e2d22faaee7346ca9d0f5405908c3b964556c689 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 15 Mar 2026 10:20:10 +0100 Subject: [PATCH 10/19] Set RUNNER_OS fallback in Docker entrypoint Populate RUNNER_OS from uname when unset, alongside TRAVIS_OS_NAME, so test suites using GitHub Actions-style OS detection behave consistently in local container runs. --- docker/entrypoint.sh | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index faf710db..945dde83 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -20,22 +20,33 @@ ensure_git_identity() { } ensure_ci_platform_compat() { - # Some test suites still key off Travis-style OS markers. - if [ -n "$TRAVIS_OS_NAME" ]; then - return - fi + # Some test suites key off Travis-style OS markers while others expect + # GitHub Actions' RUNNER_OS naming. + local travis_name="" + local runner_name="" case "$(uname -s)" in Linux*) - export TRAVIS_OS_NAME=linux + travis_name="linux" + runner_name="Linux" ;; Darwin*) - export TRAVIS_OS_NAME=osx + travis_name="osx" + runner_name="macOS" ;; CYGWIN*|MINGW*|MSYS*) - export TRAVIS_OS_NAME=windows + travis_name="windows" + runner_name="Windows" ;; esac + + if [ -n "$travis_name" ] && [ -z "$TRAVIS_OS_NAME" ]; then + export TRAVIS_OS_NAME="$travis_name" + fi + + if [ -n "$runner_name" ] && [ -z "$RUNNER_OS" ]; then + export RUNNER_OS="$runner_name" + fi } sudo sh -e /etc/init.d/xvfb start From e76790dd22895efb0d9eed3373d2444dc2250423 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 15 Mar 2026 12:08:52 +0100 Subject: [PATCH 11/19] Add stable ut-run-tests launcher scripts Add docker/ut-run-tests and docker/ut-run-tests.cmd as simple, stable entrypoints for local containerized runs without uv project coupling. Update docs to recommend the launcher command and keep run_tests.py as the underlying implementation detail. --- .gitattributes | 1 + README.md | 22 +++++++---- docker/README.md | 28 +++++++++++--- docker/run_tests.py | 86 ++++++++++++++++++++++------------------- docker/ut-run-tests | 5 +++ docker/ut-run-tests.cmd | 4 ++ sbin/README.md | 6 ++- 7 files changed, 98 insertions(+), 54 deletions(-) create mode 100644 docker/ut-run-tests create mode 100644 docker/ut-run-tests.cmd diff --git a/.gitattributes b/.gitattributes index a89755ea..ae31bf26 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,6 +4,7 @@ # Shell scripts used in Linux containers must stay LF in the working tree. /docker/*.sh text eol=lf +/docker/ut-run-tests text eol=lf /docker/xvfb text eol=lf /sbin/*.sh text eol=lf diff --git a/README.md b/README.md index a63c5ee5..d6cbcee0 100644 --- a/README.md +++ b/README.md @@ -115,18 +115,27 @@ so that ctrl+b would invoke the testing action. ### Headless container runner To run tests without affecting your interactive Sublime Text session, -use the bundled `docker/run_tests.py` script. +use the bundled `docker/ut-run-tests` launcher. ```sh # run all tests from current package root -uv run docker/run_tests.py . +/path/to/UnitTesting/docker/ut-run-tests . # run just one test file (faster) -uv run docker/run_tests.py . --file tests/test_example.py +/path/to/UnitTesting/docker/ut-run-tests . --file tests/test_example.py ``` -This script runs tests in a Docker container (headless), streams output to -stdout/stderr and keeps a cache volume so repeated runs are fast. +(Use `docker/ut-run-tests.cmd` in cmd.exe/PowerShell.) + +If `UnitTesting/docker` is on your `PATH`, you can simply run: + +```sh +ut-run-tests . +``` + +This launcher calls `docker/run_tests.py`, which runs tests in a Docker +container (headless), streams output to stdout/stderr and keeps a cache +volume so repeated runs are fast. By default it: @@ -142,9 +151,6 @@ Useful options: - `--pattern test_foo.py --tests-dir tests/subdir` - `--coverage` - `--failfast` -- `--docker-image ` -- `--no-cache-volume` -- `--scheduler-delay-ms 0` (default) > [!TIP] > diff --git a/docker/README.md b/docker/README.md index ba09de8d..45e86c78 100644 --- a/docker/README.md +++ b/docker/README.md @@ -2,15 +2,33 @@ ## Recommended usage -Use the docker wrapper script: +Use the launcher script: ```sh -uv run docker/run_tests.py /path/to/package -uv run docker/run_tests.py /path/to/package --file tests/test_example.py +# from UnitTesting repo root +./docker/ut-run-tests /path/to/package +./docker/ut-run-tests /path/to/package --file tests/test_example.py ``` -It builds/uses a local image, mounts the package at `/project`, runs tests -headlessly, and keeps a cache volume for fast reruns. +Or call it via absolute path from any package directory: + +```sh +/path/to/UnitTesting/docker/ut-run-tests . +``` + +If this directory is on your `PATH`, you can run `ut-run-tests` directly. + +The launcher calls `docker/run_tests.py`, builds/uses a local image, +mounts the package at `/project`, runs tests headlessly, and keeps a cache +volume for fast reruns. + +By default it: + +- builds `unittesting-local` image from `./docker` if missing +- mounts your repo as `/project` +- runs UnitTesting through the same CI shell entrypoints +- stores Sublime install/cache in docker volume `unittesting-home` +- synchronizes only changed files into `Packages/` using `rsync` ## Manual docker usage diff --git a/docker/run_tests.py b/docker/run_tests.py index 018a8ffc..24db0036 100644 --- a/docker/run_tests.py +++ b/docker/run_tests.py @@ -1,15 +1,16 @@ #!/usr/bin/env python3 """Run Sublime Text UnitTesting in a Docker container. +Usually invoked via the sibling launcher script `ut-run-tests`. Examples: - uv run docker/run_tests.py . - uv run docker/run_tests.py . --file tests/test_main.py + ./docker/ut-run-tests . + ./docker/ut-run-tests . --file tests/test_main.py """ from __future__ import annotations import argparse -from datetime import datetime, timezone +import hashlib import shutil import subprocess import sys @@ -18,6 +19,13 @@ DEFAULT_IMAGE = "unittesting-local" DEFAULT_CACHE_VOLUME = "unittesting-home" +DOCKER_CONTEXT_HASH_LABEL = "org.sublimetext.unittesting.context-hash" +DOCKER_CONTEXT_INPUTS = ( + "Dockerfile", + "docker.sh", + "entrypoint.sh", + "xvfb", +) def main(argv: list[str] | None = None) -> int: @@ -171,8 +179,10 @@ def maybe_build_image(image: str, args: argparse.Namespace) -> None: if not context_dir.is_dir(): raise SystemExit(f"Error: missing docker build context: {context_dir}") + context_hash = docker_context_hash(context_dir) image_exists = docker_image_exists(image) - context_changed = image_exists and docker_context_changed(context_dir, image) + image_hash = docker_image_context_hash(image) if image_exists else None + context_changed = image_exists and image_hash != context_hash should_build = args.build_image should_build = should_build or (args.build_if_missing and not image_exists) @@ -185,7 +195,15 @@ def maybe_build_image(image: str, args: argparse.Namespace) -> None: print("Docker context changed since last image build, rebuilding...") print(f"Building docker image '{image}' from {context_dir} ...") - run_checked(["docker", "build", "-t", image, str(context_dir)]) + run_checked([ + "docker", + "build", + "--label", + f"{DOCKER_CONTEXT_HASH_LABEL}={context_hash}", + "-t", + image, + str(context_dir), + ]) def docker_image_exists(image: str) -> bool: @@ -197,18 +215,31 @@ def docker_image_exists(image: str) -> bool: return result.returncode == 0 -def docker_context_changed(context_dir: Path, image: str) -> bool: - image_created = docker_image_created_at(image) - if image_created is None: - return True +def docker_context_hash(context_dir: Path) -> str: + digest = hashlib.sha256() + for rel_path in DOCKER_CONTEXT_INPUTS: + file_path = context_dir / rel_path + if not file_path.is_file(): + raise SystemExit(f"Error: missing docker context file: {file_path}") - context_mtime = newest_mtime(context_dir) - return context_mtime > image_created + digest.update(rel_path.encode("utf-8")) + digest.update(b"\0") + digest.update(file_path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() -def docker_image_created_at(image: str) -> float | None: + +def docker_image_context_hash(image: str) -> str | None: result = subprocess.run( - ["docker", "image", "inspect", image, "--format", "{{.Created}}"], + [ + "docker", + "image", + "inspect", + image, + "--format", + "{{ index .Config.Labels \"%s\" }}" % DOCKER_CONTEXT_HASH_LABEL, + ], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, @@ -216,34 +247,11 @@ def docker_image_created_at(image: str) -> float | None: if result.returncode != 0: return None - created = result.stdout.strip() - if not created: - return None - - # Example: 2026-03-13T21:47:06.123456789Z - if created.endswith("Z"): - created = created[:-1] - if "." in created: - created = created.split(".", 1)[0] - - try: - dt = datetime.strptime(created, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc) - except ValueError: + value = result.stdout.strip() + if not value or value == "": return None - return dt.timestamp() - - -def newest_mtime(path: Path) -> float: - latest = path.stat().st_mtime - for child in path.rglob("*"): - try: - mtime = child.stat().st_mtime - except OSError: - continue - if mtime > latest: - latest = mtime - return latest + return value def ensure_docker_volume(name: str) -> None: diff --git a/docker/ut-run-tests b/docker/ut-run-tests new file mode 100644 index 00000000..6a857f7f --- /dev/null +++ b/docker/ut-run-tests @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +exec python "$SCRIPT_DIR/run_tests.py" "$@" diff --git a/docker/ut-run-tests.cmd b/docker/ut-run-tests.cmd new file mode 100644 index 00000000..69108f02 --- /dev/null +++ b/docker/ut-run-tests.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set "SCRIPT_DIR=%~dp0" +python "%SCRIPT_DIR%run_tests.py" %* diff --git a/sbin/README.md b/sbin/README.md index b82ab3d2..1a2481cf 100644 --- a/sbin/README.md +++ b/sbin/README.md @@ -1,6 +1,8 @@ ## Status -For GitHub CI, prefer the official composite [actions](https://github.com/SublimeText/UnitTesting/actions). +For GitHub CI, prefer the official composite +[actions](https://github.com/SublimeText/UnitTesting/actions). -These scripts are still used for local/containerized automation (for example the Docker runner path used by `docker/run_tests.py`). +These scripts are still used for local/containerized automation +(for example the Docker runner path used by `docker/ut-run-tests`). They should be treated as supported for that workflow. From dd9da4b27eb42f3109e9eef6d71481c39e8888b4 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 15 Mar 2026 12:24:25 +0100 Subject: [PATCH 12/19] Simplify Docker runner refresh controls Replace low-level image build/pull knobs with user-facing lifecycle flags: - --refresh-image rebuilds the local image - --reset-cache recreates the cache volume - --fresh does both Keep automatic rebuilds when the docker context changes or the image is missing, and document how to refresh Sublime/Package Control state without direct docker commands. --- README.md | 5 ++ docker/README.md | 17 ++++++ docker/run_tests.py | 125 +++++++++++++++++++++++++++----------------- 3 files changed, 98 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index d6cbcee0..cddba3f5 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,11 @@ Useful options: - `--pattern test_foo.py --tests-dir tests/subdir` - `--coverage` - `--failfast` +- `--scheduler-delay-ms 0` (default) +- `--refresh-cache` (re-bootstrap cached `/root` state) +- `--refresh-image` (rebuild local Docker image) +- `--refresh` (both cache and image refresh) +- `--no-cache-volume` (run without persistent cache) > [!TIP] > diff --git a/docker/README.md b/docker/README.md index 45e86c78..93d5d0cf 100644 --- a/docker/README.md +++ b/docker/README.md @@ -50,6 +50,23 @@ The container entrypoint writes a marker in `/root/.cache/unittesting`. With `-v unittesting-home:/root`, bootstrap/install runs once and later runs only refresh your package files and execute tests. +## Refresh/update controls (without direct docker commands) + +Use launcher flags instead of calling `docker` manually: + +- `--refresh-cache`: recreate `unittesting-home` cache volume (forces fresh + bootstrap, including Sublime Text/Package Control install path) +- `--refresh-image`: rebuild local image (for Dockerfile/entrypoint changes) +- `--refresh`: both `--refresh-cache` and `--refresh-image` + +Examples: + +```sh +ut-run-tests . --refresh-image +ut-run-tests . --refresh-cache +ut-run-tests . --refresh +``` + ## Run a single test file ```sh diff --git a/docker/run_tests.py b/docker/run_tests.py index 24db0036..6eae6460 100644 --- a/docker/run_tests.py +++ b/docker/run_tests.py @@ -32,6 +32,23 @@ def main(argv: list[str] | None = None) -> int: args = parse_args(argv) ensure_docker() + if args.refresh: + args.refresh_image = True + args.refresh_cache = True + + image = args.docker_image + + if args.refresh_image or args.refresh_cache: + if args.refresh_image: + maybe_build_image(image, refresh=True) + + if args.cache_volume and args.refresh_cache: + reset_docker_volume(args.cache_volume) + ensure_docker_volume(args.cache_volume) + + print("Refresh complete.") + return 0 + package_root = args.package_root.resolve() if not package_root.is_dir(): print(f"Error: package root does not exist: {package_root}", file=sys.stderr) @@ -50,11 +67,7 @@ def main(argv: list[str] | None = None) -> int: package_root, args.file, args.tests_dir, args.pattern ) - image = args.docker_image - maybe_build_image(image, args) - - if args.pull: - run_checked(["docker", "pull", image]) + maybe_build_image(image, refresh=False) if args.cache_volume: ensure_docker_volume(args.cache_volume) @@ -77,8 +90,12 @@ def main(argv: list[str] | None = None) -> int: print(f"Package name: {package_name}") print(f"Docker image: {image}") print(f"Scheduler delay: {args.scheduler_delay_ms}ms") + if args.refresh_image: + print("Image refresh: enabled") if args.cache_volume: print(f"Cache volume: {args.cache_volume}") + if args.refresh_cache: + print("Cache refresh: enabled") if tests_dir and pattern: print(f"Test target: {tests_dir}/{pattern}") @@ -97,19 +114,38 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: type=Path, help="Path to the package root (default: current directory).", ) - parser.add_argument("--file", help="Run only tests from this file.") - parser.add_argument("--pattern", help="Custom unittest discovery pattern.") - parser.add_argument("--tests-dir", help="Custom tests directory.") - parser.add_argument("--package-name", help="Override package name.") - parser.add_argument("--coverage", action="store_true", help="Enable coverage.") - parser.add_argument("--failfast", action="store_true", help="Stop on first failure.") - parser.add_argument( + test_group = parser.add_argument_group("test options") + test_group.add_argument("--file", help="Run only tests from this file.") + test_group.add_argument("--pattern", help="Custom unittest discovery pattern.") + test_group.add_argument("--tests-dir", help="Custom tests directory.") + test_group.add_argument("--package-name", help="Override package name.") + test_group.add_argument("--coverage", action="store_true", help="Enable coverage.") + test_group.add_argument("--failfast", action="store_true", help="Stop on first failure.") + test_group.add_argument( + "--scheduler-delay-ms", + type=int, + default=0, + help="Delay before running scheduled tests inside Sublime (default: 0).", + ) + + docker_group = parser.add_argument_group("docker options") + docker_group.add_argument( + "--refresh", + action="store_true", + help="Rebuild image and recreate cache volume.", + ) + docker_group.add_argument( "--docker-image", default=DEFAULT_IMAGE, help=f"Docker image to run (default: {DEFAULT_IMAGE}).", ) - parser.add_argument( + docker_group.add_argument( + "--refresh-image", + action="store_true", + help="Rebuild the local Docker image.", + ) + docker_group.add_argument( "--cache-volume", default=DEFAULT_CACHE_VOLUME, help=( @@ -117,48 +153,26 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: f"(default: {DEFAULT_CACHE_VOLUME})." ), ) - parser.add_argument( + docker_group.add_argument( "--no-cache-volume", dest="cache_volume", action="store_const", const=None, help="Disable persistent cache volume.", ) - parser.add_argument( + docker_group.add_argument( "--sublime-text-version", type=int, default=4, help="Sublime Text major version inside container.", ) - parser.add_argument( - "--scheduler-delay-ms", - type=int, - default=0, - help="Delay before running scheduled tests inside Sublime (default: 0).", - ) - parser.add_argument( - "--pull", - action="store_true", - help="Pull docker image before running.", - ) - - parser.add_argument( - "--build-image", + docker_group.add_argument( + "--refresh-cache", action="store_true", - help="Force rebuild of local docker image from script directory.", - ) - parser.add_argument( - "--build-if-missing", - dest="build_if_missing", - action="store_true", - default=True, - help="Build image from script directory if missing (default: true).", - ) - parser.add_argument( - "--no-build-if-missing", - dest="build_if_missing", - action="store_false", - help="Do not auto-build image if missing.", + help=( + "Recreate the cache volume so Sublime Text and Package Control " + "are re-installed." + ), ) args = parser.parse_args(argv) @@ -166,6 +180,9 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: if args.file and args.pattern: parser.error("--file and --pattern are mutually exclusive") + if args.refresh_cache and not args.cache_volume: + parser.error("--refresh-cache requires a cache volume (omit --no-cache-volume)") + return args @@ -174,7 +191,7 @@ def ensure_docker() -> None: raise SystemExit("Error: docker executable not found in PATH") -def maybe_build_image(image: str, args: argparse.Namespace) -> None: +def maybe_build_image(image: str, refresh: bool) -> None: context_dir = Path(__file__).resolve().parent if not context_dir.is_dir(): raise SystemExit(f"Error: missing docker build context: {context_dir}") @@ -184,14 +201,11 @@ def maybe_build_image(image: str, args: argparse.Namespace) -> None: image_hash = docker_image_context_hash(image) if image_exists else None context_changed = image_exists and image_hash != context_hash - should_build = args.build_image - should_build = should_build or (args.build_if_missing and not image_exists) - should_build = should_build or context_changed - + should_build = refresh or not image_exists or context_changed if not should_build: return - if context_changed and not args.build_image: + if context_changed and not refresh: print("Docker context changed since last image build, rebuilding...") print(f"Building docker image '{image}' from {context_dir} ...") @@ -266,6 +280,19 @@ def ensure_docker_volume(name: str) -> None: run_checked(["docker", "volume", "create", name]) +def reset_docker_volume(name: str) -> None: + result = subprocess.run( + ["docker", "volume", "inspect", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if result.returncode != 0: + return + + print(f"Resetting cache volume: {name}") + run_checked(["docker", "volume", "rm", name]) + + def resolve_test_target( package_root: Path, test_file: str | None, From 0a6a0c7a602fcff0ad38999824e1653b93dec29a Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 15 Mar 2026 13:01:39 +0100 Subject: [PATCH 13/19] Drop "--sublime-text-version" as that's not really useful --- docker/run_tests.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/docker/run_tests.py b/docker/run_tests.py index 6eae6460..ac35a24b 100644 --- a/docker/run_tests.py +++ b/docker/run_tests.py @@ -78,7 +78,6 @@ def main(argv: list[str] | None = None) -> int: package_name=package_name, image=image, cache_volume=args.cache_volume, - sublime_text_version=args.sublime_text_version, scheduler_delay_ms=args.scheduler_delay_ms, coverage=args.coverage, failfast=args.failfast, @@ -160,12 +159,6 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: const=None, help="Disable persistent cache volume.", ) - docker_group.add_argument( - "--sublime-text-version", - type=int, - default=4, - help="Sublime Text major version inside container.", - ) docker_group.add_argument( "--refresh-cache", action="store_true", @@ -327,7 +320,6 @@ def build_docker_run_command( package_name: str, image: str, cache_volume: str | None, - sublime_text_version: int, scheduler_delay_ms: int, coverage: bool, failfast: bool, @@ -339,7 +331,6 @@ def build_docker_run_command( command.append("-i") command.extend(["-e", f"PACKAGE={package_name}"]) - command.extend(["-e", f"SUBLIME_TEXT_VERSION={sublime_text_version}"]) command.extend(["-e", f"UNITTESTING_SCHEDULER_DELAY_MS={scheduler_delay_ms}"]) command.extend(["-e", "UNITTESTING_SOURCE=/unittesting"]) command.extend(["-e", "PYTHONUNBUFFERED=1"]) From 3bb98540e1b95c73b584242d158b9d10460620fb Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 15 Mar 2026 13:24:45 +0100 Subject: [PATCH 14/19] Expose reload_package_on_testing in local runner Add --reload-package-on-testing to docker/sbin local runner CLI and pass it through to scheduled test options. Set reload_package_on_testing to false by default in generated schedule entries, so local runs avoid package reload unless explicitly requested. --- README.md | 1 + docker/run_tests.py | 10 ++++++++++ sbin/run_tests.py | 5 +++++ 3 files changed, 16 insertions(+) diff --git a/README.md b/README.md index cddba3f5..83b9ef17 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ Useful options: - `--pattern test_foo.py --tests-dir tests/subdir` - `--coverage` - `--failfast` +- `--reload-package-on-testing` (default: off) - `--scheduler-delay-ms 0` (default) - `--refresh-cache` (re-bootstrap cached `/root` state) - `--refresh-image` (rebuild local Docker image) diff --git a/docker/run_tests.py b/docker/run_tests.py index ac35a24b..24cd90bc 100644 --- a/docker/run_tests.py +++ b/docker/run_tests.py @@ -81,6 +81,7 @@ def main(argv: list[str] | None = None) -> int: scheduler_delay_ms=args.scheduler_delay_ms, coverage=args.coverage, failfast=args.failfast, + reload_package_on_testing=args.reload_package_on_testing, tests_dir=tests_dir, pattern=pattern, ) @@ -121,6 +122,11 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: test_group.add_argument("--package-name", help="Override package name.") test_group.add_argument("--coverage", action="store_true", help="Enable coverage.") test_group.add_argument("--failfast", action="store_true", help="Stop on first failure.") + test_group.add_argument( + "--reload-package-on-testing", + action="store_true", + help="Reload package under test before running tests.", + ) test_group.add_argument( "--scheduler-delay-ms", type=int, @@ -323,6 +329,7 @@ def build_docker_run_command( scheduler_delay_ms: int, coverage: bool, failfast: bool, + reload_package_on_testing: bool, tests_dir: str | None, pattern: str | None, ) -> list[str]: @@ -349,6 +356,9 @@ def build_docker_run_command( if failfast: command.append("--failfast") + if reload_package_on_testing: + command.append("--reload-package-on-testing") + if tests_dir: command.extend(["--tests-dir", tests_dir]) diff --git a/sbin/run_tests.py b/sbin/run_tests.py index 381a04b8..da7044bd 100644 --- a/sbin/run_tests.py +++ b/sbin/run_tests.py @@ -203,6 +203,7 @@ def main(default_schedule_info): parser.add_option('--pattern') parser.add_option('--tests-dir') parser.add_option('--failfast', action='store_true') + parser.add_option('--reload-package-on-testing', action='store_true') options, remainder = parser.parse_args() @@ -218,6 +219,7 @@ def main(default_schedule_info): 'syntax_compatibility': syntax_compatibility, 'color_scheme_test': color_scheme_test, 'coverage': coverage, + 'reload_package_on_testing': False, } if options.pattern: @@ -229,4 +231,7 @@ def main(default_schedule_info): if options.failfast: default_schedule_info['failfast'] = True + if options.reload_package_on_testing: + default_schedule_info['reload_package_on_testing'] = True + main(default_schedule_info) From 5ea136f4e57451ec1c4f0b375f0a88ae43f6add3 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 15 Mar 2026 13:35:34 +0100 Subject: [PATCH 15/19] Log runtime metadata before scheduled test runs Print static runtime metadata in sbin/run_tests.py before writing and executing the schedule: - Sublime Text version (via `subl --version`) - Package Control version (from installed package metadata) This makes local run logs self-describing for troubleshooting and future build/version freshness checks. --- sbin/run_tests.py | 52 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/sbin/run_tests.py b/sbin/run_tests.py index da7044bd..2b99a1cd 100644 --- a/sbin/run_tests.py +++ b/sbin/run_tests.py @@ -15,6 +15,7 @@ import subprocess import sys import time +import zipfile # todo: allow different sublime versions @@ -157,6 +158,55 @@ def restore_coverage_file(path, package): f.write(txt) +def print_runtime_metadata(): + sublime_text_version = detect_sublime_text_version() + package_control_version = detect_package_control_version() + + print("Runtime:") + print(" Sublime Text: {}".format(sublime_text_version or "unknown")) + print(" Package Control: {}".format(package_control_version or "unknown")) + + +def detect_sublime_text_version(): + try: + output = subprocess.check_output( + ["subl", "--version"], + stderr=subprocess.STDOUT, + universal_newlines=True, + ) + except Exception: + return None + + for line in output.splitlines(): + line = line.strip() + if line: + return line + + return None + + +def detect_package_control_version(): + installed_packages_dir = os.path.join( + os.path.dirname(PACKAGES_DIR_PATH), + "Installed Packages", + ) + package_path = os.path.join( + installed_packages_dir, + "Package Control.sublime-package", + ) + if not os.path.isfile(package_path): + return None + + try: + with zipfile.ZipFile(package_path, "r") as package_zip: + metadata = json.loads(package_zip.read("package-metadata.json").decode("utf-8")) + except Exception: + return None + + version = metadata.get("version") if isinstance(metadata, dict) else None + return str(version) if version else None + + def main(default_schedule_info): package_under_test = default_schedule_info['package'] output_dir = os.path.join(UT_OUTPUT_DIR_PATH, package_under_test) @@ -165,6 +215,8 @@ def main(default_schedule_info): default_schedule_info['output'] = output_file + print_runtime_metadata() + for i in range(3): create_dir_if_not_exists(output_dir) delete_file_if_exists(output_file) From d06f45dd1876055e39540b949a068e8ff84d7050 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 15 Mar 2026 13:51:47 +0100 Subject: [PATCH 16/19] Add --dry-run mode for metadata and schedule output Add a local runner dry-run mode that prints runtime metadata and the generated schedule without executing tests. Also route the option through docker -> sbin runner and document it in README files. --- README.md | 1 + docker/README.md | 9 +++++++++ docker/run_tests.py | 13 +++++++++++++ sbin/run_tests.py | 12 ++++++++++-- 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 83b9ef17..618f7915 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ Useful options: - `--coverage` - `--failfast` - `--reload-package-on-testing` (default: off) +- `--dry-run` (only print runtime metadata and schedule) - `--scheduler-delay-ms 0` (default) - `--refresh-cache` (re-bootstrap cached `/root` state) - `--refresh-image` (rebuild local Docker image) diff --git a/docker/README.md b/docker/README.md index 93d5d0cf..5f32ea84 100644 --- a/docker/README.md +++ b/docker/README.md @@ -67,6 +67,15 @@ ut-run-tests . --refresh-cache ut-run-tests . --refresh ``` +## Dry run (metadata and schedule only) + +Use `--dry-run` to print runner metadata (including detected Sublime +Text and Package Control versions) plus the generated schedule. + +```sh +ut-run-tests . --dry-run +``` + ## Run a single test file ```sh diff --git a/docker/run_tests.py b/docker/run_tests.py index 24cd90bc..2cf3782c 100644 --- a/docker/run_tests.py +++ b/docker/run_tests.py @@ -82,6 +82,7 @@ def main(argv: list[str] | None = None) -> int: coverage=args.coverage, failfast=args.failfast, reload_package_on_testing=args.reload_package_on_testing, + dry_run=args.dry_run, tests_dir=tests_dir, pattern=pattern, ) @@ -133,6 +134,11 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: default=0, help="Delay before running scheduled tests inside Sublime (default: 0).", ) + test_group.add_argument( + "--dry-run", + action="store_true", + help="Only print runtime metadata and schedule.", + ) docker_group = parser.add_argument_group("docker options") docker_group.add_argument( @@ -182,6 +188,9 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: if args.refresh_cache and not args.cache_volume: parser.error("--refresh-cache requires a cache volume (omit --no-cache-volume)") + if args.dry_run and (args.refresh or args.refresh_image or args.refresh_cache): + parser.error("--dry-run cannot be combined with --refresh* options") + return args @@ -330,6 +339,7 @@ def build_docker_run_command( coverage: bool, failfast: bool, reload_package_on_testing: bool, + dry_run: bool, tests_dir: str | None, pattern: str | None, ) -> list[str]: @@ -359,6 +369,9 @@ def build_docker_run_command( if reload_package_on_testing: command.append("--reload-package-on-testing") + if dry_run: + command.append("--dry-run") + if tests_dir: command.extend(["--tests-dir", tests_dir]) diff --git a/sbin/run_tests.py b/sbin/run_tests.py index 2b99a1cd..7c4d327f 100644 --- a/sbin/run_tests.py +++ b/sbin/run_tests.py @@ -207,7 +207,7 @@ def detect_package_control_version(): return str(version) if version else None -def main(default_schedule_info): +def main(default_schedule_info, dry_run=False): package_under_test = default_schedule_info['package'] output_dir = os.path.join(UT_OUTPUT_DIR_PATH, package_under_test) output_file = os.path.join(output_dir, "result") @@ -217,6 +217,13 @@ def main(default_schedule_info): print_runtime_metadata() + if dry_run: + create_dir_if_not_exists(output_dir) + delete_file_if_exists(output_file) + delete_file_if_exists(coverage_file) + create_schedule(package_under_test, output_file, default_schedule_info) + return + for i in range(3): create_dir_if_not_exists(output_dir) delete_file_if_exists(output_file) @@ -256,6 +263,7 @@ def main(default_schedule_info): parser.add_option('--tests-dir') parser.add_option('--failfast', action='store_true') parser.add_option('--reload-package-on-testing', action='store_true') + parser.add_option('--dry-run', action='store_true') options, remainder = parser.parse_args() @@ -286,4 +294,4 @@ def main(default_schedule_info): if options.reload_package_on_testing: default_schedule_info['reload_package_on_testing'] = True - main(default_schedule_info) + main(default_schedule_info, dry_run=options.dry_run) From 00f15ad9a81cf1c9f643b72f40606761709e28f1 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 15 Mar 2026 15:34:38 +0100 Subject: [PATCH 17/19] Tune README --- README.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/README.md b/README.md index 618f7915..5c00bdcb 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,6 @@ use the bundled `docker/ut-run-tests` launcher. /path/to/UnitTesting/docker/ut-run-tests . --file tests/test_example.py ``` -(Use `docker/ut-run-tests.cmd` in cmd.exe/PowerShell.) - If `UnitTesting/docker` is on your `PATH`, you can simply run: ```sh @@ -137,14 +135,6 @@ This launcher calls `docker/run_tests.py`, which runs tests in a Docker container (headless), streams output to stdout/stderr and keeps a cache volume so repeated runs are fast. -By default it: - -- builds `unittesting-local` image from `./docker` if missing -- mounts your repo as `/project` -- runs UnitTesting through the same CI shell entrypoints -- stores Sublime install/cache in docker volume `unittesting-home` -- synchronizes only changed files into `Packages/` using `rsync` - Useful options: - `--file tests/test_foo.py` @@ -152,8 +142,8 @@ Useful options: - `--coverage` - `--failfast` - `--reload-package-on-testing` (default: off) -- `--dry-run` (only print runtime metadata and schedule) - `--scheduler-delay-ms 0` (default) +- `--dry-run` (only print runtime metadata and schedule) - `--refresh-cache` (re-bootstrap cached `/root` state) - `--refresh-image` (rebuild local Docker image) - `--refresh` (both cache and image refresh) From de69ca385028fe6a420fbeb72639ee1d23037b16 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Mon, 16 Mar 2026 12:00:30 +0100 Subject: [PATCH 18/19] Handle Docker TTY flags in non-interactive runs Only pass -i when stdin is a TTY and -t when stdout is a TTY. This prevents `docker run` from failing with "the input device is not a TTY" when the local runner is executed from non-interactive environments (like the agent harness) while preserving interactive behavior in regular terminals. --- docker/run_tests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker/run_tests.py b/docker/run_tests.py index 2cf3782c..a81beaa5 100644 --- a/docker/run_tests.py +++ b/docker/run_tests.py @@ -343,9 +343,11 @@ def build_docker_run_command( tests_dir: str | None, pattern: str | None, ) -> list[str]: - command = ["docker", "run", "--rm", "-t"] + command = ["docker", "run", "--rm"] if sys.stdin.isatty(): command.append("-i") + if sys.stdout.isatty(): + command.append("-t") command.extend(["-e", f"PACKAGE={package_name}"]) command.extend(["-e", f"UNITTESTING_SCHEDULER_DELAY_MS={scheduler_delay_ms}"]) From 3aa4dfc62dc28eb21b23199818e103222b93e4f4 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Mon, 16 Mar 2026 12:33:06 +0100 Subject: [PATCH 19/19] Add optional colorized docker test output Add a --color flag to the local docker runner and pass it through to sbin/run_tests.py. Implement ANSI colorization for streamed unittest output so per-test statuses and final summaries are easier to scan: - ok in green - skipped in yellow - FAIL/ERROR in red - "Ran ..." summary in cyan Color mode supports auto/always/never and respects NO_COLOR, FORCE_COLOR, and CLICOLOR_FORCE in auto mode. Keep non-color mode as a fast raw passthrough path. --- docker/README.md | 12 +++++ docker/run_tests.py | 10 ++++ sbin/run_tests.py | 119 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 135 insertions(+), 6 deletions(-) diff --git a/docker/README.md b/docker/README.md index 5f32ea84..b3b33e13 100644 --- a/docker/README.md +++ b/docker/README.md @@ -76,6 +76,18 @@ Text and Package Control versions) plus the generated schedule. ut-run-tests . --dry-run ``` +## Colored output + +Use `--color` to control ANSI colors in test output: + +- `--color auto` (default): color only when stdout is a TTY +- `--color always`: force color +- `--color never`: disable color + +```sh +ut-run-tests . --color always +``` + ## Run a single test file ```sh diff --git a/docker/run_tests.py b/docker/run_tests.py index a81beaa5..1f26fc19 100644 --- a/docker/run_tests.py +++ b/docker/run_tests.py @@ -83,6 +83,7 @@ def main(argv: list[str] | None = None) -> int: failfast=args.failfast, reload_package_on_testing=args.reload_package_on_testing, dry_run=args.dry_run, + color=args.color, tests_dir=tests_dir, pattern=pattern, ) @@ -139,6 +140,12 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: action="store_true", help="Only print runtime metadata and schedule.", ) + test_group.add_argument( + "--color", + choices=("auto", "always", "never"), + default="auto", + help="Colorize test output (default: auto).", + ) docker_group = parser.add_argument_group("docker options") docker_group.add_argument( @@ -340,6 +347,7 @@ def build_docker_run_command( failfast: bool, reload_package_on_testing: bool, dry_run: bool, + color: str, tests_dir: str | None, pattern: str | None, ) -> list[str]: @@ -374,6 +382,8 @@ def build_docker_run_command( if dry_run: command.append("--dry-run") + command.extend(["--color", color]) + if tests_dir: command.extend(["--tests-dir", tests_dir]) diff --git a/sbin/run_tests.py b/sbin/run_tests.py index 7c4d327f..742151e9 100644 --- a/sbin/run_tests.py +++ b/sbin/run_tests.py @@ -28,6 +28,15 @@ SCHEDULE_RUNNER_TARGET = os.path.join(UT_DIR_PATH, "zzz_run_scheduler.py") RX_RESULT = re.compile(r'^(?POK|FAILED|ERROR)', re.MULTILINE) RX_DONE = re.compile(r'^UnitTesting: Done\.$', re.MULTILINE) +RX_TEST_STATUS = re.compile(r'\.\.\. (ok|FAIL|ERROR|skipped)(\b.*)$') +RX_SUMMARY_OK = re.compile(r'^OK(?:\b.*)?$') +RX_SUMMARY_FAIL = re.compile(r'^(FAILED|ERROR)(?:\b.*)?$') + +ANSI_RESET = "\033[0m" +ANSI_GREEN = "\033[32m" +ANSI_RED = "\033[31m" +ANSI_YELLOW = "\033[33m" +ANSI_CYAN = "\033[36m" _is_windows = sys.platform == 'win32' @@ -113,9 +122,11 @@ def kill_sublime_text(): subprocess.Popen("pkill plugin_host || true", shell=True) -def read_output(path): +def read_output(path, color='auto'): # todo: use notification instead of polling success = None + use_color = should_use_color(color) + pending = "" def check_is_success(result): try: @@ -131,8 +142,13 @@ def check_is_done(result): offset = f.tell() result = f.read() - print(result, end="") - sys.stdout.flush() + if result: + if use_color: + rendered, pending = colorize_output_chunk(result, pending) + print(rendered, end="") + else: + print(result, end="") + sys.stdout.flush() # Keep checking while we don't have a definite result. success = check_is_success(result) @@ -145,9 +161,93 @@ def check_is_done(result): time.sleep(0.2) + if use_color and pending: + print(colorize_output_line(pending), end="") + sys.stdout.flush() + return success +def should_use_color(mode): + if mode == 'always': + return True + + if mode == 'never': + return False + + if os.environ.get('NO_COLOR') is not None: + return False + + if os.environ.get('CLICOLOR_FORCE') not in (None, '', '0'): + return True + + if os.environ.get('FORCE_COLOR') not in (None, '', '0'): + return True + + try: + return sys.stdout.isatty() + except Exception: + return False + + +def colorize_output_chunk(chunk, pending): + if not chunk: + return '', pending + + text = pending + chunk + lines = text.splitlines(True) + + if lines and not lines[-1].endswith(('\n', '\r')): + pending = lines.pop() + else: + pending = '' + + rendered = ''.join(colorize_output_line(line) for line in lines) + return rendered, pending + + +def colorize_output_line(line): + newline = '' + body = line + + if body.endswith('\r\n'): + body = body[:-2] + newline = '\r\n' + elif body.endswith('\n') or body.endswith('\r'): + body = body[:-1] + newline = line[-1] + + test_status_match = RX_TEST_STATUS.search(body) + if test_status_match: + status = test_status_match.group(1) + suffix = test_status_match.group(2) + body = ( + body[:test_status_match.start()] + + '... ' + + colorize_status(status) + + suffix + ) + + if RX_SUMMARY_OK.match(body): + body = ANSI_GREEN + body + ANSI_RESET + elif RX_SUMMARY_FAIL.match(body): + body = ANSI_RED + body + ANSI_RESET + elif body.startswith('Ran '): + body = ANSI_CYAN + body + ANSI_RESET + + return body + newline + + +def colorize_status(status): + if status == 'ok': + return ANSI_GREEN + status + ANSI_RESET + + if status == 'skipped': + return ANSI_YELLOW + status + ANSI_RESET + + return ANSI_RED + status + ANSI_RESET + + def restore_coverage_file(path, package): # restore .coverage if it exists, needed for coveralls if os.path.exists(path): @@ -207,7 +307,7 @@ def detect_package_control_version(): return str(version) if version else None -def main(default_schedule_info, dry_run=False): +def main(default_schedule_info, dry_run=False, color='auto'): package_under_test = default_schedule_info['package'] output_dir = os.path.join(UT_OUTPUT_DIR_PATH, package_under_test) output_file = os.path.join(output_dir, "result") @@ -247,7 +347,7 @@ def main(default_schedule_info, dry_run=False): time.sleep(2) print("Start to read output...") - if not read_output(output_file): + if not read_output(output_file, color=color): sys.exit(1) restore_coverage_file(coverage_file, package_under_test) delete_file_if_exists(SCHEDULE_RUNNER_TARGET) @@ -264,6 +364,13 @@ def main(default_schedule_info, dry_run=False): parser.add_option('--failfast', action='store_true') parser.add_option('--reload-package-on-testing', action='store_true') parser.add_option('--dry-run', action='store_true') + parser.add_option( + '--color', + type='choice', + choices=['auto', 'always', 'never'], + default='auto', + help='Colorize test output (auto, always, never).', + ) options, remainder = parser.parse_args() @@ -294,4 +401,4 @@ def main(default_schedule_info, dry_run=False): if options.reload_package_on_testing: default_schedule_info['reload_package_on_testing'] = True - main(default_schedule_info, dry_run=options.dry_run) + main(default_schedule_info, dry_run=options.dry_run, color=options.color)