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)