diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba0430d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4769bf5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright 2024 Alexey Kutepov + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c345028 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# rere.py (**Re**cord **Re**play) + +Universal Behavior Testing Tool in Python. + +## Quick Start + +1. Create a file with a shell command line per line. Let's call it `test.list`. +2. Record the expected behavior of each shell command: +```console +$ ./rere.py record test.list +``` +The above command should create `test.list.bi` file with stdout, stderr, and returncode captured as the expected behavior. +3. Replay the command lines checking their behavior against the recorded one: +```console +$ ./rere.py record test.list.bia +``` diff --git a/rere.py b/rere.py new file mode 100755 index 0000000..667fbbb --- /dev/null +++ b/rere.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +import sys +import subprocess +from difflib import unified_diff +from typing import List, BinaryIO, Tuple, Optional + +def read_blob_field(f: BinaryIO, name: bytes) -> bytes: + line = f.readline() + field = b':b ' + name + b' ' + assert line.startswith(field), "%s" % field + assert line.endswith(b'\n') + size = int(line[len(field):-1]) + blob = f.read(size) + assert f.read(1) == b'\n' + return blob + +def read_int_field(f: BinaryIO, name: bytes) -> int: + line = f.readline() + field = b':i ' + name + b' ' + assert line.startswith(field) + assert line.endswith(b'\n') + return int(line[len(field):-1]) + +def write_int_field(f: BinaryIO, name: bytes, value: int): + f.write(b':i %s %d\n' % (name, value)) + +def write_blob_field(f: BinaryIO, name: bytes, blob: bytes): + f.write(b':b %s %d\n' % (name, len(blob))) + f.write(blob) + f.write(b'\n') + +def capture(shell: str) -> dict: + print(f"Capturing `{shell}`...") + process = subprocess.run(['sh', '-c', shell], capture_output = True) + return { + 'shell': shell, + 'returncode': process.returncode, + 'stdout': process.stdout, + 'stderr': process.stderr, + } + +def dump_snapshots(file_path: str, snapshots: [dict]): + with open(file_path, "wb") as f: + write_int_field(f, b"count", len(snapshots)) + for snapshot in snapshots: + write_blob_field(f, b"shell", bytes(snapshot['shell'], 'utf-8')) + write_int_field(f, b"returncode", snapshot['returncode']) + write_blob_field(f, b"stdout", snapshot['stdout']) + write_blob_field(f, b"stderr", snapshot['stderr']) + +def load_snapshots(file_path: str) -> [dict]: + snapshots = [] + with open(file_path, "rb") as f: + count = read_int_field(f, b"count") + for _ in range(count): + shell = read_blob_field(f, b"shell") + returncode = read_int_field(f, b"returncode") + stdout = read_blob_field(f, b"stdout") + stderr = read_blob_field(f, b"stderr") + snapshot = { + "shell": shell, + "returncode": returncode, + "stdout": stdout, + "stderr": stderr, + } + snapshots.append(snapshot) + return snapshots + +if __name__ == '__main__': + program_name, *argv = sys.argv + + if len(argv) == 0: + print(f'Usage: {program_name} ') + print('ERROR: no subcommand is provided') + exit(1) + subcommand, *argv = argv + + if subcommand == 'record': + if len(argv) == 0: + print(f'Usage: {program_name} {subcommand} ') + print('ERROR: no test.list is provided') + exit(1) + test_list_path, *argv = argv + + snapshots = [] + with open(test_list_path) as f: + snapshots = [capture(shell.strip()) for shell in f] + + dump_snapshots(f'{test_list_path}.bi', snapshots) + elif subcommand == 'replay': + if len(argv) == 0: + print(f'Usage: {program_name} {subcommand} ') + print('ERROR: no test.list is provided') + exit(1) + test_list_path, *argv = argv + + snapshots = load_snapshots(f'{test_list_path}.bi') + for snapshot in snapshots: + print(f"Replaying `{snapshot['shell']}`...") + process = subprocess.run(['sh', '-c', snapshot['shell']], capture_output = True); + if process.returncode != snapshot['returncode']: + print(f"UNEXPECTED RETURN CODE:") + print(f" EXPECTED: {snapshot['returncode']}") + print(f" ACTUAL: {process.returncode}") + exit(1) + if process.stdout != snapshot['stdout']: + a = snapshot['stdout'].decode('utf-8').splitlines(keepends=True) + b = process.stdout.decode('utf-8').splitlines(keepends=True) + print(f"UNEXPECTED STDOUT:") + for line in unified_diff(a, b, fromfile="expected", tofile="actual"): + print(line, end='') + exit(1) + if process.stderr != snapshot['stderr']: + a = snapshot['stderr'].decode('utf-8').splitlines(keepends=True) + b = process.stderr.decode('utf-8').splitlines(keepends=True) + print(f"UNEXPECTED STDERR:") + for line in unified_diff(a, b, fromfile="expected", tofile="actual"): + print(line, end='') + exit(1) + else: + print(f'ERROR: unknown subcommand {subcommand}'); + exit(1); diff --git a/test.list b/test.list new file mode 100644 index 0000000..fcefd3d --- /dev/null +++ b/test.list @@ -0,0 +1,3 @@ +echo 'Hello, World' +echo 'Foo, bar' +echo 'Ur, mom' diff --git a/test.list.bi b/test.list.bi new file mode 100644 index 0000000..a4f8eff --- /dev/null +++ b/test.list.bi @@ -0,0 +1,25 @@ +:i count 3 +:b shell 19 +echo 'Hello, World' +:i returncode 0 +:b stdout 13 +Hello, World + +:b stderr 0 + +:b shell 15 +echo 'Foo, bar' +:i returncode 0 +:b stdout 9 +Foo, bar + +:b stderr 0 + +:b shell 14 +echo 'Ur, mom' +:i returncode 0 +:b stdout 8 +Ur, mom + +:b stderr 0 +