Files

302 lines
19 KiB
Python

#!/usr/bin/env python3
"""Literal SDL-saved file -> browser file input/paint/download -> SDL reopen.
Run after stage125-check.sh artstudio-native, against the same served, verified artifact.
The native producer is the existing authoring/Game View workflow. Browser
state and hashes are read-only observations; edits use the actual controls.
Each return project retains the producer's exact .meta, scene and AssetRefs.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import time
import traceback
import urllib.error
import urllib.parse
import urllib.request
ROOT = Path(__file__).resolve().parents[4]
NATIVE_TEST = 'TestArtSceneAuthoringWorkflowNative'
def digest(data):
return hashlib.sha256(data).hexdigest()
def read_state(page):
# aegoArt.hash(frameID, channel) is the shared Go Document.Render hash,
# including every frame even when another frame is currently selected.
return page.evaluate('''async () => {
const result=JSON.parse(aegoArt.request('{"action":"state"}'));
if(result.error)throw new Error(result.error);
const document=result.state.document, bytes=AegoStudioHost.capture().bytes;
return {document,sourceSHA256:await AegoStudioProtocol.sha256(bytes),
frameHashes:Object.fromEntries(document.frames.map(frame=>[frame.id,
Object.fromEntries(['albedo','normal','height','emission'].map(channel=>
[channel,aegoArt.hash(frame.id,channel)]))]))};
}''')
def topology(document):
return {key:value for key,value in document.items() if key != 'revision'}
def checked(command, log, env=None):
with log.open('x') as output:
result = subprocess.run(command, cwd=ROOT, env=env, stdout=output, stderr=subprocess.STDOUT)
log.with_suffix(log.suffix+'.exit').write_text(str(result.returncode)+'\n')
if result.returncode:
raise RuntimeError(f'Command exited {result.returncode}: {command}; log={log}')
def main():
if not __debug__:
raise RuntimeError('File interoperability assertions require normal Python (no -O)')
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--artifact',type=Path,required=True)
parser.add_argument('--app-url',required=True)
parser.add_argument('--native-evidence',type=Path,required=True)
parser.add_argument('--evidence',type=Path,required=True)
parser.add_argument('--chromium-executable',default='')
parser.add_argument('--firefox-executable',default='')
parser.add_argument('--allow-unborn',action='store_true')
parser.add_argument('--serve-artifact',action='store_true',help='Start an owned local artifact server; --app-url must use an explicit loopback address and unprivileged port')
args=parser.parse_args()
artifact=args.artifact.resolve(strict=True)
native=args.native_evidence.resolve(strict=True)
args.evidence.mkdir(parents=True,exist_ok=False)
out=args.evidence.resolve(strict=True)
raw_manifest=(artifact/'manifest.json').read_bytes()
manifest=json.loads(raw_manifest)
app=args.app_url.rstrip('/')+'/'
report={'schema':1,'status':'FAIL','buildID':manifest['buildID'],
'manifestSHA256':digest(raw_manifest),'sourceSHA256':manifest['sourceSHA256'],
'browsers':[],'limits':['Actual Linux SDL/GL33 and browser file input/download; OS file picker and other native platforms are separate gates.']}
server=None
server_log=None
server_tool=None
snapshot=[sys.executable,str(ROOT/'tools/ci/component_try_evidence.py'),'snapshot','--root',str(ROOT)]
if args.allow_unborn:
snapshot.append('--allow-unborn')
try:
checked(snapshot+['--output',str(out/'source-before.json')],out/'source-before.log')
before=json.loads((out/'source-before.json').read_text())
native_summary=json.loads((native/'summary.json').read_text())
assert native_summary['passed'] and native_summary['required'][NATIVE_TEST]=='PASS'
assert native_summary['sourceFingerprint']==before['worktreeSHA256'], 'Native producer was tested on another source tree'
report['sourceFingerprint']=before['worktreeSHA256']
report['nativeSummarySHA256']=digest((native/'summary.json').read_bytes())
release_command=['go','run','./tools/artstudio/release']
if args.serve_artifact:
address=urllib.parse.urlsplit(app)
assert address.scheme=='http' and address.hostname in ('127.0.0.1','::1') and address.port and 1024<=address.port<=65535, 'Owned server requires an explicit HTTP loopback URL and unprivileged port'
assert not address.username and not address.password and address.path=='/' and not address.query and not address.fragment, 'Owned server URL must identify only its loopback origin'
binary_root=ROOT/'.cache/bin'
binary_root.mkdir(parents=True,exist_ok=True)
server_tool=tempfile.TemporaryDirectory(prefix='art-file-interop.',dir=binary_root)
binary=Path(server_tool.name)/'artstudio-release'
build=['go','build','-o',str(binary),'./tools/artstudio/release']
(out/'app-server-build-command.json').write_text(json.dumps(build)+'\n')
checked(build,out/'app-server-build.log')
release_command=[str(binary)]
report['serverToolSHA256']=digest(binary.read_bytes())
checked(release_command+['check-source','-root',str(ROOT),'-dir',str(artifact)],out/'artifact-source.log')
if args.serve_artifact:
bind=f'[{address.hostname}]:{address.port}' if address.hostname=='::1' else f'{address.hostname}:{address.port}'
command=release_command+['serve','-dir',str(artifact),'-addr',bind,'-docs-addr=']
(out/'app-server-command.json').write_text(json.dumps(command)+'\n')
server_log=(out/'app-server.log').open('x')
server=subprocess.Popen(command,cwd=ROOT,stdout=server_log,stderr=subprocess.STDOUT)
report['ownedServer']={'pid':server.pid,'origin':app.rstrip('/'),'stopRequested':False}
deadline=time.monotonic()+30
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}))
while True:
if server.poll() is not None:
raise RuntimeError(f'Owned artifact server exited before readiness: {server.returncode}')
try:
with opener.open(app+'manifest.json',timeout=2) as response:
served=response.read()
if served!=raw_manifest:
raise RuntimeError('Loopback port serves a different manifest')
break
except (ConnectionError,TimeoutError,urllib.error.URLError) as error:
if time.monotonic()>=deadline:
raise TimeoutError('Owned artifact server was not ready within 30s') from error
time.sleep(.05)
report['ownedServer']['readyManifestSHA256']=digest(served)
with urllib.request.urlopen(app+'manifest.json',timeout=30) as response:
assert response.read()==raw_manifest, 'HTTP server is serving another artifact'
producer=native/'interoperability'
producer_raw=(producer/'native-source.json').read_bytes()
witness=json.loads(producer_raw)
assert witness['schema']==1 and len(witness['state']['document']['frames'])==8
assert witness['sourceAsset'] and witness['sceneRefs']['SpriteRenderer.sprite']==witness['sourceAsset']
assert witness['sceneRefs']['AnimationPlayer.clip'] and len(witness['clipFrames'])==8
for relative,expected in witness['projectFiles'].items():
path=Path(relative)
assert not path.is_absolute() and '..' not in path.parts and (producer/'project'/path).is_file()
assert digest((producer/'project'/path).read_bytes())==expected, relative
source=producer/'project'/witness['sourcePath']
assert digest(source.read_bytes())==witness['state']['sourceSHA256']
report['nativeSourceSHA256']=witness['state']['sourceSHA256']
report['nativeWitnessSHA256']=digest(producer_raw)
from playwright.sync_api import sync_playwright
with sync_playwright() as playwright:
for name in ('chromium','firefox'):
directory=out/name
directory.mkdir()
(directory/'native-source.json').write_bytes(producer_raw)
browser_report={'schema':1,'status':'FAIL','browser':name,
'inputSHA256':witness['state']['sourceSHA256'],
'buildID':manifest['buildID'],'manifestSHA256':report['manifestSHA256'],
'sourceFingerprint':report['sourceFingerprint'],'consoleErrors':[]}
report['browsers'].append(browser_report)
options={'headless':True}
executable=getattr(args,name+'_executable')
if executable:
options['executable_path']=executable
if name=='chromium':
options['args']=['--mute-audio','--no-sandbox','--disable-dev-shm-usage']
else:
options['firefox_user_prefs']={'media.volume_scale':'0.0'}
if os.environ.get('MOZ_DISABLE_CONTENT_SANDBOX')=='1':
options['firefox_user_prefs']['security.sandbox.content.level']=0
browser=getattr(playwright,name).launch(**options)
browser_report['browserVersion']=browser.version
try:
context=browser.new_context(viewport={'width':1440,'height':1000},accept_downloads=True)
page=context.new_page()
page.on('pageerror',lambda error:browser_report['consoleErrors'].append(str(error)))
page.on('console',lambda message:browser_report['consoleErrors'].append(message.text) if message.type=='error' else None)
responses=[]
page.on('response',lambda response:responses.append(response) if urllib.parse.urlparse(response.url).path.endswith('/'+manifest['wasm']['path']) else None)
page.goto(app+'editor/?lang=en',wait_until='load')
page.wait_for_function('() => window.artStudioSurfaceReady === true',timeout=60000)
assert len(responses)==1 and responses[0].status==200 and digest(responses[0].body())==manifest['wasm']['sha256'], 'Executed WASM differs from artifact'
browser_report['wasmSHA256']=manifest['wasm']['sha256']
handle=page.evaluate('() => AegoStudioHost.summary().handle')
page.locator('#file-input').set_input_files(str(source))
page.wait_for_function('(handle) => AegoStudioHost.summary().handle !== handle',arg=handle,timeout=30000)
loaded=read_state(page)
assert loaded==witness['state'], 'Native saved file changed bytes, topology, palette, pivots, durations or channel pixels on browser import'
page.screenshot(path=str(directory/'browser-opened-native.png'))
shared={}
for cel in loaded['document']['cels']:
if cel['channel']=='albedo':
shared.setdefault(cel['image'],[]).append(cel)
linked=next((cels for cels in shared.values() if len(cels)>1),None)
assert linked and len(linked)==2, 'Native producer did not retain exactly one linked pair'
target=linked[-1]
page.locator(f'#timeline-table tbody button[data-cel="{target["id"]}"]').click()
page.locator('#hex-color').fill('#26c7a3' if name=='chromium' else '#db4e83')
page.locator('#hex-color').press('Tab')
page.locator('[data-tool=pencil]').click()
page.locator('#fit').click()
box=page.locator('#canvas').bounding_box()
canvas=loaded['document']['canvas']
point=lambda x,y:(box['x']+(x+.5)*box['width']/canvas['width'],box['y']+(y+.5)*box['height']/canvas['height'])
page.mouse.move(*point(4,4));page.mouse.down()
page.mouse.move(*point(6,4),steps=2);page.mouse.up()
page.wait_for_function('() => !JSON.parse(aegoArt.request(\'{"action":"state"}\')).state.gesture')
painted=read_state(page)
assert topology(painted['document'])==topology(loaded['document']), 'Painting changed document structure'
changed={frame for frame in painted['frameHashes'] if painted['frameHashes'][frame]!=loaded['frameHashes'][frame]}
assert changed=={cel['frame'] for cel in linked}, 'Stroke did not update exactly the linked cels'
for frame in painted['frameHashes']:
for channel in ('normal','height','emission'):
assert painted['frameHashes'][frame][channel]==loaded['frameHashes'][frame][channel]
assert page.evaluate('() => JSON.parse(aegoArt.request(\'{"action":"state"}\')).state.undo')==1
page.locator('#undo').click()
assert read_state(page)['frameHashes']==loaded['frameHashes'], 'Web Undo lost native pixels'
page.locator('#redo').click()
returned=read_state(page)
assert returned['frameHashes']==painted['frameHashes'] and topology(returned['document'])==topology(loaded['document'])
with page.expect_download() as download:
page.locator('#studio-download-source').click()
downloaded=directory/'browser-return.aeart'
download.value.save_as(downloaded)
assert digest(downloaded.read_bytes())==returned['sourceSHA256'], 'Browser download is not the exact edited source'
page.screenshot(path=str(directory/'browser-edited-native.png'))
assert not browser_report['consoleErrors'], browser_report['consoleErrors']
browser_report.update({'status':'PASS','state':returned,'downloadSHA256':digest(downloaded.read_bytes()),
'changedFrames':sorted(changed),'oneGestureUndoRedo':True})
finally:
browser.close()
(directory/'browser.json').write_text(json.dumps(browser_report,indent=2)+'\n')
# Only the normal local-file return is installed in a disposable
# copy. Original .meta and scene bytes are copied verbatim.
shutil.copytree(producer/'project',directory/'project')
(directory/'project'/witness['sourcePath']).write_bytes((directory/'browser-return.aeart').read_bytes())
environment=os.environ.copy()
environment['AEGO_ART_INTEROP_RETURN']=str(directory)
environment.setdefault('SDL_VIDEODRIVER','offscreen')
environment.setdefault('SDL_AUDIO_DRIVER','dummy')
command=['go','test','-p','1','-json','-count=1','-tags','aego_gltest','-run','^'+NATIVE_TEST+'$','./app/edit']
(directory/'native-command.json').write_text(json.dumps(command)+'\n')
checked(command,directory/'native-events.jsonl',environment)
events=[]
for line in (directory/'native-events.jsonl').read_text().splitlines():
try:
events.append(json.loads(line))
except json.JSONDecodeError:
pass
assert any(event.get('Test')==NATIVE_TEST and event.get('Action')=='pass' for event in events)
assert not any(event.get('Action') in ('skip','fail') for event in events)
returned_native=json.loads((directory/'native-return.json').read_text())
assert returned_native['status']=='PASS' and returned_native['commonUndo']
assert returned_native['witness']['state']==browser_report['state']
browser_report['nativeReturn']='PASS'
browser_report['nativeReturnSHA256']=digest((directory/'native-return.json').read_bytes())
print(f'PASS {name}: SDL source -> real file input/linked paint/Undo/Redo/download -> SDL Assets reopen/common Undo; source/frame AssetRefs preserved',flush=True)
checked(snapshot+['--output',str(out/'source-after.json')],out/'source-after.log')
after=json.loads((out/'source-after.json').read_text())
assert before['worktreeSHA256']==after['worktreeSHA256'] and before['files']==after['files'], 'Source changed during interoperability gate'
report['status']='PASS'
except Exception as error:
report['error']=f'{type(error).__name__}: {error}'
report['traceback']=traceback.format_exc()
print(report['error'],file=sys.stderr)
finally:
if server is not None:
was_running=server.poll() is None
report['ownedServer']['stopRequested']=was_running
try:
if was_running:
server.terminate()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
# Stop only this Popen child; never use broad process
# names or unrelated PIDs from another namespace.
report['ownedServer']['forcedStop']=True
server.kill()
server.wait(timeout=10)
except (OSError,subprocess.TimeoutExpired) as error:
report['status']='FAIL'
report['ownedServer']['cleanupError']=f'{type(error).__name__}: {error}'
report['ownedServer']['exitCode']=server.returncode
report['ownedServer']['closed']=server.poll() is not None
(out/'app-server.exit').write_text(('UNVERIFIED' if server.returncode is None else str(server.returncode))+'\n')
if not was_running and report['status']=='PASS':
report['status']='FAIL'
report['error']='Owned server exited unexpectedly before cleanup'
if server_log is not None:
server_log.close()
if server_tool is not None and (server is None or server.poll() is not None):
server_tool.cleanup()
(out/'result.json').write_text(json.dumps(report,indent=2)+'\n')
return 0 if report['status']=='PASS' else 1
if __name__=='__main__':
raise SystemExit(main())