146 lines
8.7 KiB
Python
Executable File
146 lines
8.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Compare each replay prefix on native Go and the served Go/WASM artifact.
|
|
|
|
Requires ARTSTUDIO_ARTIFACT, ARTSTUDIO_APP_URL and ARTSTUDIO_EVIDENCE. The
|
|
runner checks source and served manifest, then compares all frame pixels and
|
|
document topology after every operation in both real browsers. It exercises
|
|
the replay/API boundary, not the native UI or an OS save handle.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import traceback
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
ROOT = Path(__file__).resolve().parents[4]
|
|
|
|
|
|
def main() -> int:
|
|
if not __debug__:
|
|
raise RuntimeError('Parity checks must not run with Python optimizations')
|
|
artifact = Path(os.environ['ARTSTUDIO_ARTIFACT']).resolve(strict=True)
|
|
app = os.environ['ARTSTUDIO_APP_URL'].rstrip('/') + '/'
|
|
evidence = Path(os.environ['ARTSTUDIO_EVIDENCE']) / 'parity-replay'
|
|
evidence.mkdir(parents=True, exist_ok=False)
|
|
raw_manifest = (artifact / 'manifest.json').read_bytes()
|
|
manifest = json.loads(raw_manifest)
|
|
fixture_path = ROOT / 'tools/artstudio/tests/parity.json'
|
|
fixture = json.loads(fixture_path.read_text())
|
|
if not fixture.get('operations'):
|
|
raise RuntimeError('Empty replay is not a parity test')
|
|
result = {'status':'FAIL','buildID':manifest['buildID'],
|
|
'manifestSHA256':hashlib.sha256(raw_manifest).hexdigest(),
|
|
'sourceSHA256':manifest['sourceSHA256'],
|
|
'fixtureSHA256':hashlib.sha256(fixture_path.read_bytes()).hexdigest(),
|
|
'browsers':[], 'limits':['Native replay uses the shared Go core, not native UI input or rendering.',
|
|
'OS save and physical touch/pen are separate gates.']}
|
|
try:
|
|
subprocess.run(['go','run','./tools/artstudio/release','check-source','-root',str(ROOT),'-dir',str(artifact)],cwd=ROOT,check=True)
|
|
with urllib.request.urlopen(app + 'manifest.json', timeout=30) as response:
|
|
if response.read() != raw_manifest:
|
|
raise RuntimeError('Served manifest differs from artifact')
|
|
native = evidence/'aego-artstudio-replay'
|
|
subprocess.run(['go','build','-o',str(native),'./tools/artstudio'],cwd=ROOT,check=True)
|
|
result['nativeHostSHA256'] = hashlib.sha256(native.read_bytes()).hexdigest()
|
|
result['goVersion'] = subprocess.check_output(['go','version'],cwd=ROOT,text=True).strip()
|
|
native_steps = []
|
|
for count in range(len(fixture['operations'])+1):
|
|
prefix = copy.deepcopy(fixture)
|
|
prefix['operations'] = prefix['operations'][:count]
|
|
prefix.pop('frame',None)
|
|
path = evidence/f'prefix-{count:02}.json'
|
|
path.write_text(json.dumps(prefix,ensure_ascii=False,indent=2)+'\n')
|
|
data = json.loads(subprocess.check_output([str(native),'-replay',str(path)],cwd=ROOT,text=True))
|
|
state = data['state']
|
|
# The native replay returns every frame and all four channels in
|
|
# this one generation; never compare only the current albedo.
|
|
frames = data['frameSHA256']
|
|
native_steps.append({'state':state,'frames':frames})
|
|
result['nativeSteps'] = native_steps
|
|
from playwright.sync_api import sync_playwright
|
|
with sync_playwright() as playwright:
|
|
for name in ('chromium','firefox'):
|
|
browser_options = {'headless':True}
|
|
executable = os.environ.get('ARTSTUDIO_CHROMIUM_EXECUTABLE','') if name == 'chromium' else os.environ.get('ARTSTUDIO_FIREFOX_EXECUTABLE','')
|
|
if executable:
|
|
browser_options['executable_path'] = executable
|
|
if name == 'chromium':
|
|
browser_options['args'] = ['--mute-audio','--no-sandbox','--disable-dev-shm-usage']
|
|
else:
|
|
browser_options['firefox_user_prefs'] = {'media.volume_scale': '0.0'}
|
|
if os.environ.get('MOZ_DISABLE_CONTENT_SANDBOX') == '1':
|
|
browser_options['firefox_user_prefs']['security.sandbox.content.level'] = 0
|
|
browser = getattr(playwright,name).launch(**browser_options)
|
|
browser_result = {'name':name,'version':browser.version,'steps':[]}
|
|
result['browsers'].append(browser_result)
|
|
try:
|
|
page = browser.new_page(viewport={'width':1440,'height':1000})
|
|
errors = []
|
|
wasm_responses = []
|
|
page.on('pageerror',lambda error:errors.append(str(error)))
|
|
page.on('response',lambda response: wasm_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)
|
|
if len(wasm_responses)!=1 or wasm_responses[0].status!=200 or hashlib.sha256(wasm_responses[0].body()).hexdigest()!=manifest['wasm']['sha256']:
|
|
raise RuntimeError(f'{name}: executed WASM response does not match the pinned artifact')
|
|
browser_result['wasmSHA256'] = manifest['wasm']['sha256']
|
|
|
|
def request(payload):
|
|
value = page.evaluate('(request) => JSON.parse(aegoArt.request(JSON.stringify(request)))',payload)
|
|
if value.get('error'):
|
|
raise RuntimeError(f'{name}: operation failed: {value["error"]}')
|
|
return value
|
|
|
|
request({'action':'new','width':fixture['width'],'height':fixture['height'],'mode':fixture['mode'],'confirm':True})
|
|
for count, expected in enumerate(native_steps):
|
|
if count:
|
|
request({'action':'apply','operation':fixture['operations'][count-1]})
|
|
actual = request({'action':'state'})['state']
|
|
expected_state = copy.deepcopy(expected['state'])
|
|
actual_state = copy.deepcopy(actual)
|
|
# Document IDs are intentionally random; entity IDs and
|
|
# link topology are deterministic within each replay.
|
|
expected_state['document'].pop('id',None)
|
|
actual_state['document'].pop('id',None)
|
|
# Native CLI starts from a clean in-memory document;
|
|
# Web New explicitly marks its unsaved tab dirty. Both
|
|
# must be dirty after the first real replay operation.
|
|
native_dirty = expected_state.pop('dirty')
|
|
web_dirty = actual_state.pop('dirty')
|
|
if web_dirty is not True or native_dirty != (count > 0):
|
|
raise RuntimeError(f'{name} prefix {count}: unexpected native/web dirty state: {native_dirty}/{web_dirty}')
|
|
if actual_state != expected_state:
|
|
raise RuntimeError(f'{name} prefix {count}: native/WASM state differs\nnative={json.dumps(expected_state,sort_keys=True)}\nwasm={json.dumps(actual_state,sort_keys=True)}')
|
|
hashes = page.evaluate('''() => {
|
|
const state = JSON.parse(aegoArt.request('{"action":"state"}')).state;
|
|
return Object.fromEntries(state.document.frames.map(frame => [frame.id,
|
|
Object.fromEntries(['albedo','normal','height','emission'].map(channel=>[channel,aegoArt.hash(frame.id,channel)]))]));
|
|
}''')
|
|
if hashes != expected['frames']:
|
|
raise RuntimeError(f'{name} prefix {count}: frame pixel mismatch: native={expected["frames"]} wasm={hashes}')
|
|
browser_result['steps'].append({'prefix':count,'frameHashes':hashes,'nativeDirty':native_dirty,'webDirty':web_dirty})
|
|
print(f'PASS {name} prefix={count} frames={len(hashes)} topology+four-channel-pixels',flush=True)
|
|
if errors:
|
|
raise RuntimeError(f'{name} page errors: {errors}')
|
|
finally:
|
|
browser.close()
|
|
result['status']='PASS'
|
|
except Exception as error:
|
|
result['error']=f'{type(error).__name__}: {error}'
|
|
result['traceback']=traceback.format_exc()
|
|
print(result['error'],file=sys.stderr)
|
|
finally:
|
|
(evidence/'result.json').write_text(json.dumps(result,indent=2,ensure_ascii=False)+'\n')
|
|
return 0 if result['status']=='PASS' else 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|