Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/aspect-cli/src/builtins/aspect/MODULE.aspect
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use_task("github.axl", "token")
use_task("run.axl", "run")
use_task("test.axl", "test")
use_task("axl_add.axl", "add")
use_task("axl_test.axl", "test")
use_task("delivery.axl", "delivery")
use_task("lint.axl", "lint")
use_task("format.axl", "format")
Expand Down
131 changes: 131 additions & 0 deletions crates/aspect-cli/src/builtins/aspect/axl_test.axl
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""`aspect axl test` — run AXL `*_test.axl` unit tests.

Discovers `*_test.axl` files under the given paths (files or directories,
defaulting to the Aspect workspace root), runs each file's top-level
`def test_*(t)` functions through the built-in parallel test runner, and
reports pass/fail per test.

The runner evaluates each file in isolation with no module loader, so a test
file that `load(...)`s other modules is reported as a load error rather than
run; self-contained test files run fully. Discovery does not descend into
hidden directories (e.g. `.git`) or follow directory symlinks (so Bazel
output trees are skipped).
"""

# Capture the privileged test-runner namespace at module-eval time, where the
# @aspect std-context marker is present. The task implementation runs later in
# the shared execution module (no marker), so it must reuse this binding rather
# than call `__builtins__.testing()` itself.
_testing = __builtins__.testing()

GREEN = "\033[32m"
RED = "\033[31m"
DIM = "\033[2m"
BOLD = "\033[1m"
RESET = "\033[0m"

# Generous worklist bound; directory symlinks are not followed (read_dir marks
# them non-dir), so this only guards against a pathologically deep real tree.
_WALK_LIMIT = 1000000

def _walk_test_files(ctx, root):
"""Every `*_test.axl` path at or under `root` (a file or directory)."""
found = []
if not ctx.std.fs.is_dir(root):
if root.endswith("_test.axl"):
found.append(root)
return found

stack = [root]
for _ in range(_WALK_LIMIT):
if not stack:
break
current = stack.pop()
for entry in ctx.std.fs.read_dir(current):
child = current + "/" + entry.path
if entry.is_dir:
if not entry.path.startswith("."):
stack.append(child)
elif entry.path.endswith("_test.axl"):
found.append(child)
return found

def _resolve_test_files(ctx, paths):
"""De-duplicated, order-preserving list of test files across all roots."""
roots = list(paths) if paths else [ctx.std.env.aspect_root_dir()]
files = []
seen = {}
for root in roots:
for path in _walk_test_files(ctx, root):
if path not in seen:
seen[path] = True
files.append(path)
return files

def _relativize(ctx, path):
"""Render `path` relative to the workspace root for readable output."""
root = ctx.std.env.aspect_root_dir()
prefix = root + "/"
if path.startswith(prefix):
return path[len(prefix):]
return path

def _report_file(ctx, path, result):
"""Print one file's result; return (passed, failed, errored) tallies."""
rel = _relativize(ctx, path)

if result["error"] != None:
print("%s%sERROR%s %s" % (RED, BOLD, RESET, rel))
for line in result["error"].split("\n"):
print(" " + line)
return (0, 0, 1)

passed = result["passed"]
failed = result["failed"]
status = GREEN + "ok " + RESET if failed == 0 else RED + "FAIL" + RESET
print("%s %s%s%s (%d passed, %d failed)" % (status, DIM, rel, RESET, passed, failed))

for outcome in result["outcomes"]:
if not outcome["passed"]:
print(" %sFAIL%s %s" % (RED, RESET, outcome["name"]))
if outcome["message"] != None:
for line in outcome["message"].split("\n"):
print(" " + line)
return (passed, failed, 0)

def _impl(ctx: TaskContext) -> int:
files = _resolve_test_files(ctx, ctx.args.paths)
if not files:
print("No *_test.axl files found.")
return 0

total_passed = 0
total_failed = 0
file_errors = 0

for path in files:
source = ctx.std.fs.read_to_string(path)
result = _testing.run(source)
passed, failed, errored = _report_file(ctx, path, result)
total_passed += passed
total_failed += failed
file_errors += errored

print("")
summary = "%d passed, %d failed across %d file(s)" % (total_passed, total_failed, len(files))
if file_errors:
summary += ", %d file error(s)" % file_errors
print(BOLD + summary + RESET)

return 0 if total_failed == 0 and file_errors == 0 else 1

test = task(
group = ["axl"],
summary = "Run AXL *_test.axl unit tests.",
implementation = _impl,
args = {
"paths": args.positional(
description = "Files or directories to search for `*_test.axl` tests. Defaults to the Aspect workspace root.",
),
},
)
1 change: 1 addition & 0 deletions crates/axl-proto/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ rust_library(
visibility = [
"//crates/axl-runtime:__pkg__",
"//crates/basil:__pkg__",
"//crates/basil-core:__pkg__",
"//crates/build-event-stream:__pkg__",
],
deps = [
Expand Down
1 change: 1 addition & 0 deletions crates/axl-runtime/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ rust_library(
"//crates/aspect-telemetry",
"//crates/axl-proto",
"//crates/axl-types",
"//crates/basil-core",
"//crates/bazelrc",
"//crates/build-event-stream",
"//crates/galvanize",
Expand Down
1 change: 1 addition & 0 deletions crates/axl-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ bazelrc = { path = "../bazelrc" }
axl-types = { path = "../axl-types" }
axl-proto = { path = "../axl-proto" }
starbuf-derive = { path = "../starbuf-derive" }
basil-core = { path = "../basil-core" }
build-event-stream = { path = "../build-event-stream" }
galvanize = { path = "../galvanize" }

Expand Down
Loading