#!/usr/bin/env python3 """Exercise the display API in a private compositor, never the active desktop.""" import json, os, socket, struct, subprocess, tempfile, time from pathlib import Path stage=Path(os.environ.get('WCM_TEST_STAGE','/var/tmp/wayfire-scope-toggle')) runtime=Path(tempfile.mkdtemp(prefix='scope-toggle-test-')) env=os.environ.copy() for key in ['WAYLAND_DISPLAY','DISPLAY','WAYFIRE_SOCKET','_WAYFIRE_SOCKET','WAYFIRE_CONFIG_FILE','WAYFIRE_DEFAULT_CONFIG_BACKEND']: env.pop(key,None) env.update(GIO_USE_VFS='local',GTK_USE_PORTAL='0',XDG_RUNTIME_DIR=str(runtime),WLR_BACKENDS='headless',WLR_HEADLESS_OUTPUTS='4',WLR_RENDERER='gles2', WLR_RENDER_DRM_DEVICE='/dev/dri/renderD128',WAYFIRE_PLUGIN_PATH=str(stage/'plugins'),WAYFIRE_PLUGIN_XML_PATH=str(stage/'metadata')) config=runtime/'wayfire.ini' config.write_text('''# Preserve this comment and unrelated settings. [core] plugins = ipc stipc ipc-rules output-control grid place move resize wm-actions simple-tile cube vswitch xwayland = false stitch_enabled = true stitch_outputs = HEADLESS-1 HEADLESS-2 HEADLESS-3 default_output = VIRTUAL-1 window_scope = physical window_scope_rules = [{"app_id":"tile-wide*","scope":"virtual"},{"app_id":"scope-pinned","scope":"HEADLESS-3"}] [simple-tile] key_toggle = none tile_by_default = none float_on_top = false inner_gap_size = 10 outer_horiz_gap_size = 5 outer_vert_gap_size = 5 [wm-actions] toggle_always_on_top = KEY_T [cube] rotate_left = KEY_LEFT rotate_right = KEY_RIGHT [autostart] autostart_wf_shell = false [grid] toggle_scope = KEY_ENTER type = none zone_gap = 15 [output:HEADLESS-1] mode = 640x720@60 position = 0,0 [output:HEADLESS-2] zone_gap = 25 mode = 640x720@60 position = 640,0 [output:HEADLESS-3] mode = 640x720@60 position = 1280,0 [output:HEADLESS-4] mode = 320x240@60 position = 1920,0 masked = true ''') if os.environ.get('SCOPE_TEST_UPGRADE'): config.write_text(config.read_text().replace('output-control grid place', 'output-control '+str(stage/'libgrid-old.so')+' place')) if os.environ.get('SCOPE_TEST_ANIMATION'): config.write_text(config.read_text().replace('type = none', 'type = crossfade\nduration = 300ms circle')) log=(runtime/'wayfire.log').open('w') compositor_env=env.copy() if os.environ.get('WCM_TEST_ASAN'): compositor_env.update(LD_PRELOAD=os.environ['WCM_TEST_ASAN'],ASAN_OPTIONS='detect_leaks=0:abort_on_error=1',UBSAN_OPTIONS='halt_on_error=1:print_stacktrace=1') process=subprocess.Popen([os.environ.get('WCM_TEST_COMPOSITOR','/usr/bin/wayfire'),'-c',str(config)],env=compositor_env,stdout=log,stderr=log) def wait(fn,timeout=10): deadline=time.monotonic()+timeout while time.monotonic()=x and g['y']>=y and g['x']+g['width']<=x+w and g['y']+g['height']<=y+h,(vid,g,(x,y,w,h)) def toggle_scope(vid, keyboard=False): was=info(vid)['window-scope'] if keyboard: focus(vid);keys('KEY_LEFTMETA','KEY_ENTER') else:ipc('grid/toggle_scope',{'view-id':vid}) wait(lambda:(info(vid)['window-scope']=='virtual') != (was=='virtual')) time.sleep(.4 if os.environ.get('SCOPE_TEST_ANIMATION') else .1) def same_geometry(vid, before): expect(vid,**before) try: env['WAYFIRE_SOCKET']=str(wait(lambda:next(runtime.glob('wayfire-*.socket'),None))) env['WAYLAND_DISPLAY']=next(p.name for p in runtime.glob('wayland-*') if not p.name.endswith('.lock')) print('Runtime:',runtime,flush=True) wait(lambda:names()==['VIRTUAL-1']) if os.environ.get('SCOPE_TEST_UPGRADE'): probe,probeid=spawn_at('reload-probe',1) ipc('grid/slot_r',{'view-id':probeid}) expect(probeid,x=320,y=15,width=305,height=690) saved=geometry(probeid) config.write_text(config.read_text().replace(str(stage/'libgrid-old.so'), 'grid')) wait(lambda:str(stage/'plugins/libgrid.so') in Path('/proc/'+str(process.pid)+'/maps').read_text()) time.sleep(.4) same_geometry(probeid,saved) print('PASS live replacement retains existing snapped geometry',flush=True) probe.terminate();probe.wait(timeout=5) a,aid=spawn_at('toggle-floating',3);wset=info(aid)['wset-index'] ipc('window-rules/configure-view',{'id':aid,'geometry':{'x':1350,'y':120,'width':300,'height':180}}) before=geometry(aid) toggle_scope(aid,True);same_geometry(aid,before) toggle_scope(aid,True);same_geometry(aid,before) assert info(aid)['physical-monitor']=='HEADLESS-3' assert info(aid)['window-scope']=='physical' assert not tiled(aid) print('PASS Super+Enter round trip preserves floating geometry and remembered monitor',flush=True) b,bid=spawn_at('tile-wide-new',2) ipc('window-rules/configure-view',{'id':bid,'geometry':{'x':730,'y':150,'width':300,'height':180}}) toggle_scope(bid) assert info(bid)['physical-monitor']=='HEADLESS-2' print('PASS virtual-first window falls back to its occupied physical monitor',flush=True) c,cid=spawn_at('toggle-maximized',1) command(c,'maximize 1');expect(cid,x=15,y=15,width=610,height=690) toggle_scope(cid);expect(cid,x=0,y=0,width=1920,height=720) toggle_scope(cid);expect(cid,x=15,y=15,width=610,height=690) assert info(cid)['tiled-edges']==15 print('PASS maximized window resizes to each scope',flush=True) command(c,'maximize 0') d,did=spawn_at('toggle-fullscreen',3) command(d,'fullscreen 1');expect(did,x=1280,y=0,width=640,height=720) toggle_scope(did);expect(did,x=0,y=0,width=1920,height=720) toggle_scope(did);expect(did,x=1280,y=0,width=640,height=720) assert info(did)['fullscreen'] command(d,'fullscreen 0') print('PASS fullscreen round trip returns to the original monitor',flush=True) ipc('grid/slot_r',{'view-id':cid});expect(cid,x=320,y=15,width=305,height=690) toggle_scope(cid);expect(cid,x=960,y=0,width=960,height=720) toggle_scope(cid);expect(cid,x=320,y=15,width=305,height=690) print('PASS Grid half snap is retained across scope changes',flush=True) e,eid=spawn_at('toggle-tiled',2);f,fid=spawn_at('toggle-peer',2) ipc('simple-tile/set-layout',{'wset-index':wset,'monitor':'HEADLESS-2','workspace':{'x':0,'y':0},'layout':{'vertical-split':[{'view-id':eid,'weight':1},{'view-id':fid,'weight':1}]}}) toggle_scope(eid,True) assert contains(layout('VIRTUAL-1'),eid) and not contains(layout('HEADLESS-2'),eid) assert contains(layout('HEADLESS-2'),fid) toggle_scope(eid,True) assert contains(layout('HEADLESS-2'),eid) and not contains(layout('VIRTUAL-1'),eid) assert not info(eid)['always-on-top'] print('PASS simpletile membership survives the shortcut and migrates between roots',flush=True) p,pid=spawn_at('scope-pinned',1) assert info(pid)['window-scope']=='HEADLESS-3' toggle_scope(pid);toggle_scope(pid) assert info(pid)['window-scope']=='HEADLESS-3' and info(pid)['physical-monitor']=='HEADLESS-3' print('PASS explicitly pinned physical policy is preserved',flush=True) command(a,'dialog') dialog=wait(lambda:next((v['id'] for v in ipc('window-rules/list-views') if v.get('app-id')=='scope-dialog'),None)) focus(dialog);keys('KEY_LEFTMETA','KEY_ENTER') wait(lambda:info(aid)['window-scope']=='virtual') assert info(dialog)['window-scope']=='virtual' focus(dialog);keys('KEY_LEFTMETA','KEY_ENTER') wait(lambda:info(aid)['window-scope']=='physical') assert info(dialog)['physical-monitor']=='HEADLESS-3' print('PASS focused dialog toggles its parent family',flush=True) # Last: remove the remembered member while its window uses virtual scope. toggle_scope(aid) t=apply({'core/stitch_outputs':'HEADLESS-1 HEADLESS-2'}) confirm(t) toggle_scope(aid) assert info(aid)['physical-monitor'] in ['HEADLESS-1','HEADLESS-2'] print('PASS missing remembered monitor falls back to a remaining physical zone',flush=True) finally: for p,f in clients: if p.poll() is None:p.terminate() p.wait(timeout=5);f.close() process.terminate();process.wait(timeout=10);log.close()