150 lines
9.3 KiB
Python
150 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Reproduce stock inventory UI behavior in a diagnostic-only vanilla-dependent addon.
|
|
Never loads successor addons or edits installed/original content. Native errors remain failed.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from native_report import analyze
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
FIXTURE = ROOT/'tests/native/inventory_control'
|
|
HELPERS = ('headless_workbench.py','headless_game.py','private_prefix_cleanup.py','native_provenance.py','source_manifest.py','native_report.py','archive_native.py','project_schema.py')
|
|
EXPECTED_ERROR = 'OverlayWidget Frame0 has invalid slot GridWidgetSlot for slot manipulation function used, expected: LayoutSlot'
|
|
|
|
|
|
def fixture_inventory(folder):
|
|
if folder.is_symlink() or not folder.is_dir():
|
|
raise ValueError('Control fixture must be a regular directory')
|
|
result = {}
|
|
for path in sorted(folder.rglob('*')):
|
|
if path.is_symlink():
|
|
raise ValueError('Control fixture must not contain symlinks')
|
|
if path.is_file():
|
|
if path.suffix == '.gproj' and path != folder/'addon.gproj':
|
|
raise ValueError('Control fixture must contain only its root addon project')
|
|
if path.suffix not in {'.c','.gproj','.et','.ent','.layer','.meta','.conf','.md'}:
|
|
raise ValueError('Unexpected control fixture input: '+str(path))
|
|
result[str(path.relative_to(folder))] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
elif not path.is_dir():
|
|
raise ValueError('Control fixture must contain only regular source files/directories')
|
|
if not result or 'addon.gproj' not in result:
|
|
raise ValueError('Missing diagnostic fixture')
|
|
project = (folder/'addon.gproj').read_text()
|
|
guids = re.findall(r'\bGUID\s+"([A-F0-9]{16})"', project)
|
|
dependencies = re.findall(r'\bDependencies\s*\{([^{}]*)\}', project)
|
|
if (len(guids) != 1 or len(re.findall(r'\bGUID\b', project)) != 1
|
|
or len(dependencies) != 1 or len(re.findall(r'\bDependencies\b', project)) != 1
|
|
or not re.fullmatch(r'\s*"58D0FB3206B6F859"\s*', dependencies[0])):
|
|
raise ValueError('Control must depend only on installed vanilla')
|
|
guid = guids[0]
|
|
if guid in {'5614BBCCBB55ED1C','58D0FB3206B6F859','2528466DE220D91C','5192138CB9D5B911','D31039C7C53E348A','FA804F411BDDF2C6','9AAEE9041D79809E'}:
|
|
raise ValueError('Control must use its own addon identity')
|
|
return result,guid
|
|
|
|
|
|
def harness_source(source):
|
|
replacements = {
|
|
'ERR_ValidateBaseWorldPlugin':'ERRIC_InventoryControlPlugin',
|
|
'Worlds/ERR_Everon.ent':'Worlds/ERRIC_Inventory.ent',
|
|
"'-errPlay', '1'":"'-errPlay', '1', '-erricInventoryControl', '1'",
|
|
}
|
|
for old,new in replacements.items():
|
|
if old not in source:
|
|
raise ValueError('Known hidden harness entry changed: '+old)
|
|
source = source.replace(old,new)
|
|
return source
|
|
|
|
|
|
def control_evidence(content,guid):
|
|
report = analyze(content, 'console.log')
|
|
blocks = []
|
|
for section in content.split('Loaded addons:')[1:]:
|
|
block = []
|
|
for line in section.splitlines()[1:]:
|
|
match = re.search(r"ENGINE\s*:\s*gproj: .*? guid: '([A-F0-9]{16})'\s*$", line)
|
|
if not match: break
|
|
block.append(match[1])
|
|
blocks.append(block)
|
|
guids = blocks[0] if blocks else []
|
|
isolation = bool(blocks) and all(len(block)==3 and set(block)=={'5614BBCCBB55ED1C','58D0FB3206B6F859',guid} for block in blocks)
|
|
errors = report['engine_errors']
|
|
target_errors = [error for error in errors if re.search(r'\bGUI\s+\(E\)', error) and EXPECTED_ERROR in error]
|
|
other_errors = [error for error in errors if error not in target_errors]
|
|
target_pair = (isolation and report['smoke_completions'] == 1
|
|
and not report['script_or_runtime_failures'] and not report['missing_end_screen']
|
|
and len(target_errors) == 2)
|
|
reproduced = (isolation and report['smoke_completions']==1 and not report['script_or_runtime_failures'] and not report['missing_end_screen']
|
|
and len(errors)==2 and all(EXPECTED_ERROR in error for error in errors))
|
|
return {'loaded_addon_guids':guids,'loaded_addon_blocks':blocks,'only_vanilla_and_control_loaded':isolation,
|
|
'target_gui_error_count':len(target_errors),'target_gui_pair_observed':target_pair,
|
|
'other_engine_errors':other_errors,
|
|
'stock_slot_error_reproduced':reproduced,'native_clean':not errors and not report['script_or_runtime_failures'],
|
|
'native_report':report,'successor_or_release_qualified':False}
|
|
|
|
|
|
def main():
|
|
p=argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument('--compat',type=Path,default=Path('/tmp/erri-headless-compat'))
|
|
p.add_argument('--profile',type=Path,default=Path('/tmp/erri-inventory-control-profile'))
|
|
p.add_argument('--timeout',type=int,default=150)
|
|
p.add_argument('--archive-name',required=True)
|
|
a=p.parse_args()
|
|
if a.compat != Path('/tmp/erri-headless-compat') or a.compat.resolve()!=a.compat:
|
|
p.error('Only the established disposable test prefix is supported')
|
|
if not 90<=a.timeout<=300 or not re.fullmatch(r'inventory-control-[a-z0-9-]+',a.archive_name):
|
|
p.error('Use a90..300 second bound and a unique inventory-control-* archive name')
|
|
dest=ROOT/'docs/evidence'/a.archive_name
|
|
if dest.exists() or dest.is_symlink():p.error('Archive must not preexist')
|
|
profile=a.profile
|
|
if profile != profile.resolve() or not profile.is_relative_to('/tmp') or profile==Path('/tmp') or profile.is_relative_to(a.compat) or a.compat.is_relative_to(profile):
|
|
p.error('Use a separate canonical disposable /tmp profile')
|
|
marker=profile/'.erri-inventory-control-profile.json';marker_value={'kind':'erri-vanilla-inventory-control','version':1}
|
|
if profile.exists() and (not marker.is_file() or marker.is_symlink() or json.loads(marker.read_text())!=marker_value):
|
|
p.error('Existing profile is not owned by this control')
|
|
original,guid=fixture_inventory(FIXTURE)
|
|
bundle=Path(tempfile.mkdtemp(prefix='erri-inventory-control-run-'))
|
|
(bundle/'addons').mkdir();shutil.copytree(FIXTURE,bundle/'addons/core');(bundle/'tools').mkdir()
|
|
for name in HELPERS:shutil.copyfile(ROOT/'tools'/name,bundle/'tools'/name)
|
|
runner=bundle/'tools/headless_workbench.py';runner.write_text(harness_source(runner.read_text()))
|
|
profile.mkdir(parents=True,exist_ok=True);marker.write_text(json.dumps(marker_value)+'\n')
|
|
tool_hashes={name:hashlib.sha256((bundle/'tools'/name).read_bytes()).hexdigest() for name in HELPERS}
|
|
command=[sys.executable,str(runner),'--compat',str(a.compat),'--profile',str(profile),'--project','core','--smoke','victory','--rounds','1','--timeout',str(a.timeout)]
|
|
before=set((profile/'logs').glob('logs_*')) if (profile/'logs').exists() else set()
|
|
with (bundle/'control-launch.log').open('w') as output:
|
|
result=subprocess.run(command,stdout=output,stderr=subprocess.STDOUT)
|
|
fresh=set((profile/'logs').glob('logs_*'))-before
|
|
if len(fresh)!=1:
|
|
print((bundle/'control-launch.log').read_text(),end='');raise RuntimeError('Control did not produce exactly one session; retained bundle '+str(bundle))
|
|
session=next(iter(fresh));runner_report=json.loads((session/'runner.json').read_text())
|
|
if runner_report.get('cleanup_confirmed') is not True or runner_report.get('source_unchanged_after_run') is not True:
|
|
raise RuntimeError('Control cleanup/source seal incomplete; retained bundle '+str(bundle))
|
|
subprocess.run([sys.executable,str(bundle/'tools/archive_native.py'),str(session),a.archive_name],check=False)
|
|
staged=bundle/'docs/evidence'/a.archive_name
|
|
if not staged.is_dir():raise RuntimeError('Native archive was not created; retained bundle '+str(bundle))
|
|
for capture in sorted([*session.glob('erric-*.png'), *session.glob('erric-*.bmp')]):
|
|
if capture.is_file() and not capture.is_symlink(): shutil.copyfile(capture,staged/capture.name)
|
|
after,_=fixture_inventory(FIXTURE)
|
|
if original!=after:raise RuntimeError('Authored control fixture changed during run')
|
|
review=control_evidence((session/'console.log').read_text(errors='replace'),guid)
|
|
review.update(scope='Scripted stock-character inventory control with vanilla plus one diagnostic addon only; no normal-input, successor gameplay or release qualification.',
|
|
native_runner_exit=result.returncode,session=str(session),bundle=str(bundle),command=command,
|
|
fixture_sha256=original,harness_sha256=tool_hashes,cleanup_confirmed=True,source_unchanged_after_run=True)
|
|
(staged/'control-review.json').write_text(json.dumps(review,indent=2)+'\n')
|
|
shutil.copyfile(bundle/'control-launch.log',staged/'control-launch.log.txt')
|
|
# Preserve the actual remapped harness which launched this isolated input.
|
|
(staged/'harness.py.txt').write_bytes(runner.read_bytes())
|
|
(staged/'control-extra-sha256.json').write_text(json.dumps({name:hashlib.sha256((staged/name).read_bytes()).hexdigest() for name in ('control-review.json','control-launch.log.txt','harness.py.txt')},indent=2)+'\n')
|
|
shutil.copytree(staged,dest)
|
|
print(json.dumps({'archive':str(dest),**review},indent=2))
|
|
return 0 if review['stock_slot_error_reproduced'] else 1
|
|
|
|
|
|
if __name__=='__main__':raise SystemExit(main())
|