ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: success
GitHub / darwin arm64 native GitHub-hosted native CI: success
GitHub / darwin amd64 native GitHub-hosted native CI: failure
GitHub / linux amd64 native GitHub-hosted native CI: success
ci / checks (push) Failing after 20m20s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 17m58s
ci / release (push) Skipped
514 lines
35 KiB
Python
514 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
"""Real WebGL2 presentation acceptance on the same immutable Studio artifact.
|
|
No mock canvas, shader fixture substitution or updated golden images.
|
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
import traceback
|
|
|
|
|
|
def main():
|
|
if not __debug__:
|
|
raise RuntimeError("GPU acceptance must not run with Python optimization")
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument('--artifact', required=True, type=Path)
|
|
p.add_argument('--app-url', required=True)
|
|
p.add_argument('--browser', choices=['chromium', 'firefox'], required=True)
|
|
p.add_argument('--executable', default='')
|
|
p.add_argument('--headed', action='store_true')
|
|
p.add_argument('--evidence', required=True, type=Path)
|
|
args = p.parse_args()
|
|
args.evidence.mkdir(parents=True, exist_ok=False)
|
|
raw = (args.artifact/'manifest.json').read_bytes()
|
|
m = json.loads(raw)
|
|
report = dict(status='FAIL', browser=args.browser, manifestSHA256=hashlib.sha256(raw).hexdigest(),
|
|
sourceSHA256=m['sourceSHA256'], buildID=m['buildID'], checks=[], consoleErrors=[],
|
|
transport='real-http-webgl2', resourceCycles=[])
|
|
def checked(name):
|
|
report['checks'].append(name)
|
|
print('PASS', name, flush=True)
|
|
try:
|
|
from playwright.sync_api import sync_playwright
|
|
with sync_playwright() as pw:
|
|
options = dict(headless=not args.headed)
|
|
if args.executable:
|
|
options['executable_path'] = args.executable
|
|
if args.browser == 'chromium':
|
|
options['args'] = ['--mute-audio', '--no-sandbox', '--disable-dev-shm-usage', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader']
|
|
else:
|
|
options['firefox_user_prefs'] = {'webgl.force-enabled': True, 'webgl.disabled': False, '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(pw,args.browser).launch(**options)
|
|
report['browserVersion'] = browser.version
|
|
ctx = browser.new_context(viewport={'width':1440,'height':1050},device_scale_factor=2)
|
|
page = ctx.new_page()
|
|
page.on('pageerror',lambda e:report['consoleErrors'].append(str(e)))
|
|
page.on('dialog',lambda d:d.accept() if d.type in ('confirm','beforeunload') else d.dismiss())
|
|
responses = []
|
|
page.on('response',lambda r:responses.append(r) if r.url.endswith('.wasm') else None)
|
|
page.goto(args.app_url.rstrip('/')+'/editor/?new=sprite&lang=en',wait_until='load')
|
|
page.wait_for_function('() => window.artStudioSurfaceReady === true',timeout=60000)
|
|
assert m.get('previewWasm'), 'GPU runtime absent from manifest'
|
|
assert len(responses)==1, 'GPU runtime must not load before a GPU mode is requested'
|
|
page.locator('#new-dialog [name=width]').fill('32')
|
|
page.locator('#new-dialog [name=height]').fill('32')
|
|
old_handle = page.evaluate('() => AegoStudioHost.summary().handle')
|
|
page.locator('#new-dialog [value=create]').click()
|
|
page.wait_for_function('(old) => AegoStudioHost.summary().handle !== old',arg=old_handle)
|
|
def state():
|
|
return page.evaluate('() => JSON.parse(aegoArt.request(\'{"action":"state"}\'))')
|
|
def status():
|
|
return page.evaluate('() => JSON.parse(aegoArtPreview.status()).status')
|
|
def ready(mode):
|
|
page.wait_for_function('(mode)=>{if(!window.aegoArtPreview)return false;const s=JSON.parse(aegoArtPreview.status()).status;return s.state==="ready"&&s.mode===mode&&s.stats.MaterialPending===0}',arg=mode,timeout=60000)
|
|
def paint(x,y,x2=None,y2=None,tool='pencil'):
|
|
page.locator(f'[data-tool="{tool}"]').click()
|
|
b=page.locator('#canvas').bounding_box()
|
|
size=state()['state']['document']['canvas']
|
|
sx,sy=b['width']/size['width'],b['height']/size['height']
|
|
page.mouse.move(b['x']+(x+.5)*sx,b['y']+(y+.5)*sy)
|
|
page.mouse.down()
|
|
if x2 is not None:page.mouse.move(b['x']+(x2+.5)*sx,b['y']+(y2+.5)*sy,steps=12)
|
|
page.mouse.up()
|
|
def capture(name):
|
|
image=page.locator('#gpu-preview').screenshot(path=str(args.evidence/(name+'.png')))
|
|
return hashlib.sha256(image).hexdigest()
|
|
def capture_pixels(name):
|
|
digest=capture(name)
|
|
# Decode the actual browser screenshot for numerical pixel
|
|
# assertions. This canvas is a test-side PNG reader, never a
|
|
# substitute preview renderer or an authoring implementation.
|
|
page.evaluate('''async ({name,png}) => {
|
|
const bytes=Uint8Array.from(atob(png),c=>c.charCodeAt(0));
|
|
const image=await createImageBitmap(new Blob([bytes],{type:'image/png'}));
|
|
const reader=document.createElement('canvas');
|
|
reader.width=image.width;reader.height=image.height;
|
|
const context=reader.getContext('2d',{willReadFrequently:true});
|
|
context.drawImage(image,0,0);image.close();
|
|
window.artGPUScreens ??= {};
|
|
window.artGPUScreens[name]=context.getImageData(0,0,reader.width,reader.height);
|
|
}''',dict(name=name,png=base64.b64encode((args.evidence/(name+'.png')).read_bytes()).decode('ascii')))
|
|
return digest
|
|
def settle_gpu():
|
|
# Input publication and the real renderer have separate RAF
|
|
# callbacks. Wait for completed frames after the UI change.
|
|
page.evaluate('''() => new Promise(resolve=>{
|
|
let left=4;function frame(){if(--left===0)resolve();else requestAnimationFrame(frame);}
|
|
requestAnimationFrame(frame);
|
|
})''')
|
|
ready('lit')
|
|
def emission_strength(key):
|
|
page.locator('#gpu-emission').press(key)
|
|
settle_gpu()
|
|
def select(mode):
|
|
page.locator('#gpu-mode').select_option(mode)
|
|
ready(mode)
|
|
def sample_shadows():
|
|
# Renderer counters describe one frame. Observe before the
|
|
# input: checking only after a locator click can miss the sole
|
|
# dirty-mask frame and then see only valid cache hits.
|
|
page.evaluate('''() => {
|
|
window.artShadowSamples=[];
|
|
const token={};window.artShadowSampling=token;
|
|
function sample(){
|
|
if(window.artShadowSampling!==token)return;
|
|
const s=JSON.parse(aegoArtPreview.status()).status;
|
|
artShadowSamples.push({revision:s.revision,sequence:s.sequence,
|
|
draws:s.stats.ShadowDraws,dirty:s.stats.DirtyShadowMasks,hits:s.stats.ShadowCacheHits});
|
|
if(artShadowSamples.length<240)requestAnimationFrame(sample);
|
|
}
|
|
requestAnimationFrame(sample);
|
|
}''')
|
|
def shadow_result(revision):
|
|
page.wait_for_function('(revision) => artShadowSamples.some(s=>s.revision===revision&&s.draws>0&&s.dirty>0)',arg=revision,timeout=30000)
|
|
page.wait_for_function('() => JSON.parse(aegoArtPreview.status()).status.stats.ShadowCacheHits>0',timeout=30000)
|
|
return page.evaluate('() => {window.artShadowSampling=null;return artShadowSamples;}')
|
|
def configure_surface_pixels():
|
|
# Keep the existing scenario and its controls intact. Author a
|
|
# separate real document while GPU preview is paused, publish
|
|
# it once, then exercise presentation-only selections. Direct
|
|
# channel views use Unlit; this is not a height-lighting test.
|
|
def snapshot():
|
|
return page.evaluate('''async () => {
|
|
const s=aegoArt.snapshotBytes();if(s.error)throw Error(s.error);
|
|
const digest=await crypto.subtle.digest('SHA-256',s.bytes);
|
|
return {handle:s.handle,revision:s.revision,size:s.bytes.length,
|
|
sha256:[...new Uint8Array(digest)].map(x=>x.toString(16).padStart(2,'0')).join('')};
|
|
}''')
|
|
original_state=state()['state'];original_snapshot=snapshot()
|
|
original_controls=page.evaluate('''() => ({
|
|
tool:document.querySelector('[data-tool].active').dataset.tool,
|
|
color:document.getElementById('fg-color').value,
|
|
alpha:document.getElementById('fg-alpha').value,
|
|
size:document.getElementById('brush-size').value
|
|
})''')
|
|
original_sequence=status()['sequence']
|
|
original_resources=page.evaluate('() => JSON.parse(aegoArtPreview.resources())')
|
|
page.locator('#gpu-mode').select_option('pixels')
|
|
page.locator('#new').click()
|
|
page.locator('#new-dialog [name=width]').fill('32')
|
|
page.locator('#new-dialog [name=height]').fill('32')
|
|
page.locator('#new-dialog [value=create]').click()
|
|
page.wait_for_function('(old)=>AegoStudioHost.summary().handle!==old',arg=original_snapshot['handle'])
|
|
page.locator('#fg-alpha').fill('255')
|
|
page.locator('#fg-alpha').press('Tab')
|
|
channels=('albedo','normal','height','emission')
|
|
# Endpoints have the same expected displayed values under
|
|
# both sRGB color and linear NonColor texture preparation.
|
|
# Normal vectors are nonzero; the raw diagnostic view does
|
|
# not apply the normalization performed by the Lit shader.
|
|
colors=(('#ff0000','#00ffff','#ffffff','#ffff00'),
|
|
('#0000ff','#00ff00','#000000','#ff00ff'))
|
|
witness=dict(status='RUNNING',scope='Unlit frame and raw channel selection',
|
|
generationEvidence='TestConfigureChangesCachedFrameAndChannelRegion checks the unexposed loader generation',
|
|
pixelTolerance=2,fixture=[],observations=[])
|
|
report['configureSurfacePixels']=witness
|
|
frames=[]
|
|
for fi in range(2):
|
|
if fi:
|
|
previous=state()['state']['frame']
|
|
page.locator('#add-frame').click()
|
|
page.wait_for_function('(old)=>JSON.parse(aegoArt.request(\'{"action":"state"}\')).state.frame!==old',arg=previous)
|
|
frames.append(state()['state']['frame'])
|
|
for channel,hex_color in zip(channels,colors[fi]):
|
|
page.locator('#channel').select_option(channel)
|
|
page.locator('#fg-color').fill(hex_color)
|
|
paint(16,16,tool='fill')
|
|
expected=[int(hex_color[i:i+2],16) for i in (1,3,5)]+[255]
|
|
canonical=page.evaluate('''expected => {
|
|
const p=aegoArt.pixels('',false,false);let matching=0;
|
|
for(let i=0;i<p.length;i+=4)if(expected.every((v,k)=>p[i+k]===v))matching++;
|
|
return {pixels:p.length/4,matching,sha256:aegoArt.hash()};
|
|
}''',expected)
|
|
witness['fixture'].append(dict(frame=frames[-1],channel=channel,expected=expected,canonical=canonical))
|
|
assert canonical['pixels']==1024 and canonical['matching']==1024, ('Canonical channel fixture differs from its intended opaque RGBA',fi,channel,canonical,expected)
|
|
page.locator('#channel').select_option('albedo')
|
|
page.locator('#first-frame').click()
|
|
fixture_state=state()['state'];fixture_snapshot=snapshot()
|
|
select('sprite')
|
|
page.wait_for_function('''expected => {
|
|
const s=JSON.parse(aegoArtPreview.status()).status;
|
|
return s.document===expected.id&&s.revision===expected.revision&&s.frame===expected.frame;
|
|
}''',arg=dict(id=fixture_state['document']['id'],revision=fixture_snapshot['revision'],frame=frames[0]))
|
|
published=status()
|
|
witness['snapshot']=fixture_snapshot
|
|
witness['publicationSequence']=published['sequence']
|
|
witness['renderer']=published['renderer']
|
|
assert published['sequence']==original_sequence+1, 'Fixture selection published more than its final document snapshot'
|
|
for mode,ci in (('sprite',0),('normal',1),('height',2),('emission',3)):
|
|
select(mode)
|
|
first_pixels=None
|
|
for visit,fi in enumerate((0,1,0)):
|
|
page.locator('#first-frame' if fi==0 else '#next-frame').click()
|
|
page.wait_for_function('''expected => {
|
|
const s=JSON.parse(aegoArtPreview.status()).status;
|
|
return s.state==='ready'&&s.frame===expected.frame&&s.mode===expected.mode&&s.stats.MaterialPending===0;
|
|
}''',arg=dict(frame=frames[fi],mode=mode),timeout=30000)
|
|
# Allow completed render frames, not a retry until the
|
|
# pixels happen to match. A stale Ref cache must fail.
|
|
page.evaluate('''() => new Promise(resolve=>{
|
|
let left=4;function frame(){if(--left===0)resolve();else requestAnimationFrame(frame);}
|
|
requestAnimationFrame(frame);
|
|
})''')
|
|
current=status();name=f'configure-{mode}-{visit}-{fi}'
|
|
png_sha=capture_pixels(name)
|
|
expected=witness['fixture'][fi*4+ci]['expected']
|
|
pixels=page.evaluate('''async ({name,expected,tolerance}) => {
|
|
const image=artGPUScreens[name],p=image.data;
|
|
const x0=Math.floor(image.width*.4),x1=Math.ceil(image.width*.6);
|
|
const y0=Math.floor(image.height*.4),y1=Math.ceil(image.height*.6);
|
|
let matching=0,count=0,maxError=0;const sample=[];
|
|
for(let y=y0;y<y1;y++)for(let x=x0;x<x1;x++){
|
|
const i=(y*image.width+x)*4;
|
|
const error=Math.max(...expected.map((v,k)=>Math.abs(p[i+k]-v)));
|
|
count++;if(error<=tolerance)matching++;maxError=Math.max(maxError,error);
|
|
if(sample.length===0)sample.push(...p.slice(i,i+4));
|
|
}
|
|
const digest=await crypto.subtle.digest('SHA-256',p);
|
|
return {width:image.width,height:image.height,roi:[x0,y0,x1,y1],count,matching,maxError,sample,
|
|
rgbaSHA256:[...new Uint8Array(digest)].map(x=>x.toString(16).padStart(2,'0')).join('')};
|
|
}''',dict(name=name,expected=expected,tolerance=witness['pixelTolerance']))
|
|
witness['observations'].append(dict(mode=mode,frame=frames[fi],visit=visit,expected=expected,
|
|
png=name+'.png',pngSHA256=png_sha,pixels=pixels,status=current))
|
|
assert pixels['count']>=100 and pixels['matching']==pixels['count'], ('Configure retained another frame/channel in actual WebGL pixels',mode,fi,pixels,expected)
|
|
assert current['stats']['Instances']>0, 'No real sprite draw accompanied the channel capture'
|
|
assert current['sequence']==published['sequence'] and current['revision']==published['revision'], 'Presentation selection republished the document'
|
|
assert current['stats']['MaterialReloadGenerations']==published['stats']['MaterialReloadGenerations'], 'Presentation selection reloaded material resources'
|
|
now=state()['state'];now['frame']=fixture_state['frame']
|
|
assert now==fixture_state and snapshot()==fixture_snapshot, 'Frame/channel preview changed source bytes, revision, selection or History'
|
|
if visit==0:first_pixels=pixels['rgbaSHA256']
|
|
elif visit==2:assert pixels['rgbaSHA256']==first_pixels, 'Returning to frame A did not restore its exact GPU pixels'
|
|
page.locator('#gpu-mode').select_option('pixels')
|
|
page.locator('.document-tab.active .close-document').click()
|
|
page.wait_for_function('(handle)=>AegoStudioHost.summary().handle===handle',arg=original_snapshot['handle'])
|
|
page.locator('#fg-color').fill(original_controls['color'])
|
|
page.locator('#fg-alpha').fill(original_controls['alpha']);page.locator('#fg-alpha').press('Tab')
|
|
page.locator('#brush-size').fill(original_controls['size'])
|
|
page.locator(f'[data-tool="{original_controls["tool"]}"]').click()
|
|
select('sprite')
|
|
page.wait_for_function('(id)=>JSON.parse(aegoArtPreview.status()).status.document===id',arg=original_state['document']['id'])
|
|
page.evaluate('''() => new Promise(resolve=>{
|
|
let left=4;function frame(){if(--left===0)resolve();else requestAnimationFrame(frame);}
|
|
requestAnimationFrame(frame);
|
|
})''')
|
|
assert state()['state']==original_state and snapshot()==original_snapshot, 'Temporary GPU fixture changed the original document'
|
|
restored_resources=page.evaluate('() => JSON.parse(aegoArtPreview.resources())')
|
|
witness['resourcesBefore']=original_resources
|
|
witness['resourcesAfter']=restored_resources
|
|
for field in ('Textures','Buffers','Shaders','Pipelines','Framebuffers'):
|
|
assert restored_resources[field]<=original_resources[field], ('Temporary frame/channel fixture retained GPU resources',field,original_resources,restored_resources)
|
|
page.evaluate('() => {for(const name of Object.keys(artGPUScreens))if(name.startsWith("configure-"))delete artGPUScreens[name];}')
|
|
witness['status']='PASS'
|
|
checked('configure-frame-and-channel-pixels-without-republication')
|
|
page.locator('#filled').check()
|
|
paint(6,7,24,25,tool='rectangle')
|
|
select('sprite')
|
|
assert len(responses)==2
|
|
for key in ('wasm','previewWasm'):
|
|
res=next(r for r in responses if r.url.endswith('/'+m[key]['path']))
|
|
# Chromium evicts large streaming compilation bodies from the
|
|
# DevTools inspector. Hash the immutable response in the browser
|
|
# cache instead; this is the same content-hashed HTTP resource,
|
|
# with no route interception or substituted bytes.
|
|
downloaded=page.evaluate('''async url => {
|
|
const r=await fetch(url,{cache:'force-cache'});
|
|
if(!r.ok||!r.headers.get('Content-Type')?.startsWith('application/wasm'))throw Error('Invalid WASM response');
|
|
const hash=await crypto.subtle.digest('SHA-256',await r.arrayBuffer());
|
|
return [...new Uint8Array(hash)].map(x=>x.toString(16).padStart(2,'0')).join('');
|
|
}''',res.url)
|
|
assert res.status==200 and downloaded==m[key]['sha256']
|
|
checked('lazy-core-and-real-gpu-wasm-match-manifest')
|
|
configure_surface_pixels()
|
|
source=state()['state']
|
|
plain=capture('sprite')
|
|
select('lit')
|
|
page.wait_for_function('() => JSON.parse(aegoArtPreview.status()).status.stats.SelectedLights>0')
|
|
lit=capture('lit')
|
|
assert lit!=plain, 'Lit did not change rendered output'
|
|
page.locator('#gpu-light').press('End')
|
|
page.wait_for_timeout(350)
|
|
moved=capture('moved-light')
|
|
assert moved!=lit, 'Real light control has no observable result'
|
|
sample_shadows()
|
|
page.locator('#gpu-shadows').check()
|
|
report['shadowEnableFrames']=shadow_result(state()['state']['document']['revision'])
|
|
shadowed=capture('alpha-shadow')
|
|
assert shadowed!=moved
|
|
sample_shadows()
|
|
paint(6,7,8,9,tool='eraser')
|
|
report['shadowAlphaFrames']=shadow_result(state()['state']['document']['revision'])
|
|
assert capture('changed-alpha-shadow')!=shadowed, 'Changed alpha did not update visible pixels and shadow'
|
|
report['litStatus']=status()
|
|
checked('runtime-lit-moving-light-and-alpha-shadows')
|
|
# Missing emission must not fall back to uniform material glow.
|
|
emission_strength('Home')
|
|
no_emission=capture('no-emission-zero')
|
|
emission_strength('End')
|
|
assert capture('no-emission-maximum')==no_emission, 'Intensity emitted light without an emission channel'
|
|
checked('albedo-only-artwork-has-no-material-emission')
|
|
# Author actual surface data using the same UI and shared core.
|
|
page.locator('#channel').select_option('normal')
|
|
page.locator('#fg-color').fill('#8080ff')
|
|
page.locator('#brush-size').fill('32')
|
|
paint(16,16)
|
|
page.locator('#channel').select_option('emission')
|
|
page.locator('#fg-color').fill('#ffb030')
|
|
page.locator('#brush-size').fill('6')
|
|
paint(16,16)
|
|
page.locator('#channel').select_option('albedo')
|
|
page.wait_for_function('()=>{const s=JSON.parse(aegoArtPreview.status()).status;return s.hasNormal&&s.hasEmission&&s.revision===AegoStudioHost.summary().revision}')
|
|
readonly=state()['state']
|
|
page.locator('#gpu-bloom').uncheck()
|
|
emission_strength('Home');capture_pixels('emission-zero-no-bloom')
|
|
emission_strength('End');capture_pixels('emission-high-no-bloom')
|
|
page.locator('#gpu-bloom').check()
|
|
settle_gpu()
|
|
page.wait_for_function('() => JSON.parse(aegoArtPreview.status()).status.stats.PostFXPasses>=4')
|
|
capture_pixels('emission-high-bloom')
|
|
emission_strength('Home');capture_pixels('emission-zero-bloom')
|
|
report['emissionPixels']=page.evaluate('''() => {
|
|
const a=artGPUScreens['emission-zero-no-bloom'],b=artGPUScreens['emission-high-no-bloom'];
|
|
const c=artGPUScreens['emission-zero-bloom'],d=artGPUScreens['emission-high-bloom'];
|
|
if([b,c,d].some(v=>v.width!==a.width||v.height!==a.height))throw Error('GPU capture dimensions changed');
|
|
let changed=0,borderChanged=0,x0=a.width,y0=a.height,x1=-1,y1=-1;
|
|
for(let y=0;y<a.height;y++)for(let x=0;x<a.width;x++){
|
|
const i=(y*a.width+x)*4;
|
|
const delta=Math.max(...[0,1,2].map(k=>b.data[i+k]-a.data[i+k]));
|
|
if(delta>32){changed++;x0=Math.min(x0,x);y0=Math.min(y0,y);x1=Math.max(x1,x);y1=Math.max(y1,y);}
|
|
if((x<a.width*.12||x>a.width*.88||y<a.height*.12||y>a.height*.88)&&delta>2)borderChanged++;
|
|
}
|
|
let halo=0;
|
|
for(let y=0;y<a.height;y++)for(let x=0;x<a.width;x++){
|
|
if(x>=x0-2&&x<=x1+2&&y>=y0-2&&y<=y1+2)continue;
|
|
const i=(y*a.width+x)*4;
|
|
// Subtract the Bloom contribution of ordinary Lit pixels.
|
|
// Only the additional halo caused by authored emission counts.
|
|
const extra=Math.max(...[0,1,2].map(k=>(d.data[i+k]-b.data[i+k])-(c.data[i+k]-a.data[i+k])));
|
|
if(extra>8)halo++;
|
|
}
|
|
return {width:a.width,height:a.height,changed,borderChanged,halo,emissionBounds:[x0,y0,x1,y1]};
|
|
}''')
|
|
assert report['emissionPixels']['changed']>=20, ('Authored emission did not change actual Lit pixels',report['emissionPixels'])
|
|
assert report['emissionPixels']['borderChanged']==0, ('Artwork intensity changed the shadow receiver material',report['emissionPixels'])
|
|
assert report['emissionPixels']['halo']>=10, ('Bloom produced no additional halo from authored emission',report['emissionPixels'])
|
|
emission_strength('End')
|
|
assert status()['stats']['PostFXFailures']==0 and status()['stats']['LightingFailures']==0
|
|
assert state()['state']==readonly, 'Emission and Bloom controls mutated the authoring document'
|
|
checked('dirty-normal-emission-and-real-bloom')
|
|
# An albedo-only frame in the same document must not inherit the
|
|
# preceding frame's emission. Exercise the actual Play button too;
|
|
# shared Go FrameAt drives the presentation's frame configuration.
|
|
page.locator('#gpu-bloom').uncheck()
|
|
page.locator('#add-frame').click()
|
|
# A screenshot can take longer than a short animation frame on a
|
|
# loaded software renderer. Keep playback running, but use the
|
|
# supported maximum duration so the expected frame cannot normally
|
|
# wrap back while Playwright captures it.
|
|
page.locator('#duration').fill('600000');page.locator('#duration').press('Tab')
|
|
paint(6,7,24,25,tool='rectangle')
|
|
plain_frame=state()['state']['frame']
|
|
page.wait_for_function('(frame)=>{const s=JSON.parse(aegoArtPreview.status()).status;return s.frame===frame&&!s.hasEmission&&s.revision===AegoStudioHost.summary().revision}',arg=plain_frame)
|
|
emission_strength('Home');plain_frame_zero=capture('mixed-frame-no-emission-zero')
|
|
emission_strength('End');plain_frame_high=capture('mixed-frame-no-emission-maximum')
|
|
assert plain_frame_high==plain_frame_zero, 'Previous frame emission leaked into an albedo-only frame'
|
|
page.locator('#first-frame').click();settle_gpu()
|
|
assert status()['hasEmission']
|
|
emissive_frame_high=capture('mixed-frame-emission-maximum')
|
|
assert emissive_frame_high!=plain_frame_high, 'Playback fixture does not distinguish its emissive and plain frames'
|
|
before_play=state()['state']
|
|
programs=status()['stats']['MaterialPrograms']
|
|
generations=status()['stats']['MaterialReloadGenerations']
|
|
page.locator('#play').click()
|
|
page.wait_for_function('''(frame)=>{
|
|
const s=JSON.parse(aegoArtPreview.status()).status;
|
|
const active=document.querySelector('#timeline-table th button[data-frame].active');
|
|
return s.frame===frame&&!s.hasEmission&&active?.dataset.frame===frame;
|
|
}''',arg=plain_frame)
|
|
# Observe timeline changes, not just status at either endpoint: a
|
|
# slow capture might otherwise wrap away and back to this frame.
|
|
page.evaluate('''() => {
|
|
const changes=[];
|
|
const observer=new MutationObserver(records=>{
|
|
for(const r of records)if(r.target.matches('th button[data-frame]'))
|
|
changes.push({frame:r.target.dataset.frame,oldClass:r.oldValue});
|
|
});
|
|
observer.observe(document.getElementById('timeline-table'),
|
|
{subtree:true,attributes:true,attributeFilter:['class'],attributeOldValue:true});
|
|
window.gpuPlaybackCapture={changes,observer,started:performance.now()};
|
|
}''')
|
|
settle_gpu()
|
|
# Regression: exceed the old 1500ms frame duration while playback
|
|
# remains live. This models a delayed screenshot consumer without
|
|
# retrying or relaxing the pixel comparison.
|
|
page.wait_for_timeout(1700)
|
|
playback_before=status()
|
|
playing_before=page.locator('#play').evaluate('(button)=>button.classList.contains("active")')
|
|
playback_pixels=capture('playback-no-emission-frame')
|
|
playback_after=status()
|
|
playing_after=page.locator('#play').evaluate('(button)=>button.classList.contains("active")')
|
|
playback_observation=page.evaluate('''() => {
|
|
const {observer,changes,started}=window.gpuPlaybackCapture;
|
|
for(const r of observer.takeRecords())if(r.target.matches('th button[data-frame]'))
|
|
changes.push({frame:r.target.dataset.frame,oldClass:r.oldValue});
|
|
observer.disconnect();delete window.gpuPlaybackCapture;
|
|
return {changes,elapsedMS:performance.now()-started};
|
|
}''')
|
|
report['playbackCapture']=dict(before=playback_before,after=playback_after,
|
|
playingBefore=playing_before,playingAfter=playing_after,**playback_observation)
|
|
assert playback_observation['elapsedMS']>=1500, 'Playback capture did not exercise the delayed-consumer regression'
|
|
assert playing_before and playing_after, 'Playback stopped during the GPU capture'
|
|
assert not playback_observation['changes'], ('Playback changed frames during the GPU capture',playback_observation)
|
|
for observed in (playback_before,playback_after):
|
|
assert observed['frame']==plain_frame and not observed['hasEmission'], ('GPU capture observed a different playback frame',observed)
|
|
assert playback_pixels==plain_frame_high, 'Automatic playback retained the emissive frame material'
|
|
page.locator('#play').click();settle_gpu()
|
|
assert state()['state']==before_play, 'Playback changed the authoring frame or document'
|
|
assert status()['stats']['MaterialPrograms']==programs and status()['stats']['MaterialReloadGenerations']==generations, 'Playback recompiled or recreated the material'
|
|
page.locator('#next-frame').click()
|
|
assert state()['state']['frame']==plain_frame
|
|
page.locator('#delete-frame').click()
|
|
page.wait_for_function('()=>{const s=JSON.parse(aegoArtPreview.status()).status;return s.hasEmission&&s.frames===1&&s.revision===AegoStudioHost.summary().revision}')
|
|
checked('mixed-frame-emission-follows-real-playback-without-shader-reload')
|
|
readonly=state()['state']
|
|
select('flash');page.locator('#gpu-amount').press('Home');page.wait_for_timeout(250)
|
|
base=capture('flash-zero')
|
|
page.locator('#gpu-amount').press('End');page.wait_for_timeout(250)
|
|
assert capture('flash-one')!=base
|
|
select('dissolve');page.locator('#gpu-amount').press('Home');page.wait_for_timeout(250)
|
|
base=capture('dissolve-zero')
|
|
page.locator('#gpu-amount').press('End');page.wait_for_timeout(250)
|
|
assert capture('dissolve-one')!=base
|
|
assert state()['state']==readonly, 'Preview mutated document, selection or History'
|
|
checked('existing-flash-dissolve-shaders-and-preview-isolation')
|
|
for _ in range(6):page.locator('#duplicate-frame').click()
|
|
page.locator('#link-frame').click()
|
|
page.locator('#duration').fill('160');page.locator('#duration').press('Tab')
|
|
select('particles')
|
|
page.wait_for_function('()=>{const s=JSON.parse(aegoArtPreview.status()).status;return s.frames===8&&s.particles>=3}',timeout=30000)
|
|
report['particleStatus']=status();capture('particle-flipbook')
|
|
assert status()['stats']['Instances']>0
|
|
page.locator('#gpu-rate').press('Home')
|
|
page.wait_for_function('() => JSON.parse(aegoArtPreview.status()).status.particles===0',timeout=15000)
|
|
page.locator('#gpu-rate').press('ArrowRight')
|
|
page.wait_for_function('() => JSON.parse(aegoArtPreview.status()).status.particles>0',timeout=10000)
|
|
checked('real-cpu-particle-flipbook-rate-and-expiration')
|
|
select('lit')
|
|
doc_hash=page.evaluate('() => aegoArt.hash()')
|
|
assert page.evaluate('() => aegoArtPreview.contextLoss(true)'), 'WEBGL_lose_context unavailable'
|
|
page.wait_for_function('() => JSON.parse(aegoArtPreview.status()).status.state==="context-lost"')
|
|
page.wait_for_timeout(500)
|
|
assert 'lost' in page.locator('#gpu-status').inner_text().lower()
|
|
assert page.evaluate('() => aegoArtPreview.contextLoss(false)')
|
|
ready('lit')
|
|
assert page.evaluate('() => aegoArt.hash()')==doc_hash
|
|
checked('actual-webgl-context-loss-restore-cpu-pixels-preserved')
|
|
page.set_viewport_size({'width':1280,'height':720})
|
|
ready('lit');capture('resize-dpr2')
|
|
assert page.evaluate('() => document.getElementById("gpu-preview").width>=document.getElementById("gpu-preview").clientWidth*1.9')
|
|
page.set_viewport_size({'width':1440,'height':1050})
|
|
from effect_templates_ui import exercise_effect_templates
|
|
report['effectTemplates'] = exercise_effect_templates(page, m, args.evidence, checked)
|
|
select('sprite');page.locator('#gpu-bloom').uncheck()
|
|
page.wait_for_timeout(500)
|
|
baseline=page.evaluate('() => JSON.parse(aegoArtPreview.resources())')
|
|
for cycle in range(30):
|
|
page.locator('#new').click()
|
|
page.locator('#new-dialog [name=width]').fill('16')
|
|
page.locator('#new-dialog [name=height]').fill('16')
|
|
page.locator('#new-dialog [value=create]').click()
|
|
page.wait_for_function('()=>JSON.parse(aegoArtPreview.status()).status.document===JSON.parse(aegoArt.request(\'{"action":"state"}\')).state.document.id')
|
|
page.locator('.document-tab.active .close-document').click()
|
|
page.wait_for_function('()=>JSON.parse(aegoArtPreview.status()).status.document===JSON.parse(aegoArt.request(\'{"action":"state"}\')).state.document.id')
|
|
page.wait_for_timeout(70)
|
|
resources=page.evaluate('() => JSON.parse(aegoArtPreview.resources())')
|
|
report['resourceCycles'].append(resources)
|
|
for field in ('Textures','Buffers','Shaders','Pipelines','Framebuffers'):
|
|
assert resources[field]<=baseline[field]+2,(cycle,field,resources,baseline)
|
|
report['baselineResources']=baseline
|
|
checked('thirty-document-open-close-cycles-no-growing-gpu-objects')
|
|
assert page.evaluate('() => aegoArt.hash()')==doc_hash
|
|
assert not report['consoleErrors'],report['consoleErrors']
|
|
assert status()['stats']['MaterialCompileFailures']==0
|
|
page.screenshot(path=str(args.evidence/'final.png'))
|
|
ctx.close();browser.close()
|
|
report['status']='PASS'
|
|
return 0
|
|
except Exception as e:
|
|
report['error']=f'{type(e).__name__}: {e}'
|
|
report['traceback']=traceback.format_exc()
|
|
print(report['error'],file=sys.stderr)
|
|
return 1
|
|
finally:
|
|
(args.evidence/'gpu.json').write_text(json.dumps(report,indent=2,ensure_ascii=False)+'\n')
|
|
|
|
|
|
if __name__=='__main__':
|
|
raise SystemExit(main())
|