tangled
alpha
login
or
join now
bwc9876.dev
/
gh-grader-preview
0
fork
atom
Local runner for GitHub autograder
0
fork
atom
overview
issues
pulls
pipelines
Way better outputting, support no comparison
bwc9876.dev
6 months ago
430a95f2
927a26ca
verified
This commit was signed with the committer's
known signature
.
bwc9876.dev
SSH Key Fingerprint:
SHA256:DanMEP/RNlSC7pAVbnXO6wzQV00rqyKj053tz4uH5gQ=
+190
-87
6 changed files
expand all
collapse all
unified
split
.github
workflows
release.yml
Cargo.lock
Cargo.toml
src
grader.rs
main.rs
runner.rs
+30
-14
.github/workflows/release.yml
···
1
1
-
2
1
name: Release
3
3
-
on:
2
2
+
on:
4
3
workflow_dispatch:
5
5
-
inputs:
6
6
-
tag:
7
7
-
description: Version Tag
8
8
-
type: string
9
9
-
4
4
+
10
5
permissions:
11
6
contents: write
7
7
+
id-token: write
8
8
+
attestations: write
12
9
13
10
jobs:
14
11
release:
···
23
20
with:
24
21
toolchain: stable
25
22
26
26
-
- name: Build
23
23
+
- name: Get Metadata
24
24
+
id: metadata
27
25
run: |
28
28
-
cargo build --release
26
26
+
echo "meta=$(cargo metadata --no-deps --frozen --format-version 1)" >> $GITHUB_OUTPUT
27
27
+
28
28
+
- name: Check Tag
29
29
+
id: check-tag
30
30
+
run: |
31
31
+
echo "exists=$(git ls-remote --exit-code --tags origin ${{ env.TAG }} >/dev/null 2>&1 && echo true || echo false)" >> $GITHUB_OUTPUT
32
32
+
echo "tag=${{ env.TAG }}" >> $GITHUB_OUTPUT
33
33
+
env:
34
34
+
TAG: "v${{fromJson(steps.metadata.outputs.meta).packages[0].version}}"
35
35
+
36
36
+
- name: Block if There's a Release
37
37
+
if: ${{ steps.check-tag.outputs.exists != 'false' }}
38
38
+
run: echo "::error file=Cargo.toml,title=Refusing to Release::Tag ${{ steps.check-tag.outputs.tag }} already exists" && exit 1
39
39
+
40
40
+
- name: Build
41
41
+
run: |
42
42
+
cargo build --frozen --release
43
43
+
44
44
+
- name: Generate artifact attestation
45
45
+
uses: actions/attest-build-provenance@v2
46
46
+
with:
47
47
+
subject-path: 'target/release/gh-grader-preview'
29
48
30
49
- name: Upload Binary Artifact
31
50
uses: actions/upload-artifact@v4
···
36
55
- name: Create Release
37
56
uses: softprops/action-gh-release@v2
38
57
with:
39
39
-
name: GH Grader Preview ${{ inputs.tag }}
40
40
-
tag_name: v${{ inputs.tag }}
58
58
+
name: GH Grader Preview ${{ steps.check-tag.outputs.tag }}
59
59
+
tag_name: ${{ steps.check-tag.outputs.tag }}
41
60
generate_release_notes: false
42
61
draft: true
43
62
files: |
44
63
target/release/gh-grader-preview
45
45
-
46
46
-
47
47
-
+1
-1
Cargo.lock
···
165
165
166
166
[[package]]
167
167
name = "gh-grader-preview"
168
168
-
version = "0.2.0"
168
168
+
version = "0.3.0"
169
169
dependencies = [
170
170
"anyhow",
171
171
"clap",
+1
-3
Cargo.toml
···
1
1
[package]
2
2
name = "gh-grader-preview"
3
3
-
version = "0.2.0"
3
3
+
version = "0.3.0"
4
4
edition = "2024"
5
5
-
6
6
-
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7
5
8
6
[dependencies]
9
7
anyhow = "1.0.99"
+34
-10
src/grader.rs
···
1
1
+
use std::fmt::Display;
2
2
+
1
3
use anyhow::Result;
2
4
use serde::Deserialize;
3
5
···
11
13
#[serde(rename_all = "snake_case")]
12
14
pub enum ComparisonType {
13
15
#[default]
16
16
+
Pass,
14
17
Included,
15
18
Excluded,
16
19
Exact,
17
20
Regex,
18
21
}
19
22
23
23
+
impl Display for ComparisonType {
24
24
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25
25
+
let s = match self {
26
26
+
ComparisonType::Included => "include this string",
27
27
+
ComparisonType::Excluded => "not include this string",
28
28
+
ComparisonType::Exact => "exactly match this string",
29
29
+
ComparisonType::Regex => "match against this regular expression",
30
30
+
ComparisonType::Pass => "can be anything",
31
31
+
};
32
32
+
f.write_str(s)
33
33
+
}
34
34
+
}
35
35
+
20
36
#[derive(Deserialize)]
21
37
pub struct TestCase {
22
38
pub name: String,
23
23
-
pub setup: String,
39
39
+
pub setup: Option<String>,
24
40
pub run: String,
25
41
#[serde(default)]
26
42
pub input: String,
27
27
-
pub output: String,
43
43
+
pub output: Option<String>,
44
44
+
#[serde(default)]
28
45
pub comparison: ComparisonType,
29
46
pub timeout: u32,
30
47
#[allow(unused)]
···
56
73
57
74
impl TestCase {
58
75
pub fn check_output(&self, output: String) -> Result<bool> {
59
59
-
match self.comparison {
60
60
-
ComparisonType::Included => Ok(output.contains(&self.output)),
61
61
-
ComparisonType::Excluded => Ok(!output.contains(&self.output)),
62
62
-
ComparisonType::Exact => Ok(output.trim() == self.output.trim()),
63
63
-
ComparisonType::Regex => {
64
64
-
let re = regex::Regex::new(&self.output)?;
65
65
-
Ok(re.is_match(&output))
76
76
+
if let Some(ref expected_output) = self.output {
77
77
+
match self.comparison {
78
78
+
ComparisonType::Included => Ok(output.contains(expected_output)),
79
79
+
ComparisonType::Excluded => Ok(!output.contains(expected_output)),
80
80
+
ComparisonType::Exact => Ok(output.trim() == expected_output.trim()),
81
81
+
ComparisonType::Regex => {
82
82
+
let re = regex::Regex::new(expected_output)?;
83
83
+
Ok(re.is_match(&output))
84
84
+
}
85
85
+
ComparisonType::Pass => Ok(true),
66
86
}
87
87
+
} else {
88
88
+
Ok(true)
67
89
}
68
90
}
69
91
70
92
pub fn run(&self) -> Result<(bool, TestResult)> {
71
71
-
runner::setup_phase(&self.setup)?;
93
93
+
if let Some(setup) = self.setup.as_ref() {
94
94
+
runner::setup_phase(setup)?;
95
95
+
}
72
96
let res = runner::run_phase(&self.run, &self.input, self.timeout as u64)?;
73
97
let matches = self.check_output(res.stdout.clone())?;
74
98
Ok((matches, res))
+122
-57
src/main.rs
···
12
12
use grader::AutoGraderData;
13
13
use indicatif::ProgressBar;
14
14
15
15
+
use crate::{grader::ComparisonType, runner::TestResult};
16
16
+
17
17
+
struct CmdOutput(String, String, Option<String>);
18
18
+
19
19
+
impl CmdOutput {
20
20
+
fn format_output(self, name: &str, comp: &ComparisonType) -> String {
21
21
+
let CmdOutput(stdout, stderr, expected) = self;
22
22
+
23
23
+
let heading = format!("┏━┻ Stdout of {name}");
24
24
+
let stdout = stdout.trim().replace("\n", "\n┃ ");
25
25
+
let mid = format!("┣━━ Stderr of {name}");
26
26
+
let stderr = stderr.trim().replace("\n", "\n┃ ");
27
27
+
let (mid2, expected) = if let Some(expected) = expected {
28
28
+
(
29
29
+
format!("┣━━ Stdout of {name} must {comp}"),
30
30
+
expected.trim().replace("\n", "\n┃ "),
31
31
+
)
32
32
+
} else {
33
33
+
(format!("┣━━ {name} has no output check"), String::new())
34
34
+
};
35
35
+
let foot = "┗━━ End of output".to_string();
36
36
+
37
37
+
format!("{heading}\n┃ {stdout}\n{mid}\n┃ {stderr}\n{mid2}\n┃ {expected}\n{foot}")
38
38
+
}
39
39
+
}
40
40
+
41
41
+
#[derive(Debug)]
42
42
+
enum TestOutput {
43
43
+
Passed,
44
44
+
LogicError,
45
45
+
NonZeroExit(Option<i32>),
46
46
+
RunnerError(anyhow::Error),
47
47
+
Skipped,
48
48
+
}
49
49
+
50
50
+
impl From<Result<(bool, TestResult)>> for TestOutput {
51
51
+
fn from(res: Result<(bool, TestResult)>) -> Self {
52
52
+
match res {
53
53
+
Ok((matched, TestResult { status: Ok(0), .. })) => {
54
54
+
if matched {
55
55
+
Self::Passed
56
56
+
} else {
57
57
+
Self::LogicError
58
58
+
}
59
59
+
}
60
60
+
Ok((_, TestResult { status, .. })) => Self::NonZeroExit(status.ok()),
61
61
+
Err(why) => Self::RunnerError(why),
62
62
+
}
63
63
+
}
64
64
+
}
65
65
+
66
66
+
impl TestOutput {
67
67
+
fn output(&self) -> (&str, String) {
68
68
+
match self {
69
69
+
TestOutput::Passed => ("✅", "Passed".to_string()),
70
70
+
TestOutput::LogicError => ("❌", "Did not match expected output".to_string()),
71
71
+
TestOutput::NonZeroExit(code) => (
72
72
+
"💥",
73
73
+
format!("Exited with code {}", code.unwrap_or(i32::MAX)),
74
74
+
),
75
75
+
TestOutput::RunnerError(error) => ("🙈", format!("Wasn't able to run: {error:?}")),
76
76
+
TestOutput::Skipped => ("〰️", "Was skipped".to_string()),
77
77
+
}
78
78
+
}
79
79
+
}
80
80
+
15
81
fn main() -> Result<()> {
16
82
let cli = Cli::parse();
17
83
···
22
88
println!("{}", meta::gen_completions(shell.parse().unwrap()));
23
89
return Ok(());
24
90
}
91
91
+
92
92
+
let target_test = cli.test.as_ref().map(|s| s.to_lowercase());
25
93
26
94
let grader_data = AutoGraderData::get(cli.file)?;
27
95
28
28
-
let test_len = grader_data.tests.len();
29
29
-
30
30
-
let amount_passed = grader_data
96
96
+
let outputs = grader_data
31
97
.tests
32
98
.into_iter()
33
99
.enumerate()
34
34
-
.filter(|(i, test)| {
100
100
+
.map(|(i, test)| {
101
101
+
// In verbose mode make the output a bit easier to follow
102
102
+
if cli.verbose {
103
103
+
println!();
104
104
+
}
35
105
let bar = ProgressBar::new_spinner();
36
106
bar.set_message(format!("Running {}", test.name));
37
107
bar.enable_steady_tick(Duration::from_millis(100));
38
38
-
if cli.skip.map(|skip| i < &skip).unwrap_or(false)
39
39
-
|| cli
40
40
-
.test
41
41
-
.as_ref()
42
42
-
.map(|target| target.to_lowercase() != test.name.to_lowercase())
43
43
-
.unwrap_or(false)
44
44
-
{
45
45
-
bar.finish_with_message(format!("〰️ Skipped {}", test.name));
46
46
-
return false;
108
108
+
109
109
+
let skip_number = cli.skip.map(|skip| i < skip).unwrap_or(false);
110
110
+
111
111
+
let skip_non_target = target_test
112
112
+
.as_ref()
113
113
+
.is_some_and(|s| *s != test.name.to_lowercase());
114
114
+
115
115
+
let (output, verbose_output) = if skip_number || skip_non_target {
116
116
+
(TestOutput::Skipped, None)
117
117
+
} else {
118
118
+
let result = test.run();
119
119
+
let verbose_output =
120
120
+
result
121
121
+
.as_ref()
122
122
+
.ok()
123
123
+
.map(|(_, TestResult { stdout, stderr, .. })| {
124
124
+
CmdOutput(stdout.clone(), stderr.clone(), test.output.clone())
125
125
+
});
126
126
+
(result.into(), verbose_output)
127
127
+
};
128
128
+
129
129
+
let (emoji, msg) = output.output();
130
130
+
131
131
+
bar.finish_with_message(format!("{emoji} {} - {msg}", test.name));
132
132
+
133
133
+
if let (Some(verbose_output), true) = (verbose_output, cli.verbose) {
134
134
+
println!(
135
135
+
"{}",
136
136
+
verbose_output.format_output(&test.name, &test.comparison)
137
137
+
);
47
138
}
48
48
-
match test.run() {
49
49
-
Ok((matched, result)) => {
50
50
-
if cli.verbose {
51
51
-
println!(
52
52
-
"Stdout of {}:\n{}\n\nStderr of {}:\n{}",
53
53
-
test.name, result.stdout, test.name, result.stderr
54
54
-
);
55
55
-
}
56
56
-
match result.status {
57
57
-
Ok(code) => {
58
58
-
if code != 0 {
59
59
-
bar.finish_with_message(format!(
60
60
-
"⚠️ {} Exited with code {code}",
61
61
-
test.name
62
62
-
))
63
63
-
}
64
64
-
if matched {
65
65
-
bar.finish_with_message(format!("✅ {} passed!", test.name));
66
66
-
true
67
67
-
} else {
68
68
-
bar.finish_with_message(format!(
69
69
-
"❌ {} did not give the correct output",
70
70
-
test.name
71
71
-
));
72
72
-
false
73
73
-
}
74
74
-
}
75
75
-
Err(why) => {
76
76
-
bar.finish_with_message(format!(
77
77
-
"❌ Failed to run {}: {why:?}",
78
78
-
test.name
79
79
-
));
80
80
-
false
81
81
-
}
82
82
-
}
83
83
-
}
84
84
-
Err(why) => {
85
85
-
bar.finish_with_message(format!("❌ Failed to run {}: {why:?}", test.name));
86
86
-
false
87
87
-
}
88
88
-
}
139
139
+
140
140
+
output
89
141
})
142
142
+
.collect::<Vec<_>>();
143
143
+
144
144
+
let total_tests = outputs.len();
145
145
+
let total_not_skipped = outputs
146
146
+
.iter()
147
147
+
.filter(|o| !matches!(o, TestOutput::Skipped))
148
148
+
.count();
149
149
+
let total_passed = outputs
150
150
+
.iter()
151
151
+
.filter(|o| matches!(o, TestOutput::Passed))
90
152
.count();
91
153
92
92
-
println!("Passed {amount_passed}/{test_len} tests");
93
93
-
if cli.skip.is_some() || cli.test.is_some() {
94
94
-
println!("(Some were skipped)");
154
154
+
let percent_passed = total_passed as f32 / total_not_skipped as f32 * 100_f32;
155
155
+
156
156
+
println!("\n== TEST SUMMARY ==");
157
157
+
println!("{total_passed} / {total_not_skipped} Tests Passed ({percent_passed:.2}%)");
158
158
+
if total_tests != total_not_skipped {
159
159
+
println!("({} Tests Were Skipped)", total_tests - total_not_skipped);
95
160
}
96
161
97
162
Ok(())
+2
-2
src/runner.rs
···
9
9
10
10
#[cfg(not(windows))]
11
11
fn spawn_cmd(cmd: &str) -> Command {
12
12
-
let mut command = Command::new("bash");
13
13
-
command.arg("-c").arg(cmd);
12
12
+
let mut command = Command::new("/usr/bin/env");
13
13
+
command.arg("sh").arg("-c").arg(cmd);
14
14
command
15
15
}
16
16