-
Notifications
You must be signed in to change notification settings - Fork 19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Prototype Rust Autotesting #482
Closed
Closed
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
9d8f031
Add rust autotest target
1whatleytay 37a5220
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] f951f2d
Add rust autotesting in README.md
1whatleytay 275c7c7
Add test_modules property for rust autotesting
1whatleytay 0fa08d0
Switch autotest rust to single test_module
1whatleytay ff8a451
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 6d473fa
Remove --lib option from rust autotester
1whatleytay 7f90072
Fix module string problem
1whatleytay 4730983
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] dab5da8
Prevent options from being passed to cargo test
1whatleytay File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
#!/usr/bin/env bash | ||
|
||
# The main repository for APT seems to be fairly old (1.65). | ||
# Building cargo-nextest from source requires Rust 1.70, so I'm using rustup instead of a package manager. | ||
|
||
# We need a few things for our rust target. | ||
# - curl for downloading rustup. Alternatively we can use wget. | ||
# - rustup for installing rust and installing cargo-nextest. | ||
# - cargo-nextest for machine readable test output. | ||
|
||
# Install Curl | ||
if ! dpkg -l curl &> /dev/null; then | ||
apt-get -y update | ||
|
||
DEBIAN_FRONTEND=noninteractive apt-get install -y -o 'Dpkg::Options::=--force-confdef' -o 'Dpkg::Options::=--force-confold' curl | ||
fi | ||
|
||
# Install Rust | ||
if ! command -v cargo &> /dev/null; then | ||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y | ||
|
||
# Add cargo to the path. | ||
. "$HOME/.cargo/env" | ||
fi | ||
|
||
# Install Nextest | ||
# For justification as to why we're using nextest, take a look at rust_tester.py. | ||
if ! command -v cargo-nextest &> /dev/null; then | ||
cargo install cargo-nextest --locked | ||
fi |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,160 @@ | ||
import os | ||
import subprocess | ||
from typing import Type, Optional | ||
import json | ||
from ..tester import Tester, Test, TestError | ||
from ..specs import TestSpecs | ||
from pathlib import Path | ||
import re | ||
|
||
TEST_IDENTIFIER_REGEX = re.compile("^((?P<exec>.*)\\$)?(?P<test>.*)$") | ||
|
||
|
||
def parse_test_identifier(identifier: str) -> tuple[str, str]: | ||
result = TEST_IDENTIFIER_REGEX.match(identifier) | ||
|
||
return result.group("exec"), result.group("test") | ||
|
||
|
||
class RustTest(Test): | ||
def __init__(self, tester: "RustTester", executable: str, test: str, success: bool, message: str) -> None: | ||
self.executable = executable | ||
self.test = test | ||
self.success = success | ||
self.message = message | ||
|
||
super().__init__(tester) | ||
|
||
@property | ||
def test_name(self) -> str: | ||
return self.test | ||
|
||
def run(self) -> str: | ||
if self.success: | ||
return self.passed(message=self.message) | ||
else: | ||
return self.failed(message=self.message) | ||
|
||
|
||
def parse_adjacent_json(text: str) -> list[dict]: | ||
skip = {"\n", "\r"} | ||
|
||
i = 0 | ||
decoder = json.JSONDecoder() | ||
|
||
items = [] | ||
|
||
while i < len(text): | ||
value, i = decoder.raw_decode(text, i) | ||
|
||
items.append(value) | ||
|
||
while i < len(text) and text[i] in skip: | ||
i += 1 | ||
|
||
return items | ||
|
||
|
||
class RustTester(Tester): | ||
def __init__(self, specs: TestSpecs, test_class: Type[RustTest] = RustTest) -> None: | ||
super().__init__(specs, test_class) | ||
|
||
def parse_test_events(self, items: list[dict]) -> list[RustTest]: | ||
tests = [] | ||
|
||
for item in items: | ||
# Ignore suite events. | ||
if item["type"] != "test": | ||
continue | ||
|
||
event = item["event"] | ||
|
||
finished_events = {"failed", "ok"} | ||
|
||
if event not in finished_events: | ||
continue | ||
|
||
executable, test = parse_test_identifier(item["name"]) | ||
|
||
output = item["stdout"] if event == "failed" else "" | ||
|
||
tests.append(self.test_class(self, executable, test, event == "ok", output)) | ||
|
||
return tests | ||
|
||
# This should likely be moved to setup.py's create_environment. | ||
def rust_env(self) -> dict: | ||
# Hint to /bin/sh that it should look in $HOME/.cargo/bin. | ||
# Despite .profile pointing to this directory, /bin/dash does not want to acknowledge it. | ||
# There is conflicting information online as to whether-or-not .profile is respected by dash. | ||
rust_home_path = os.path.join(Path.home(), ".cargo", "bin") | ||
|
||
env = os.environ.copy() | ||
|
||
env["PATH"] = rust_home_path + ":" + env["PATH"] | ||
|
||
return env | ||
|
||
def compile_rust_tests(self, directory: str) -> subprocess.CompletedProcess: | ||
command = ["cargo", "nextest", "run", "--no-run", "--color", "never"] | ||
|
||
env = self.rust_env() | ||
|
||
return subprocess.run(command, cwd=directory, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | ||
|
||
# We need a way to get machine-readable output from cargo test (why are we using nextest?). | ||
# Here are the options I've explored: | ||
# - cargo-test by default outputs human-readable strings. | ||
# These strings might change in format over time, and I figure one tests stdout could bleed into another. | ||
# - cargo-test supports machine-readable strings (via `cargo test -- --format json`). | ||
# This option is nightly rust only, and enforcing nightly rust could cause other issues. | ||
# - cargo-nextest supports experimental machine-readable output. | ||
# While machine-readable output is experimental, it's based on yhr libtest standard that cargo test would use. | ||
# nextest should also interact very similarly to `cargo test`. It should be very simple to swap to cargo-test. | ||
# It's also reliable and only requires Rust 1.36 or earlier for running. | ||
def run_rust_tests(self, directory: str, module: Optional[str]) -> subprocess.CompletedProcess: | ||
command = ["cargo", "nextest", "run", "--no-fail-fast", "--message-format", "libtest-json", "--color", "never"] | ||
|
||
# Prevent CLI options from being propagated. | ||
if module is not None and "-" not in module: | ||
command.append(module) | ||
|
||
# Machine-readable output is experimental with Nextest. | ||
env = self.rust_env() | ||
env["NEXTEST_EXPERIMENTAL_LIBTEST_JSON"] = "1" | ||
|
||
return subprocess.run(command, cwd=directory, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | ||
|
||
def run_and_parse_rust_tests(self, directory: str, module: Optional[str]) -> list[RustTest]: | ||
test_results = self.run_rust_tests(directory, module) | ||
|
||
json_string = test_results.stdout.decode("utf-8") | ||
test_events = parse_adjacent_json(json_string) | ||
|
||
return self.parse_test_events(test_events) | ||
|
||
@Tester.run_decorator | ||
def run(self) -> None: | ||
# Awkwardly, cargo doesn't have a great way of running files. | ||
# Instead, it can run all the tests in a module (which is named after a file barring .rs and the path). | ||
|
||
try: | ||
compile_result = self.compile_rust_tests(".") | ||
|
||
if compile_result.returncode != 0: | ||
raise TestError(compile_result.stderr.decode("utf-8")) | ||
except subprocess.CalledProcessError as e: | ||
raise TestError(e) | ||
|
||
module = self.specs.get("test_data", "test_module") | ||
|
||
if module is not None: | ||
module = module.strip() | ||
|
||
if module == "": | ||
module = None | ||
|
||
tests = self.run_and_parse_rust_tests(".", module) | ||
|
||
for test in tests: | ||
print(test.run(), flush=True) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
{ | ||
"type": "object", | ||
"properties": { | ||
"tester_type": { | ||
"type": "string", | ||
"enum": [ | ||
"rust" | ||
] | ||
}, | ||
"test_data": { | ||
"title": "Test Groups", | ||
"type": "array", | ||
"minItems": 1, | ||
"items": { | ||
"type": "object", | ||
"required": [ | ||
"timeout" | ||
], | ||
"properties": { | ||
"test_module": { | ||
"title": "Test module name", | ||
"type": "string", | ||
"minLength": 0 | ||
}, | ||
"category": { | ||
"title": "Category", | ||
"type": "array", | ||
"items": { | ||
"$ref": "#/definitions/test_data_categories" | ||
}, | ||
"uniqueItems": true | ||
}, | ||
"timeout": { | ||
"title": "Timeout", | ||
"type": "integer", | ||
"default": 30 | ||
}, | ||
"feedback_file_names": { | ||
"title": "Feedback files", | ||
"type": "array", | ||
"items": { | ||
"type": "string" | ||
} | ||
}, | ||
"extra_info": { | ||
"$ref": "#/definitions/extra_group_data" | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import os | ||
import json | ||
import subprocess | ||
|
||
|
||
def create_environment(settings_, env_dir, default_env_dir): | ||
return {"PYTHON": os.path.join(default_env_dir, "bin", "python3")} | ||
|
||
|
||
def settings(): | ||
with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "settings_schema.json")) as f: | ||
return json.load(f) | ||
|
||
|
||
def install(): | ||
subprocess.run(os.path.join(os.path.dirname(os.path.realpath(__file__)), "requirements.system"), check=True) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@1whatleytay I see on nextest's documentation that you're treating
module
as "[FILTERS]
". I wasn't able to figure out exactly how that part worked, but at the very least it seems to match more than just the module name? With the sample files you added on your MarkUs PR, using a value oftest_enqueue
does a filter based on test name.But, I also see the
-E '...'
test filter expression option, which seems clearly defined. I wonder if this is a better target for user configuration, if it supports filtering both by package name (e.g.package(test)
?) and test function name (e.g.test(dequeue)
). It would require the instructor to understand this syntax, but I think that's okay?