Skip to content

Commit e76790d

Browse files
committed
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.
1 parent e2d22fa commit e76790d

7 files changed

Lines changed: 98 additions & 54 deletions

File tree

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
# Shell scripts used in Linux containers must stay LF in the working tree.
66
/docker/*.sh text eol=lf
7+
/docker/ut-run-tests text eol=lf
78
/docker/xvfb text eol=lf
89
/sbin/*.sh text eol=lf
910

README.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,18 +115,27 @@ so that <kbd>ctrl</kbd>+<kbd>b</kbd> would invoke the testing action.
115115
### Headless container runner
116116

117117
To run tests without affecting your interactive Sublime Text session,
118-
use the bundled `docker/run_tests.py` script.
118+
use the bundled `docker/ut-run-tests` launcher.
119119

120120
```sh
121121
# run all tests from current package root
122-
uv run docker/run_tests.py .
122+
/path/to/UnitTesting/docker/ut-run-tests .
123123

124124
# run just one test file (faster)
125-
uv run docker/run_tests.py . --file tests/test_example.py
125+
/path/to/UnitTesting/docker/ut-run-tests . --file tests/test_example.py
126126
```
127127

128-
This script runs tests in a Docker container (headless), streams output to
129-
stdout/stderr and keeps a cache volume so repeated runs are fast.
128+
(Use `docker/ut-run-tests.cmd` in cmd.exe/PowerShell.)
129+
130+
If `UnitTesting/docker` is on your `PATH`, you can simply run:
131+
132+
```sh
133+
ut-run-tests .
134+
```
135+
136+
This launcher calls `docker/run_tests.py`, which runs tests in a Docker
137+
container (headless), streams output to stdout/stderr and keeps a cache
138+
volume so repeated runs are fast.
130139

131140
By default it:
132141

@@ -142,9 +151,6 @@ Useful options:
142151
- `--pattern test_foo.py --tests-dir tests/subdir`
143152
- `--coverage`
144153
- `--failfast`
145-
- `--docker-image <name>`
146-
- `--no-cache-volume`
147-
- `--scheduler-delay-ms 0` (default)
148154

149155
> [!TIP]
150156
>

docker/README.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,33 @@
22

33
## Recommended usage
44

5-
Use the docker wrapper script:
5+
Use the launcher script:
66

77
```sh
8-
uv run docker/run_tests.py /path/to/package
9-
uv run docker/run_tests.py /path/to/package --file tests/test_example.py
8+
# from UnitTesting repo root
9+
./docker/ut-run-tests /path/to/package
10+
./docker/ut-run-tests /path/to/package --file tests/test_example.py
1011
```
1112

12-
It builds/uses a local image, mounts the package at `/project`, runs tests
13-
headlessly, and keeps a cache volume for fast reruns.
13+
Or call it via absolute path from any package directory:
14+
15+
```sh
16+
/path/to/UnitTesting/docker/ut-run-tests .
17+
```
18+
19+
If this directory is on your `PATH`, you can run `ut-run-tests` directly.
20+
21+
The launcher calls `docker/run_tests.py`, builds/uses a local image,
22+
mounts the package at `/project`, runs tests headlessly, and keeps a cache
23+
volume for fast reruns.
24+
25+
By default it:
26+
27+
- builds `unittesting-local` image from `./docker` if missing
28+
- mounts your repo as `/project`
29+
- runs UnitTesting through the same CI shell entrypoints
30+
- stores Sublime install/cache in docker volume `unittesting-home`
31+
- synchronizes only changed files into `Packages/<Package>` using `rsync`
1432

1533
## Manual docker usage
1634

docker/run_tests.py

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
#!/usr/bin/env python3
22
"""Run Sublime Text UnitTesting in a Docker container.
33
4+
Usually invoked via the sibling launcher script `ut-run-tests`.
45
Examples:
5-
uv run docker/run_tests.py .
6-
uv run docker/run_tests.py . --file tests/test_main.py
6+
./docker/ut-run-tests .
7+
./docker/ut-run-tests . --file tests/test_main.py
78
"""
89

910
from __future__ import annotations
1011

1112
import argparse
12-
from datetime import datetime, timezone
13+
import hashlib
1314
import shutil
1415
import subprocess
1516
import sys
@@ -18,6 +19,13 @@
1819

1920
DEFAULT_IMAGE = "unittesting-local"
2021
DEFAULT_CACHE_VOLUME = "unittesting-home"
22+
DOCKER_CONTEXT_HASH_LABEL = "org.sublimetext.unittesting.context-hash"
23+
DOCKER_CONTEXT_INPUTS = (
24+
"Dockerfile",
25+
"docker.sh",
26+
"entrypoint.sh",
27+
"xvfb",
28+
)
2129

2230

2331
def main(argv: list[str] | None = None) -> int:
@@ -171,8 +179,10 @@ def maybe_build_image(image: str, args: argparse.Namespace) -> None:
171179
if not context_dir.is_dir():
172180
raise SystemExit(f"Error: missing docker build context: {context_dir}")
173181

182+
context_hash = docker_context_hash(context_dir)
174183
image_exists = docker_image_exists(image)
175-
context_changed = image_exists and docker_context_changed(context_dir, image)
184+
image_hash = docker_image_context_hash(image) if image_exists else None
185+
context_changed = image_exists and image_hash != context_hash
176186

177187
should_build = args.build_image
178188
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:
185195
print("Docker context changed since last image build, rebuilding...")
186196

187197
print(f"Building docker image '{image}' from {context_dir} ...")
188-
run_checked(["docker", "build", "-t", image, str(context_dir)])
198+
run_checked([
199+
"docker",
200+
"build",
201+
"--label",
202+
f"{DOCKER_CONTEXT_HASH_LABEL}={context_hash}",
203+
"-t",
204+
image,
205+
str(context_dir),
206+
])
189207

190208

191209
def docker_image_exists(image: str) -> bool:
@@ -197,53 +215,43 @@ def docker_image_exists(image: str) -> bool:
197215
return result.returncode == 0
198216

199217

200-
def docker_context_changed(context_dir: Path, image: str) -> bool:
201-
image_created = docker_image_created_at(image)
202-
if image_created is None:
203-
return True
218+
def docker_context_hash(context_dir: Path) -> str:
219+
digest = hashlib.sha256()
220+
for rel_path in DOCKER_CONTEXT_INPUTS:
221+
file_path = context_dir / rel_path
222+
if not file_path.is_file():
223+
raise SystemExit(f"Error: missing docker context file: {file_path}")
204224

205-
context_mtime = newest_mtime(context_dir)
206-
return context_mtime > image_created
225+
digest.update(rel_path.encode("utf-8"))
226+
digest.update(b"\0")
227+
digest.update(file_path.read_bytes())
228+
digest.update(b"\0")
207229

230+
return digest.hexdigest()
208231

209-
def docker_image_created_at(image: str) -> float | None:
232+
233+
def docker_image_context_hash(image: str) -> str | None:
210234
result = subprocess.run(
211-
["docker", "image", "inspect", image, "--format", "{{.Created}}"],
235+
[
236+
"docker",
237+
"image",
238+
"inspect",
239+
image,
240+
"--format",
241+
"{{ index .Config.Labels \"%s\" }}" % DOCKER_CONTEXT_HASH_LABEL,
242+
],
212243
stdout=subprocess.PIPE,
213244
stderr=subprocess.DEVNULL,
214245
text=True,
215246
)
216247
if result.returncode != 0:
217248
return None
218249

219-
created = result.stdout.strip()
220-
if not created:
221-
return None
222-
223-
# Example: 2026-03-13T21:47:06.123456789Z
224-
if created.endswith("Z"):
225-
created = created[:-1]
226-
if "." in created:
227-
created = created.split(".", 1)[0]
228-
229-
try:
230-
dt = datetime.strptime(created, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
231-
except ValueError:
250+
value = result.stdout.strip()
251+
if not value or value == "<no value>":
232252
return None
233253

234-
return dt.timestamp()
235-
236-
237-
def newest_mtime(path: Path) -> float:
238-
latest = path.stat().st_mtime
239-
for child in path.rglob("*"):
240-
try:
241-
mtime = child.stat().st_mtime
242-
except OSError:
243-
continue
244-
if mtime > latest:
245-
latest = mtime
246-
return latest
254+
return value
247255

248256

249257
def ensure_docker_volume(name: str) -> None:

docker/ut-run-tests

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
5+
exec python "$SCRIPT_DIR/run_tests.py" "$@"

docker/ut-run-tests.cmd

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@echo off
2+
setlocal
3+
set "SCRIPT_DIR=%~dp0"
4+
python "%SCRIPT_DIR%run_tests.py" %*

sbin/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
## Status
22

3-
For GitHub CI, prefer the official composite [actions](https://github.com/SublimeText/UnitTesting/actions).
3+
For GitHub CI, prefer the official composite
4+
[actions](https://github.com/SublimeText/UnitTesting/actions).
45

5-
These scripts are still used for local/containerized automation (for example the Docker runner path used by `docker/run_tests.py`).
6+
These scripts are still used for local/containerized automation
7+
(for example the Docker runner path used by `docker/ut-run-tests`).
68
They should be treated as supported for that workflow.

0 commit comments

Comments
 (0)