Skip to content

Commit 2b96960

Browse files
Merge pull request #290 from webtech-network/expect-file-artifact
(feat) new I/O test : Expect File Output
2 parents 9a16f24 + 232a52b commit 2b96960

9 files changed

Lines changed: 806 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ Tests command-line programs by providing inputs and validating outputs.
195195
| Test Name | Description | Key Parameters |
196196
|--------------------|---------------------------------------------------------|----------------------------------------------|
197197
| `expect_output` | Execute program with inputs and verify output | `inputs`, `expected_output`, `program_command` |
198+
| `expect_file_artifact` | Execute program and validate a generated output file | `artifact_path`, `expected_content`, `program_command` |
198199
| `dont_fail` | Validates that a program does not crash on a given input | `inputs`, `program_command` |
199200
| `forbidden_import` | Analyzes a file looking for specified libraries imports | `forbidden_imports` |
200201

autograder/template_library/input_output.py

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def run_sandbox_execution(self, sandbox: SandboxContainer, inputs: list = None,
3939
command = ' '.join(inputs) if isinstance(inputs, list) else str(inputs)
4040
return sandbox.run_command(command)
4141

42-
def check_for_base_errors(self, output) -> TestResult:
42+
def check_for_base_errors(self, output, **kwargs) -> TestResult:
4343
"""
4444
Checks for Timeout, Compilation Error, or Runtime Error in the sandbox output.
4545
Returns a TestResult with score 0.0 if an error is found, or None if successful.
@@ -119,7 +119,7 @@ def execute(self, files, sandbox: SandboxContainer, *args, inputs: list = None,
119119
)
120120

121121
# Check for generic execution failures
122-
error_result = self.check_for_base_errors(output)
122+
error_result = self.check_for_base_errors(output, locale=kwargs.get("locale"))
123123
if error_result:
124124
return error_result
125125

@@ -191,7 +191,7 @@ def execute(self, files, sandbox: SandboxContainer, *args, user_input: str = "",
191191
)
192192

193193
# Check for generic execution failures
194-
error_result = self.check_for_base_errors(output)
194+
error_result = self.check_for_base_errors(output, locale=kwargs.get("locale"))
195195
if error_result:
196196
return error_result
197197

@@ -211,6 +211,130 @@ def execute(self, files, sandbox: SandboxContainer, *args, user_input: str = "",
211211

212212

213213

214+
# ===============================================================
215+
# TestFunction for File Artifact Validation
216+
# ===============================================================
217+
218+
class ExpectFileArtifactTest(BaseExecutionTest):
219+
"""
220+
Tests a command-line program by running it, then extracting a generated
221+
file from the sandbox and comparing its content against expected values.
222+
223+
Supports exact, contains, and regex matching modes.
224+
"""
225+
226+
@property
227+
def name(self):
228+
return "expect_file_artifact"
229+
230+
@property
231+
def description(self):
232+
return t("io.expect_file_artifact.description")
233+
234+
@property
235+
def required_file(self):
236+
return None
237+
238+
@property
239+
def parameter_description(self):
240+
return [
241+
ParamDescription("program_command", t("io.expect_file_artifact.params.program_command"), "string or dict"),
242+
ParamDescription("artifact_path", t("io.expect_file_artifact.params.artifact_path"), "string"),
243+
ParamDescription("expected_content", t("io.expect_file_artifact.params.expected_content"), "string"),
244+
ParamDescription("match_mode", t("io.expect_file_artifact.params.match_mode"), "string"),
245+
ParamDescription("inputs", t("io.expect_file_artifact.params.inputs"), "list of strings"),
246+
ParamDescription("normalization", t("io.expect_file_artifact.params.normalization"), "boolean"),
247+
]
248+
249+
@staticmethod
250+
def _validate_artifact_path(artifact_path: str) -> Optional[str]:
251+
"""Return an error message if the path is unsafe, else None."""
252+
if not artifact_path:
253+
return "artifact_path is required"
254+
if artifact_path.startswith("/") or ".." in artifact_path.split("/"):
255+
return f"Invalid artifact_path (absolute or traversal): {artifact_path}"
256+
return None
257+
258+
@staticmethod
259+
def _normalize(text: str) -> str:
260+
"""Normalize line endings and strip trailing whitespace per line."""
261+
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
262+
return "\n".join(line.rstrip() for line in lines).strip()
263+
264+
@staticmethod
265+
def _match(actual: str, expected: str, mode: str) -> bool:
266+
if mode == "contains":
267+
return expected in actual
268+
if mode == "regex":
269+
return bool(re.search(expected, actual))
270+
return actual == expected
271+
272+
def execute(self, files, sandbox: SandboxContainer, *args,
273+
program_command: Optional[str] = None,
274+
artifact_path: str = "",
275+
expected_content: str = "",
276+
match_mode: str = "exact",
277+
inputs: list = None,
278+
normalization: bool = True,
279+
**kwargs) -> TestResult:
280+
locale = kwargs.get("locale")
281+
282+
# Validate artifact_path
283+
path_error = self._validate_artifact_path(artifact_path)
284+
if path_error:
285+
return TestResult(test_name=self.name, score=0.0,
286+
report=t("io.expect_file_artifact.report.invalid_path", locale=locale, error=path_error))
287+
288+
# Validate match_mode
289+
if match_mode not in ("exact", "contains", "regex"):
290+
return TestResult(test_name=self.name, score=0.0,
291+
report=t("io.expect_file_artifact.report.invalid_match_mode", locale=locale, mode=match_mode))
292+
293+
# Pre-compile regex to catch bad patterns early
294+
if match_mode == "regex":
295+
try:
296+
re.compile(expected_content)
297+
except re.error as e:
298+
return TestResult(test_name=self.name, score=0.0,
299+
report=t("io.expect_file_artifact.report.invalid_regex", locale=locale, error=str(e)))
300+
301+
# Execute student program
302+
try:
303+
output = self.run_sandbox_execution(sandbox=sandbox, inputs=inputs, program_command=program_command)
304+
error_result = self.check_for_base_errors(output, locale=locale)
305+
if error_result:
306+
return error_result
307+
except (ValueError, TimeoutError, RuntimeError) as e:
308+
return TestResult(test_name=self.name, score=0.0,
309+
report=t("io.expect_output.report.internal_error", locale=locale, error=str(e)))
310+
311+
# Extract artifact
312+
full_path = f"/app/{artifact_path}"
313+
try:
314+
extracted = sandbox.extract_file(full_path)
315+
except FileNotFoundError:
316+
return TestResult(test_name=self.name, score=0.0,
317+
report=t("io.expect_file_artifact.report.file_not_found", locale=locale, path=artifact_path))
318+
except (ValueError, RuntimeError) as e:
319+
return TestResult(test_name=self.name, score=0.0,
320+
report=t("io.expect_file_artifact.report.extraction_error", locale=locale, error=str(e)))
321+
322+
# Compare content
323+
actual = extracted.content_text
324+
expected = expected_content
325+
if normalization:
326+
actual = self._normalize(actual)
327+
expected = self._normalize(expected)
328+
329+
if self._match(actual, expected, match_mode):
330+
return TestResult(test_name=self.name, score=100.0,
331+
report=t("io.expect_file_artifact.report.success", locale=locale, path=artifact_path))
332+
333+
return TestResult(test_name=self.name, score=0.0,
334+
report=t("io.expect_file_artifact.report.mismatch", locale=locale,
335+
path=artifact_path, expected=expected, actual=actual))
336+
337+
214338
# ===============================================================
215339
# TestFunction for Forbidden Import Detection
216340
# ===============================================================
@@ -396,6 +520,7 @@ def __init__(self):
396520
self.tests = {
397521
"expect_output": ExpectOutputTest(),
398522
"dont_fail": DontFailTest(),
523+
"expect_file_artifact": ExpectFileArtifactTest(),
399524
"forbidden_import": ForbiddenImportTest(),
400525
}
401526

autograder/translations/en.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,26 @@
467467
"template": {
468468
"name": "Input/Output",
469469
"description": "A template for evaluating assignments based on command-line input and output."
470+
},
471+
"expect_file_artifact": {
472+
"description": "Executes the student program, then extracts a generated file from the sandbox and validates its content against expected values.",
473+
"params": {
474+
"program_command": "(Optional) Command to run the program. Can be a string or dict (multi-lang).",
475+
"artifact_path": "Relative path (under /app) of the file the program must generate (e.g. 'output.txt').",
476+
"expected_content": "The expected file content or pattern to match against.",
477+
"match_mode": "Comparison mode: 'exact' (default), 'contains', or 'regex'.",
478+
"inputs": "List of strings to be sent to the program via stdin.",
479+
"normalization": "Whether to normalize line endings and trim trailing whitespace (default: true)."
480+
},
481+
"report": {
482+
"success": "Success: Artifact '{path}' matches expected content.",
483+
"mismatch": "FAILURE: Content mismatch in artifact '{path}'.\nExpected:\n{expected}\nActual:\n{actual}",
484+
"file_not_found": "FAILURE: Expected artifact '{path}' was not found. Make sure your program creates this file.",
485+
"extraction_error": "FAILURE: Could not read artifact from sandbox.\nDetails: {error}",
486+
"invalid_path": "CONFIGURATION ERROR: Invalid artifact path.\nDetails: {error}",
487+
"invalid_match_mode": "CONFIGURATION ERROR: Invalid match_mode '{mode}'. Must be 'exact', 'contains', or 'regex'.",
488+
"invalid_regex": "CONFIGURATION ERROR: Invalid regex pattern.\nDetails: {error}"
489+
}
470490
}
471491
},
472492
"api_testing": {

autograder/translations/pt_br.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,26 @@
467467
"template": {
468468
"name": "Entrada/Saída (I/O)",
469469
"description": "Um modelo para avaliar trabalhos com base na entrada e saída de linha de comando."
470+
},
471+
"expect_file_artifact": {
472+
"description": "Executa o programa do aluno, extrai um arquivo gerado do sandbox e valida seu conteúdo contra os valores esperados.",
473+
"params": {
474+
"program_command": "(Opcional) Comando para executar o programa. Pode ser uma string ou dict (multi-idioma).",
475+
"artifact_path": "Caminho relativo (sob /app) do arquivo que o programa deve gerar (ex: 'output.txt').",
476+
"expected_content": "O conteúdo esperado do arquivo ou padrão para comparação.",
477+
"match_mode": "Modo de comparação: 'exact' (padrão), 'contains' ou 'regex'.",
478+
"inputs": "Lista de strings a serem enviadas para o programa via stdin.",
479+
"normalization": "Se deve normalizar quebras de linha e remover espaços em branco no final (padrão: true)."
480+
},
481+
"report": {
482+
"success": "Sucesso: O artefato '{path}' corresponde ao conteúdo esperado.",
483+
"mismatch": "FALHA: Conteúdo divergente no artefato '{path}'.\nEsperado:\n{expected}\nObtido:\n{actual}",
484+
"file_not_found": "FALHA: O artefato esperado '{path}' não foi encontrado. Verifique se o seu programa cria este arquivo.",
485+
"extraction_error": "FALHA: Não foi possível ler o artefato do sandbox.\nDetalhes: {error}",
486+
"invalid_path": "ERRO DE CONFIGURAÇÃO: Caminho de artefato inválido.\nDetalhes: {error}",
487+
"invalid_match_mode": "ERRO DE CONFIGURAÇÃO: match_mode '{mode}' inválido. Deve ser 'exact', 'contains' ou 'regex'.",
488+
"invalid_regex": "ERRO DE CONFIGURAÇÃO: Padrão regex inválido.\nDetalhes: {error}"
489+
}
470490
}
471491
},
472492
"api_testing": {

docs/templates/input_output.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,84 @@ Executes a program with a series of stdin inputs and compares the program's stdo
5656

5757
---
5858

59+
### `expect_file_artifact`
60+
61+
Executes a program and then extracts a generated file from the sandbox container, comparing its content against an expected value. Useful for assignments where students must produce output files (e.g., reports, sorted data, metrics).
62+
63+
| Parameter | Type | Required | Description |
64+
|-----------|------|----------|-------------|
65+
| `artifact_path` | string || Relative path under `/app` of the file the program must generate (e.g., `output.txt`) |
66+
| `expected_content` | string || Expected file content or pattern to match against |
67+
| `program_command` | string || Command to execute (same format as `expect_output`) |
68+
| `match_mode` | string || `exact` (default), `contains`, or `regex` |
69+
| `inputs` | list[string] || List of strings sent to stdin before extraction |
70+
| `normalization` | boolean || Normalize line endings and trim trailing whitespace (default: `true`) |
71+
72+
**Scoring:** 100 if file content matches, 0 otherwise.
73+
74+
**Error handling:** Inherits all execution error detection from `expect_output` (timeouts, compilation errors, runtime errors), plus:
75+
- Missing artifact file
76+
- Artifact extraction failure
77+
- Invalid regex pattern
78+
- Invalid artifact path (absolute or traversal paths are rejected)
79+
80+
**Artifact path conventions:**
81+
- Paths are relative to `/app` (the sandbox working directory)
82+
- Absolute paths (`/etc/passwd`) and traversal paths (`../secret`) are rejected
83+
- The file must be a regular file (not a directory or symlink)
84+
- Maximum extracted file size: 1 MB
85+
86+
**Exact match example:**
87+
```json
88+
{
89+
"name": "expect_file_artifact",
90+
"parameters": {
91+
"inputs": ["1000"],
92+
"program_command": {
93+
"python": "python3 main.py",
94+
"java": "java Main",
95+
"node": "node main.js",
96+
"cpp": "./main"
97+
},
98+
"artifact_path": "matricula_selecao.txt",
99+
"match_mode": "exact",
100+
"expected_content": "comparacoes: 10\nmovimentacoes: 4\ntempo: 0.001s"
101+
},
102+
"weight": 100
103+
}
104+
```
105+
106+
**Regex match example** (for variable values like execution time):
107+
```json
108+
{
109+
"name": "expect_file_artifact",
110+
"parameters": {
111+
"inputs": ["1000"],
112+
"program_command": "CMD",
113+
"artifact_path": "matricula_insercao.txt",
114+
"match_mode": "regex",
115+
"expected_content": "comparacoes:\\s*\\d+\\s+movimentacoes:\\s*\\d+\\s+tempo:\\s*[0-9.]+s"
116+
},
117+
"weight": 100
118+
}
119+
```
120+
121+
**Contains match example:**
122+
```json
123+
{
124+
"name": "expect_file_artifact",
125+
"parameters": {
126+
"program_command": "python3 main.py",
127+
"artifact_path": "report.txt",
128+
"match_mode": "contains",
129+
"expected_content": "PASSED"
130+
},
131+
"weight": 50
132+
}
133+
```
134+
135+
---
136+
59137
### `dont_fail`
60138

61139
Executes a program with a specific input and verifies it completes **without crashing**. The program's stdout is ignored — this test only validates that execution succeeds. Useful for testing error handling (e.g., sending invalid input).

sandbox_manager/models/sandbox_models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ def __str__(self):
4646
return self.stdout
4747

4848

49+
@dataclass
50+
class ExtractedFile:
51+
"""Result of extracting a single file from a sandbox container."""
52+
path: str
53+
content_bytes: bytes
54+
size: int
55+
content_text: str = ""
56+
encoding: str = "utf-8"
57+
58+
4959
class HttpResponse:
5060
"""Wrapper for HTTP responses from containerized applications."""
5161

0 commit comments

Comments
 (0)