diff --git a/.gitattributes b/.gitattributes
index 188fb2a6..ae31bf26 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,11 +2,16 @@
# 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/ut-run-tests 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/README.md b/README.md
index 37048e1d..5c00bdcb 100644
--- a/README.md
+++ b/README.md
@@ -112,6 +112,49 @@ 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/ut-run-tests` launcher.
+
+```sh
+# run all tests from current package root
+/path/to/UnitTesting/docker/ut-run-tests .
+
+# run just one test file (faster)
+/path/to/UnitTesting/docker/ut-run-tests . --file tests/test_example.py
+```
+
+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.
+
+Useful options:
+
+- `--file tests/test_foo.py`
+- `--pattern test_foo.py --tests-dir tests/subdir`
+- `--coverage`
+- `--failfast`
+- `--reload-package-on-testing` (default: off)
+- `--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)
+- `--no-cache-volume` (run without persistent cache)
+
+> [!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..b3b33e13 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -1,16 +1,99 @@
# Docker image for Sublime Text UnitTesting
+## Recommended usage
+
+Use the launcher script:
+
+```sh
+# from UnitTesting repo root
+./docker/ut-run-tests /path/to/package
+./docker/ut-run-tests /path/to/package --file tests/test_example.py
+```
+
+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
-## 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.
+
+## 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
+```
+
+## 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
+```
+
+## 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
-# 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..c08aa56d 100644
--- a/docker/docker.sh
+++ b/docker/docker.sh
@@ -4,8 +4,13 @@ set -e
BASEDIR=`dirname $0`
+UNITTESTING_SOURCE=${UNITTESTING_SOURCE:-/unittesting}
+SOURCE_CISH="$UNITTESTING_SOURCE/sbin/ci.sh"
CISH="/tmp/ci.sh"
-if [ ! -f "$CISH" ]; then
+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
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index a050893e..945dde83 100644
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -5,7 +5,87 @@ echo PACKAGE = $PACKAGE
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
+}
+
+ensure_ci_platform_compat() {
+ # 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*)
+ travis_name="linux"
+ runner_name="Linux"
+ ;;
+ Darwin*)
+ travis_name="osx"
+ runner_name="macOS"
+ ;;
+ CYGWIN*|MINGW*|MSYS*)
+ 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
-/docker.sh bootstrap
-/docker.sh install_package_control
-/docker.sh run_tests --coverage
+ensure_git_identity
+ensure_ci_platform_compat
+
+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
+ # 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..1f26fc19
--- /dev/null
+++ b/docker/run_tests.py
@@ -0,0 +1,403 @@
+#!/usr/bin/env python3
+"""Run Sublime Text UnitTesting in a Docker container.
+
+Usually invoked via the sibling launcher script `ut-run-tests`.
+Examples:
+ ./docker/ut-run-tests .
+ ./docker/ut-run-tests . --file tests/test_main.py
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+
+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:
+ 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)
+ 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
+ )
+
+ maybe_build_image(image, refresh=False)
+
+ 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,
+ scheduler_delay_ms=args.scheduler_delay_ms,
+ coverage=args.coverage,
+ 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,
+ )
+
+ 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.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}")
+
+ 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).",
+ )
+
+ 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(
+ "--reload-package-on-testing",
+ action="store_true",
+ help="Reload package under test before running tests.",
+ )
+ test_group.add_argument(
+ "--scheduler-delay-ms",
+ type=int,
+ 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.",
+ )
+ 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(
+ "--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}).",
+ )
+ 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=(
+ "Docker volume mounted at /root to cache Sublime setup "
+ f"(default: {DEFAULT_CACHE_VOLUME})."
+ ),
+ )
+ docker_group.add_argument(
+ "--no-cache-volume",
+ dest="cache_volume",
+ action="store_const",
+ const=None,
+ help="Disable persistent cache volume.",
+ )
+ docker_group.add_argument(
+ "--refresh-cache",
+ action="store_true",
+ help=(
+ "Recreate the cache volume so Sublime Text and Package Control "
+ "are re-installed."
+ ),
+ )
+
+ args = parser.parse_args(argv)
+
+ 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)")
+
+ 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
+
+
+def ensure_docker() -> None:
+ if not shutil.which("docker"):
+ raise SystemExit("Error: docker executable not found in PATH")
+
+
+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}")
+
+ context_hash = docker_context_hash(context_dir)
+ image_exists = docker_image_exists(image)
+ image_hash = docker_image_context_hash(image) if image_exists else None
+ context_changed = image_exists and image_hash != context_hash
+
+ should_build = refresh or not image_exists or context_changed
+ if not should_build:
+ return
+
+ if context_changed and not refresh:
+ print("Docker context changed since last image build, rebuilding...")
+
+ print(f"Building docker image '{image}' from {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:
+ result = subprocess.run(
+ ["docker", "image", "inspect", image],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ return result.returncode == 0
+
+
+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}")
+
+ 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_context_hash(image: str) -> str | None:
+ result = subprocess.run(
+ [
+ "docker",
+ "image",
+ "inspect",
+ image,
+ "--format",
+ "{{ index .Config.Labels \"%s\" }}" % DOCKER_CONTEXT_HASH_LABEL,
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ )
+ if result.returncode != 0:
+ return None
+
+ value = result.stdout.strip()
+ if not value or value == "":
+ return None
+
+ return value
+
+
+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 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,
+ 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,
+ scheduler_delay_ms: int,
+ coverage: bool,
+ failfast: bool,
+ reload_package_on_testing: bool,
+ dry_run: bool,
+ color: str,
+ tests_dir: str | None,
+ pattern: str | None,
+) -> list[str]:
+ 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}"])
+ 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 reload_package_on_testing:
+ command.append("--reload-package-on-testing")
+
+ if dry_run:
+ command.append("--dry-run")
+
+ command.extend(["--color", color])
+
+ 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/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/.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..1a2481cf 100644
--- a/sbin/README.md
+++ b/sbin/README.md
@@ -1,3 +1,8 @@
-## 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/ut-run-tests`).
+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/install_sublime_text.sh b/sbin/install_sublime_text.sh
index f1209611..604786b1 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
@@ -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
diff --git a/sbin/run_tests.py b/sbin/run_tests.py
index 6cd07a99..742151e9 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
@@ -27,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'
@@ -55,19 +65,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 +95,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()
@@ -104,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:
@@ -122,7 +142,13 @@ def check_is_done(result):
offset = f.tell()
result = f.read()
- print(result, end="")
+ 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)
@@ -135,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):
@@ -148,7 +258,56 @@ def restore_coverage_file(path, package):
f.write(txt)
-def main(default_schedule_info):
+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, 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")
@@ -156,6 +315,15 @@ def main(default_schedule_info):
default_schedule_info['output'] = output_file
+ 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)
@@ -179,7 +347,7 @@ def main(default_schedule_info):
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)
@@ -191,6 +359,18 @@ 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')
+ 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()
@@ -206,6 +386,19 @@ def main(default_schedule_info):
'syntax_compatibility': syntax_compatibility,
'color_scheme_test': color_scheme_test,
'coverage': coverage,
+ 'reload_package_on_testing': False,
}
- main(default_schedule_info)
+ 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
+
+ if options.reload_package_on_testing:
+ default_schedule_info['reload_package_on_testing'] = True
+
+ main(default_schedule_info, dry_run=options.dry_run, color=options.color)
diff --git a/unittesting/scheduler.py b/unittesting/scheduler.py
index aaa1b0a7..871c2171 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,17 +84,25 @@ 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():
- # 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)