#!/usr/bin/env python3 """Regression test for the stdout/stderr split of the production CLI. Normal progress, startup/backend configuration, successful file outputs, timings and cache summaries are normal informational output and must go to stdout. Genuine warnings, errors and the Debug per-event direct-fallback diagnostic go to stderr. A mixed success/error line such as ``Rendered ... (ok|write failed)`` must follow its outcome. The checks capture the two streams separately so that a future change which silently moves a normal message to stderr (or an error to stdout) is caught. Small deterministic Minkowski scenes keep the runtime short. """ import os import subprocess import sys import tempfile from pathlib import Path # Keep scratch data inside the pre-approved OpenCode scratch directory. TMP_ROOT = Path('/tmp/opencode') TMP_ROOT.mkdir(parents=True, exist_ok=True) BUILD = Path(sys.argv[1] if len(sys.argv) > 1 else 'build/Release').resolve() ENV = dict(os.environ, OMP_NUM_THREADS='4') COMMON = ['--catalog', 'assets/sky_grid_5deg.csv', '--width', 16, '--height', 8, '--fov-deg', 80, '--exposure', 1e-3, '--coarse-cell-pixels', 8, '--refine-max-level', 0, '--psf-relative-tail', 1e-4] def run(binary, *args, env=ENV): return subprocess.run([str(binary), *map(str, args)], env=env, capture_output=True, text=True) def stderr_errors(result): """Non-advisory stderr lines. A Debug build deliberately logs each direct PSF fallback from the active splat worker; those ``Debug:`` lines are real diagnostics, not a misplaced normal message, so they are excluded when the Debug startup banner is present on stdout. """ lines = [line for line in result.stderr.splitlines() if line.strip()] if 'Debug build:' in result.stdout: lines = [line for line in lines if not line.startswith('Debug:')] return lines binary = BUILD / 'minkowski_sky' assert binary.exists(), f'missing {binary}' # The image extension follows the build's compiled writer, exactly as # tests/test_camera_cli.py detects it from --help (a libpng build advertises # `.png`, a PNG-less build advertises `.ppm`). help_text = run(binary, '--help').stdout ext = 'png' if '.png' in help_text else 'ppm' with tempfile.TemporaryDirectory(prefix='gr-output-streams-', dir=str(TMP_ROOT)) as directory: tmp = Path(directory) # 1) --help is informational: complete usage on stdout, nothing on stderr. help_result = run(binary, '--help') assert help_result.returncode == 0, help_result.stderr assert 'Usage:' in help_result.stdout, help_result.stdout assert help_result.stderr == '', help_result.stderr # 2) An unknown option is an error: usage diagnostic on stderr only. bad_result = run(binary, '--not-an-option') assert bad_result.returncode != 0, bad_result.stdout assert bad_result.stdout == '', bad_result.stdout assert bad_result.stderr != '', 'missing error diagnostic on stderr' # 3) A successful non-verbose single-frame render puts every startup, # statistics and success line on stdout with an empty stderr. single_out = tmp / f'single.{ext}' single = run(binary, *COMMON, '--output', single_out) assert single.returncode == 0, single.stderr assert single_out.exists(), single.stderr assert 'Blackbody backend:' in single.stdout, single.stdout assert 'PSF cache ready:' in single.stdout, single.stdout assert 'Rendered' in single.stdout and '(ok)' in single.stdout, single.stdout assert '(write failed)' not in single.stdout, single.stdout assert not stderr_errors(single), single.stderr # 4) Verbose progress (including the trace-cost line) is still stdout. verbose_out = tmp / f'verbose.{ext}' verbose = run(binary, *COMMON, '--verbose', '--output', verbose_out) assert verbose.returncode == 0, verbose.stderr assert 'Frame 0: tracing' in verbose.stdout, verbose.stdout assert 'Frame 0 trace cost:' in verbose.stdout, verbose.stdout assert not stderr_errors(verbose), verbose.stderr # 5) A short movie exercises the asynchronous writer; its per-frame logs, # timing summary and writer summary are stdout, stderr stays empty. The # 2 s fixture track plus --duration 1 --fps 1 yields two frames, so the # async producer/writer overlap is actually exercised. track = tmp / 'track.csv' track_run = run(binary, '--write-minkowski-accel-track', track, '--duration', 2, '--fps', 30, '--proper-acceleration', 1.52) assert track_run.returncode == 0, track_run.stderr assert track.exists(), track_run.stderr frames_dir = tmp / 'frames' frames_dir.mkdir() movie = run(binary, *COMMON, '--observer-track', track, '--frames-dir', frames_dir, '--frames-prefix', 'frame', '--duration', 1, '--fps', 1, '--verbose', '--output', tmp / f'movie.{ext}') assert movie.returncode == 0, movie.stderr assert (frames_dir / f'frame_000000.{ext}').exists(), movie.stderr assert (frames_dir / f'frame_000001.{ext}').exists(), movie.stderr assert 'Ray trace generation' in movie.stdout, movie.stdout assert movie.stdout.count('Rendered') == 2, movie.stdout assert movie.stdout.count('(ok)') == 2, movie.stdout assert 'Movie timing total' in movie.stdout, movie.stdout assert 'Movie writer summary:' in movie.stdout, movie.stdout assert 'Movie end-to-end wall:' in movie.stdout, movie.stdout assert not stderr_errors(movie), movie.stderr # 6) A genuine warning goes to stderr and does not disturb the success line # on stdout. The fast-mode preview advisory is deterministic in the CPU # build. fast_out = tmp / f'fast.{ext}' fast = run(binary, *COMMON, '--fast-mode', '--output', fast_out) assert fast.returncode == 0, fast.stderr assert 'Fast mode is a preview approximation' in fast.stderr, fast.stderr assert fast.stdout.count('Rendered') == 1, fast.stdout assert '(ok)' in fast.stdout, fast.stdout # The only stderr content is the advisory: no normal line leaked across. assert len(stderr_errors(fast)) == 1, fast.stderr # 7) A failed write must route the mixed success/error line to stderr and # leave stdout free of the success wording. missing_dir = tmp / 'missing_subdir' / f'out.{ext}' failed = run(binary, *COMMON, '--output', missing_dir) assert failed.returncode != 0, failed.stdout assert '(write failed)' in failed.stderr, failed.stderr assert '(write failed)' not in failed.stdout, failed.stdout # 8) A rejected camera velocity is an error on stderr, not stdout. velocity = run(binary, *COMMON, '--observer-velocity', 10, 0, 0, '--output', tmp / f'velocity.{ext}') assert velocity.returncode != 0, velocity.stdout assert 'not timelike' in velocity.stderr, velocity.stderr # stdout may hold only the Debug startup banner; no render ran. assert 'Rendered' not in velocity.stdout, velocity.stdout print('output-stream checks passed: normal success stdout / diagnostics ' 'stderr', flush=True)