#!/usr/bin/env python3 """Native CLI-/Standalone-Parität. Vorher cargo build --release für tbc/tbrt/tb-template.""" import argparse import os from pathlib import Path import shutil import struct import subprocess import sys import tempfile def run(args, cwd, *, data=b'', env=None): return subprocess.run([str(a) for a in args], cwd=cwd, input=data, capture_output=True, timeout=30, env=env) def must(result): assert result.returncode == 0, (result.args, result.returncode, result.stdout, result.stderr) return result def terminal(binary, arguments, cwd, env, abort=False): """Echte Unix-PTY, Eingabe/Abbruch und unveränderte Terminalattribute.""" import fcntl import select import signal import termios import time master, slave = os.openpty() original = termios.tcgetattr(slave) fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 30, 100, 0, 0)) pid = os.fork() if pid == 0: os.setsid() fcntl.ioctl(slave, termios.TIOCSCTTY, 0) for fd in (0, 1, 2): os.dup2(slave, fd) os.close(master) os.close(slave) result = subprocess.run([str(binary), *map(str, arguments)], cwd=cwd, env=env, check=False) restored = termios.tcgetattr(0) == original os._exit(result.returncode if restored else 99) transcript = bytearray() sent = False reaped = False deadline = time.monotonic() + 15 try: while True: if select.select([master], [], [], .05)[0]: transcript.extend(os.read(master, 65536)) if not sent and b'READY' in transcript: os.write(master, b'\x03' if abort else b'Ada\r') sent = True child, status = os.waitpid(pid, os.WNOHANG) if child: reaped = True assert os.waitstatus_to_exitcode(status) == (3 if abort else 0), bytes(transcript) break assert time.monotonic() < deadline, bytes(transcript[-2000:]) assert sent and b'?1049h' in transcript and b'?1049l' in transcript, transcript assert (b'Abgebrochen' if abort else b'PTYAda') in transcript, transcript finally: if not reaped: os.killpg(pid, signal.SIGKILL) os.waitpid(pid, 0) os.close(master) os.close(slave) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--bin-dir', type=Path, default=Path('target/release')) parser.add_argument('--target', required=True) args = parser.parse_args() binaries = args.bin_dir.resolve() suffix = '.exe' if args.target == 'x86_64-pc-windows-msvc' else '' tbc = binaries / ('tbc' + suffix) rt = binaries / ('tbrt' + suffix) if not rt.is_file(): rt = binaries / 'runtimes' / args.target / ('tbrt' + suffix) prepare = binaries / ('tb-template' + suffix) env = dict(os.environ, PATH='') # weder Cargo noch tbc/tbrt über PATH auflösbar with tempfile.TemporaryDirectory(prefix='tb-native-') as work: root = Path(work) template = root / ('runtime' + suffix) shutil.copy2(rt, template) metadata = Path(str(rt) + '.meta') if metadata.is_file(): shutil.copyfile(metadata, Path(str(template) + '.meta')) else: must(run([prepare, template, args.target], root)) empty = run([template], root, env=env) assert empty.returncode == 1 and b'Nutzlast' in empty.stderr, empty cases = { 'console': ('PRINT "Hallo, Welt!"\nPRINT COMMAND$\nINPUT "Name"; n$\nPRINT n$\nLPRINT "Papier"\nEND\n', b'Ada\n'), 'restart': ('OPEN "count.txt" FOR BINARY AS #1\nn%=LOF(1)\nCLOSE #1\nIF n%=0 THEN\nOPEN "count.txt" FOR OUTPUT AS #1\nPRINT #1, "einmal"\nCLOSE #1\nx%=99\nRUN\nEND IF\nPRINT x%; COMMAND$\nEND\n', b''), 'stop': ('PRINT "vor STOP"\nSTOP\nPRINT "falsch"\n', b''), 'error': ('PRINT "vor Fehler"\nERROR 6\n', b''), 'project': ('CALL Ergebnis\nEND\n', b''), 'form': ('VERSION 1.00\nBegin Form Form1\n Caption = "Native Form"\n Width = 40\n Height = 10\n Begin CommandButton Ok\n Caption = "OK"\n Default = -1\n Left = 2\n Top = 2\n Width = 10\n Height = 1\n End\nEnd\nForm1.Show\nSUB Ok_Click\nForm1.Hide\nCLS\nPRINT "FORMDONE"\nEND\nEND SUB\n', b'\n'), 'external': ('RUN "NEXT.TBC"\n', b''), } for name, (code, data) in cases.items(): case = root / name sources, reference, clean = (case / p for p in ('sources', 'reference', 'clean')) for d in (sources, reference, clean): d.mkdir(parents=True) source = sources / ('main.frm' if name == 'form' else 'main.bas') source.write_text(code, encoding='utf-8') if name == 'project': (sources / 'lib.bas').write_text("'$INCLUDE: 'OUTER.BI'\n") (sources / 'outer.bi').write_text("'$INCLUDE: 'INNER.BI'\n") (sources / 'inner.bi').write_text('SUB Ergebnis\nOPEN "seed.txt" FOR INPUT AS #1\nLINE INPUT #1, s$\nCLOSE #1\nOPEN "result.txt" FOR OUTPUT AS #2\nPRINT #2, s$\nCLOSE #2\nPRINT s$\nERROR 6\nEND SUB\n') source = sources / 'app.mak' source.write_text('MAIN.BAS\nLIB.BAS\n') for d in (reference, clean): (d / 'seed.txt').write_text('Grüße\n', encoding='utf-8') if name == 'form': source.write_text(code.replace('PRINT "FORMDONE"', 'CALL Ergebnis'), encoding='utf-8') (sources / 'lib.bas').write_text("'$INCLUDE: 'OUTER.BI'\n") (sources / 'outer.bi').write_text("'$INCLUDE: 'INNER.BI'\n") (sources / 'inner.bi').write_text('SUB Ergebnis\nPRINT "FORMDONE"\nEND SUB\n') source = sources / 'app.mak' source.write_text('MAIN.FRM\nLIB.BAS\n') if name == 'external': child = sources / 'next.bas' child.write_text('PRINT "EXTERNAL"; COMMAND$\nEND\n') must(run([tbc, 'build', child], root)) shutil.copy2(child.with_suffix('.tbc'), clean / 'next.tbc') expected = run([tbc, 'run', source, 'alpha', 'ä beta'], reference, data=data) exe = clean / ('program' + suffix) build_source = source if name == 'error': must(run([tbc, 'build', source], root)) build_source = source.with_suffix('.tbc') command = [tbc, 'build', build_source, '--exe', '--target', args.target, '--template', template, '-o', exe] must(run(command, root, env=env)) original = exe.read_bytes() conflict = run(command, root, env=env) assert conflict.returncode == 1 and b'--force' in conflict.stderr assert exe.read_bytes() == original must(run(command + ['--force'], root, env=env)) if sys.platform == 'darwin': must(run(['/usr/bin/codesign', '--verify', '--strict', exe], root)) shutil.rmtree(sources) actual = run([exe, 'alpha', 'ä beta'], clean, data=data, env=env) assert (actual.returncode, actual.stdout, actual.stderr) == (expected.returncode, expected.stdout, expected.stderr), (name, expected, actual) if name == 'form': assert b'FORMDONE' in actual.stdout, actual if name == 'restart': assert b'99' not in actual.stdout and b'alpha' in actual.stdout, actual if name == 'project': assert b'inner.bi:9:1:' in actual.stderr.lower(), actual for file in reference.iterdir(): assert (clean / file.name).read_bytes() == file.read_bytes(), file assert sorted(p.name for p in clean.iterdir()) == sorted([exe.name] + [p.name for p in reference.iterdir()] + (['next.tbc'] if name == 'external' else [])) print('PASS native', name, flush=True) if os.name == 'posix': source = root / 'terminal.bas' source.write_text('PRINT "READY"\nINPUT s$\nPRINT "PTY"; s$\nEND\n') exe = root / 'terminal' must(run([tbc, 'build', source, '--exe', '--template', template, '--target', args.target, '-o', exe], root, env=env)) for abort in (False, True): terminal(tbc, ['run', source], root, env, abort) source.unlink() for abort in (False, True): terminal(exe, [], root, env, abort) print('PASS PTY Eingabe, Abbruch und Wiederherstellung (CLI/native)', flush=True) if os.name == 'posix': import signal import time source = root / 'cancel.bas' source.write_text('PRINT "nicht veröffentlicht"\nEND\n') output = root / 'cancelled' for sig in (signal.SIGINT, signal.SIGTERM): output.write_bytes(b'original') process = subprocess.Popen([str(tbc), 'build', str(source), '--exe', '--template', str(template), '--target', args.target, '-o', str(output), '--force'], cwd=root, stdout=subprocess.PIPE, stderr=subprocess.PIPE) try: deadline = time.monotonic() + 10 while not list(root.glob('.tb-export-*.tmp')): assert process.poll() is None and time.monotonic() < deadline, 'Export endete vor Abbruchprobe' time.sleep(.001) process.send_signal(sig) stdout, stderr = process.communicate(timeout=15) assert process.returncode == 1 and b'abgebrochen' in stderr, (process.returncode, stdout, stderr) assert output.read_bytes() == b'original' assert not list(root.glob('.tb-export-*.tmp')) finally: if process.poll() is None: process.kill() process.wait() print('PASS Exportabbruch SIGINT/SIGTERM, Original und Bereinigung', flush=True) # Beschädigte Nutzlast wird auf macOS neu signiert, damit der Lader statt des Kernels prüft. valid = (root / 'console/clean' / ('program' + suffix)).read_bytes() footer = valid.rfind(b'TBPCODE!') assert footer >= 0 for name, mutate in ( ('length', lambda b: struct.pack_into('