Wait for both convoy cargo phases and use actual magazine stock. Resolve empty primary weapon ammunition from muzzle metadata and recover cleanly from failed initial settings saves. Update native fixtures, scoped cleanup guards, packaging evidence and validation documentation. Make reviewed fixture tests checkout-relative. All repository checks pass against the isolated staged tree; native resupply completion and multiplayer qualification remain outstanding.
220 lines
21 KiB
Python
220 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""Run bounded native Workbench smoke tests inside a windowless Gamescope display.
|
|
Requires Linux, gamescope, Proton, and a disposable COPY of a working Tools compat prefix.
|
|
No desktop rules or existing Wine processes are changed. This is not a dedicated server.
|
|
"""
|
|
import argparse, fcntl, json, os, shutil, signal, subprocess, time
|
|
from pathlib import Path
|
|
from native_provenance import snapshot, persist, bindings, seal_run
|
|
from headless_game import assert_prefix_idle, cleanup_owned_run
|
|
from external_addons import policy as external_policy, prepare_profile, verify_profile
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
|
|
p.add_argument('--external-addons', type=Path, help='Installed RHS/WCS/BMP source root; expose only audited closure in a separate Chat profile')
|
|
p.add_argument('--compat', type=Path, required=True, help='Disposable cloned Proton compatdata directory outside Steam')
|
|
p.add_argument('--profile', type=Path, required=True, help='Separate disposable successor Workbench profile')
|
|
p.add_argument('--smoke', choices=['escape-route', 'optional-site-skip', 'infantry-combat', 'medical-combat', 'hunter-faction', 'unconscious-camera', 'town-garrisons', 'site-garrisons', 'radio-interaction', 'enemy-loadout', 'aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'hq-local-handoff', 'infantry-transfer', 'hq-body', 'convoy', 'supply-convoys', 'hq-memory', 'hq-awareness', 'aircraft-field', 'aircraft-route', 'native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
|
|
p.add_argument('--convoy-cover', action='store_true', help='After convoy driving, arrange the existing prisoner to verify native sighting/radio HOLD and RESUME')
|
|
p.add_argument('--field-terrain-audit', action='store_true', help='After a fixed aircraft-field survey refusal, log one read-only 1681-node coarse terrain grid; never selects or qualifies a corridor')
|
|
p.add_argument('--field-service-cancel', action='store_true', help='Explicit aircraft-field variant: Stop the original retained service during measured cruise; requires physical cancellation landing')
|
|
p.add_argument('--route-service-cancel', action='store_true', help='Explicit aircraft-route variant: stop the retained service after actual airborne translation and require controlled cancellation landing')
|
|
p.add_argument('--station-teardown-diagnostics', action='store_true', help='Opt-in bounded native deletion diagnostics; preserves all validation errors')
|
|
p.add_argument('--flight-autohover', action='store_true', help='Opt-in owned native helicopter autohover experiment; requires Core powered-flight and actor-local action inputs')
|
|
p.add_argument('--flight-action-inputs', action='store_true', help='Experimental exact-AI ActionManager inputs; Core powered-flight fixture only')
|
|
p.add_argument('--flight-input-diagnostics', action='store_true', help='Opt-in read-only powered-flight input/application and native rotor signal observations')
|
|
p.add_argument('--recon-takeover-only', action='store_true', help='Air-recon fixture: only native faction handover/release and explicit aircraft deletion')
|
|
p.add_argument('--rounds', type=int, default=2)
|
|
p.add_argument('--timeout', type=int, default=360)
|
|
p.add_argument('--project', choices=['core', 'chat'], default='chat', help='Diagnostic fixture family (both load the combined mod)')
|
|
a = p.parse_args()
|
|
if a.recon_takeover_only and (a.smoke != 'air-recon' or a.project != 'core' or a.rounds != 1): p.error('--recon-takeover-only requires Core air-recon and one round')
|
|
steam, compat, profile = a.steam.resolve(), a.compat.resolve(), a.profile.resolve()
|
|
if not 1 <= a.rounds <= 10 or not 60 <= a.timeout <= 1800:
|
|
p.error('Use 1..10 rounds and a 60..1800 second timeout')
|
|
if a.field_terrain_audit and a.smoke != 'aircraft-field': p.error('--field-terrain-audit requires --smoke aircraft-field')
|
|
if a.field_terrain_audit and a.field_service_cancel: p.error('--field-terrain-audit and --field-service-cancel are mutually exclusive')
|
|
if a.field_service_cancel and a.smoke != 'aircraft-field': p.error('--field-service-cancel requires --smoke aircraft-field')
|
|
if a.route_service_cancel and a.smoke != 'aircraft-route': p.error('--route-service-cancel requires --smoke aircraft-route')
|
|
if a.smoke == 'mortar-courier' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 500):
|
|
p.error('mortar-courier requires --project chat --rounds 1 --timeout at least 500')
|
|
if a.smoke in {'mortar-refresh', 'mortar-salvo', 'mortar-expiry', 'mortar-transfer', 'mortar-fire'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 400):
|
|
p.error('mortar-refresh/mortar-salvo/mortar-expiry/mortar-transfer/mortar-fire requires --project chat --rounds 1 --timeout at least 400')
|
|
if a.smoke in {'aircraft-field', 'aircraft-route', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 420): p.error('aircraft-field/aircraft-route/aircraft-control/aircraft-cancel/aircraft-translate requires Chat, one round, and at least 420 seconds')
|
|
if a.convoy_cover and a.smoke != 'convoy': p.error('--convoy-cover requires --smoke convoy')
|
|
if a.convoy_cover and a.timeout < 900: p.error('--convoy-cover requires timeout at least900 seconds')
|
|
if a.smoke == 'supply-convoys' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 900): p.error('supply-convoys requires Chat, one round, and at least900 seconds')
|
|
if a.smoke == 'convoy' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 600): p.error('convoy requires Chat, exactly one round, and timeout at least600 seconds')
|
|
if a.smoke == 'infantry-transfer' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 360): p.error('infantry-transfer requires Chat, one round and timeout at least360 seconds')
|
|
if a.smoke == 'hq-local-handoff' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 360): p.error('hq-local-handoff requires Chat, one round and timeout at least360 seconds')
|
|
if a.smoke in {'hq-memory', 'hq-awareness', 'hq-body'} and (a.project != 'chat' or a.rounds != 1): p.error('hq-memory/hq-awareness/hq-body requires Chat and exactly one round')
|
|
if a.smoke == 'map-ownership' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 180): p.error('map-ownership requires Chat, one round, and at least 180 seconds')
|
|
if a.smoke == 'powered-translate' and (a.project != 'core' or not a.flight_action_inputs or not a.flight_autohover): p.error('powered-translate requires Core with flight-action-inputs and flight-autohover')
|
|
if a.flight_autohover and (a.project != 'core' or a.smoke not in {'powered-flight', 'powered-translate'} or not a.flight_action_inputs): p.error('flight-autohover requires Core powered-flight with flight-action-inputs')
|
|
if a.flight_action_inputs and (a.project != 'core' or a.smoke not in {'powered-flight', 'powered-translate'}): p.error('flight-action-inputs requires the Core powered-flight fixture')
|
|
if a.smoke in {'powered-flight', 'powered-translate'} and (a.project != 'core' or a.rounds != 1 or a.timeout < 420): p.error('powered-flight requires Core, one round, and at least 420 seconds')
|
|
if a.smoke == 'station-teardown' and (a.project != 'core' or a.rounds != 1): p.error('station-teardown requires --project core --rounds 1')
|
|
if a.smoke in {'native-end-active', 'native-end-preparing'}:
|
|
if a.project != 'core' or a.rounds != 2 or a.timeout < 120: p.error('native-end fixtures require Core, exactly two rounds and at least 120 seconds')
|
|
fixture_names = ['erri-native-end-round.json', 'erri-native-end-round.json.tmp', 'erri-native-end-round.json.bak']
|
|
if any((profile/name).exists() or (profile/name).is_symlink() for name in fixture_names): p.error('native-end fixture paths must not preexist; preserve prior evidence before retrying')
|
|
if a.smoke == 'environment-restart':
|
|
if a.project != 'core' or a.rounds != 2: p.error('environment-restart requires --project core --rounds 2')
|
|
fixture_names = ['erri-environment-restart.json', 'erri-environment-restart.json.bak', 'erri-environment-restart.json.tmp', 'erri-environment-restart-round.json']
|
|
if any((profile/name).exists() or (profile/name).is_symlink() for name in fixture_names): p.error('environment-restart fixture paths must not preexist; preserve or move prior evidence before retrying')
|
|
if compat.is_relative_to(steam) or not (compat/'pfx/system.reg').is_file():
|
|
p.error('--compat must be a disposable cloned prefix outside the Steam installation')
|
|
if a.external_addons and profile == Path('/tmp/erri-workbench-profile'): p.error('Use a separate external-dependency profile; preserve the vanilla test profile')
|
|
if not a.external_addons: p.error('The combined mod requires --external-addons pointing to installed authorized RHS/WCS/BMP dependencies')
|
|
prefix_lock = (compat/'.erri-workbench.lock').open('a')
|
|
try: fcntl.flock(prefix_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError: p.error('Another test runner owns this disposable Wine prefix')
|
|
# The lock must be held before the preflight; never repair a busy prefix here.
|
|
try: assert_prefix_idle(compat)
|
|
except (OSError,ValueError) as error: p.error(str(error))
|
|
if profile.is_relative_to(ROOT) or profile.is_relative_to(steam):
|
|
p.error('--profile must be outside source and Steam directories')
|
|
if not shutil.which('gamescope') or not shutil.which('pactl'): p.error('gamescope and pactl are required')
|
|
wb = steam/'steamapps/common/Arma Reforger Tools/Workbench'
|
|
proton = steam/'steamapps/common/Proton - Experimental/proton'
|
|
vanilla = steam/'steamapps/common/Arma Reforger/addons/data'
|
|
if not proton.is_file() or not (wb/'ArmaReforgerWorkbenchSteamDiag.exe').is_file():
|
|
p.error('Stable Reforger Tools and Proton Experimental must already be installed')
|
|
profile.mkdir(parents=True, exist_ok=True)
|
|
addons = profile/'addons'
|
|
addons.mkdir(exist_ok=True)
|
|
link = addons/'ArmaReforger'
|
|
if not link.exists(): link.symlink_to(vanilla, target_is_directory=True)
|
|
external_inputs = None
|
|
external_cache = profile/'external-source-cache.json'
|
|
allowed_profile_addons = {'ArmaReforger', 'saves'}
|
|
if a.external_addons:
|
|
try:
|
|
external_rules = external_policy(ROOT)
|
|
external_inputs = prepare_profile(addons, a.external_addons, external_rules, external_cache)
|
|
allowed_profile_addons.update('ERRI_'+guid for guid in external_inputs)
|
|
except (OSError, ValueError) as error: p.error(str(error))
|
|
if link.resolve() != vanilla.resolve() or any(x.name not in allowed_profile_addons for x in addons.iterdir()) or any((addons/'saves').rglob('*.gproj')):
|
|
p.error('Test profile addons must contain only vanilla and the explicit audited external closure')
|
|
profile_arg = 'Z:'+str(profile)
|
|
# Refuse concurrency instead of terminating anybody else's editor.
|
|
for proc in Path('/proc').iterdir():
|
|
if not proc.name.isdigit(): continue
|
|
try:
|
|
args = (proc/'cmdline').read_bytes().split(b'\0')
|
|
if profile_arg.encode() in args and any(x.endswith(b'ArmaReforgerWorkbenchSteamDiag.exe') for x in args):
|
|
p.error('Another Workbench already uses this test profile')
|
|
except (FileNotFoundError, PermissionError, ProcessLookupError): pass
|
|
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720', '-w', '1280', '-h', '720', '-r', '30', '--', str(proton), 'run', str(wb/'ArmaReforgerWorkbenchSteamDiag.exe'), '-gproj', 'Z:'+str(ROOT/'addons/core/addon.gproj'), '-addonsDir', 'Z:'+str(ROOT/'addons'), '-profile', profile_arg, '-logconsole', '-wbModule=WorldEditor', '-load', 'Worlds/ERR_Everon.ent', '-plugin=ERR_ValidateWorldPlugin', '-errPlay', '1', '-errSmoke', '1', '-errSmokeOutcome', a.smoke, '-noThrow', '-disableCrashReporter']
|
|
if a.convoy_cover: command += ['-errConvoyCover', '1']
|
|
if a.field_service_cancel: command += ['-errSmokeFieldServiceCancel', '1']
|
|
if a.field_terrain_audit: command += ['-errSmokeFieldTerrainAudit', '1']
|
|
if a.route_service_cancel: command += ['-errSmokeRouteServiceCancel', '1']
|
|
if a.station_teardown_diagnostics: command += ['-errStationTeardownDiag', '1']
|
|
if a.flight_input_diagnostics: command += ['-errFlightInputDiag', '1']
|
|
if a.flight_action_inputs: command += ['-errFlightActionInputs', '1']
|
|
if a.flight_autohover: command += ['-errFlightAutohover', '1']
|
|
if a.recon_takeover_only: command += ['-errReconTakeoverOnly', '1']
|
|
if a.project == 'core':
|
|
if a.smoke not in {'optional-site-skip', 'infantry-combat', 'medical-combat', 'hunter-faction', 'unconscious-camera', 'town-garrisons', 'site-garrisons', 'radio-interaction', 'enemy-loadout', 'aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'native-end-active', 'native-end-preparing', 'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'victory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'}: p.error('Unsupported Core-only fixture')
|
|
command[command.index('-plugin=ERR_ValidateWorldPlugin')] = '-plugin=ERR_ValidateBaseWorldPlugin'
|
|
command[command.index('-errSmoke')] = '-errBaseSmoke'
|
|
if a.smoke in {'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'} and a.project != 'core': p.error('This fixture requires --project core')
|
|
if a.smoke in {'startup-failure', 'site-survey', 'transport-survey', 'extraction-startup-failure', 'extraction-boundary-failure'} and (a.project != 'core' or a.rounds != 1): p.error('This diagnostic requires --project core --rounds 1')
|
|
env = os.environ.copy()
|
|
env.update(STEAM_COMPAT_DATA_PATH=str(compat), STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam), WINEPREFIX=str(compat/'pfx'))
|
|
# A test-only sink prevents startup sounds; a stream check also catches a saved
|
|
# explicit Wine device selection. Never change the user's default sink.
|
|
silent_sink = 'erri_tests_silent'
|
|
sinks = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'short', 'sinks'], text=True))
|
|
if not any(sink['name'] == silent_sink for sink in sinks):
|
|
subprocess.run(['pactl', 'load-module', 'module-null-sink', 'sink_name='+silent_sink, 'sink_properties=device.description=ERRI_Silent_Tests'], check=True, stdout=subprocess.DEVNULL)
|
|
subprocess.run(['pactl', 'set-sink-mute', silent_sink, '1'], check=True)
|
|
subprocess.run(['pactl', 'set-sink-volume', silent_sink, '0%'], check=True)
|
|
sinks = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'short', 'sinks'], text=True))
|
|
silent_index = next(sink['index'] for sink in sinks if sink['name'] == silent_sink)
|
|
env.update(PULSE_SINK=silent_sink, PULSE_PROP='application.id=org.escapistsrereintroduced.tests')
|
|
muted_streams = set()
|
|
def silence_test_streams():
|
|
streams = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'sink-inputs'], text=True))
|
|
for stream in streams:
|
|
props = stream.get('properties', {})
|
|
owned = props.get('application.id') == 'org.escapistsrereintroduced.tests'
|
|
pid = props.get('application.process.id', '')
|
|
if not owned and pid.isdigit():
|
|
try:
|
|
target = str(compat/'pfx').encode()
|
|
values = (Path('/proc')/pid/'environ').read_bytes().split(b'\0')
|
|
owned = any(value.startswith(b'WINEPREFIX=') and value[len(b'WINEPREFIX='):].rstrip(b'/') == target for value in values)
|
|
except (FileNotFoundError, PermissionError, ProcessLookupError): pass
|
|
if not owned: continue
|
|
index = str(stream['index'])
|
|
if stream.get('sink') != silent_index:
|
|
subprocess.run(['pactl', 'move-sink-input', index, silent_sink], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
if not stream.get('mute'):
|
|
subprocess.run(['pactl', 'set-sink-input-mute', index, '1'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
if index not in muted_streams:
|
|
muted_streams.add(index)
|
|
print('Test audio routed to silent output and muted (stream '+index+')', flush=True)
|
|
# Snapshot addon bytes before launching any engine process.
|
|
captured_sources = snapshot(ROOT, command)
|
|
recorded_sessions = set()
|
|
started = time.time()
|
|
log_root = profile/'logs'
|
|
previous = set(log_root.glob('logs_*')) if log_root.exists() else set()
|
|
result, session, complete_at = 'timeout', None, None
|
|
runner_error = None
|
|
cleanup_ok = True
|
|
cleanup_report = None
|
|
child = None
|
|
with (profile/'headless-launch.log').open('w') as output:
|
|
try:
|
|
child = subprocess.Popen(command, cwd=wb, env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
|
|
while time.time()-started < a.timeout:
|
|
silence_test_streams()
|
|
candidates = [x for x in log_root.glob('logs_*') if x not in previous]
|
|
if candidates:
|
|
session = max(candidates, key=lambda x:x.stat().st_mtime)
|
|
if session not in recorded_sessions:
|
|
persist(session, captured_sources)
|
|
if external_inputs:
|
|
(session/'external-addons-inputs.json').write_text(json.dumps(external_inputs, indent=2)+'\n')
|
|
recorded_sessions.add(session)
|
|
console = session/'console.log'
|
|
content = console.read_text(errors='replace') if console.exists() else ''
|
|
if ('Reintroduced: scenario preparation failed' in content and a.smoke not in {'startup-failure', 'extraction-startup-failure', 'extraction-boundary-failure'}) or 'ERR SMOKE FAIL:' in content or 'Virtual Machine Exception' in content or 'Can\'t compile "Game"' in content:
|
|
result = 'test-failed'; break
|
|
if content.count('ERR SMOKE COMPLETE:') >= a.rounds:
|
|
if complete_at is None: complete_at = time.time()
|
|
if time.time()-complete_at >= 6: result = 'smoke-completed'; break
|
|
if child.poll() is not None: result = 'process-exited'; break
|
|
time.sleep(1)
|
|
except KeyboardInterrupt:
|
|
result = 'interrupted'
|
|
except (OSError,ValueError,subprocess.SubprocessError) as error:
|
|
result = 'runner-failed'; runner_error = str(error)
|
|
finally:
|
|
result_before_cleanup = result
|
|
if child is not None:
|
|
cleanup_report = cleanup_owned_run(child,proton,env,compat)
|
|
cleanup_ok = cleanup_report['cleanup_confirmed']
|
|
if not cleanup_ok:
|
|
result = 'cleanup-failed'
|
|
cleanup_error = '; '.join(cleanup_report['errors'])
|
|
runner_error = (runner_error + '; ' if runner_error else '') + cleanup_error
|
|
external_unchanged = True
|
|
if external_inputs:
|
|
try: external_unchanged = verify_profile(external_inputs, a.external_addons, external_rules, external_cache, addons)
|
|
except (OSError, ValueError): external_unchanged = False
|
|
if not external_unchanged: result = 'external-inputs-changed'
|
|
stopped_evidence = seal_run(ROOT, session, captured_sources) if cleanup_ok and session else {}
|
|
if stopped_evidence and not stopped_evidence['source_unchanged_after_run']: result = 'source-changed'
|
|
summary = {**stopped_evidence, **bindings(captured_sources), 'external_addons_unchanged_after_run':external_unchanged, 'external_addon_count':len(external_inputs or {}), 'external_addon_inputs':'external-addons-inputs.json' if external_inputs else None, 'started_unix':started, 'result':result, 'result_before_cleanup':result_before_cleanup, 'cleanup_confirmed':cleanup_ok, 'cleanup_report':cleanup_report, 'runner_error':runner_error, 'session':str(session) if session else None, 'rounds_required':a.rounds, 'project':a.project, 'display':'headless Gamescope 1280x720/30Hz', 'dedicated_server':False, 'audio':'silent sink and per-test-stream mute'}
|
|
if session:
|
|
(session/'runner.json').write_text(json.dumps(summary, indent=2)+'\n')
|
|
shutil.copyfile(profile/'headless-launch.log', session/'headless-launch.log')
|
|
print(json.dumps(summary, indent=2), flush=True)
|
|
if result != 'smoke-completed': raise SystemExit(1)
|
|
# All native errors still fail the report even when the scripted sequence completed.
|
|
raise SystemExit(subprocess.call(['python3', str(ROOT/'tools/native_report.py'), str(session/'console.log'), '--require-completions', str(a.rounds)]))
|