Dotfiles_ArchAP/tests/dock/headless.py

296 lines
16 KiB
Python

#!/usr/bin/env python3
"""Private Sway only; production Dock/Menu, real Python bridge and input paths."""
import json
import os
from pathlib import Path
import subprocess
import sys
import time
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / 'tests/session_v2/fixtures'))
from headless import Lab
from ipc_proxy import IpcProxy
SOURCE = REPO / 'quickshell/.config/quickshell/labfy-sway'
ARTIFACTS = REPO / 'docs/validation/quickshell-dock-tela-polish-v2'
NINE = ['firefox.desktop', 'kitty.desktop', 'yazi.desktop', 'nvim.desktop',
'org.darktable.darktable.desktop', 'orca-slicer.desktop', 'org.freecad.FreeCAD.desktop',
'org.kde.digikam.desktop', 'AppFlowy.desktop']
class DockLab(Lab):
def __init__(self):
super().__init__()
self.config=self.directory/'dock-sway.conf'
base=(REPO/'tests/session_v2/fixtures/headless.conf').read_text()
effects=(REPO/'sway/.config/sway/swayfx').read_text().split('layer_effects "labfy-sway-dock"')[1]
effects='layer_effects "labfy-sway-dock"'+effects
self.config.write_text(base+'\noutput HEADLESS-1 bg #11111b solid_color\nset $bar_blur enable\n'+effects)
def start(self, argv):
if argv[0]==os.environ.get('LABFY_TEST_SWAY','sway'):
argv=[*argv[:-1],str(self.config)]
return super().start(argv)
with DockLab() as lab:
proxy=IpcProxy(lab.directory/'dock-ipc.sock',lab.socket)
lab.env['SWAYSOCK']=str(lab.directory/'dock-ipc.sock')
path = Path(lab.env['XDG_STATE_HOME']) / 'labfy-sway/applications-favorites.json'
path.parent.mkdir(parents=True)
path.write_text(json.dumps(dict(version=1, ids=NINE)))
qml = lab.directory / 'fixture.qml'
qml.write_text('''//@ pragma UseQApplication
import QtQuick
import QtTest
import Quickshell
import Quickshell.Io
import "SOURCE/applications" as A
import "SOURCE/dock" as D
ShellRoot {
D.Dock { id: dock; modelData: Quickshell.screens[0] }
PanelWindow { id: bar; implicitWidth: 100; implicitHeight: 30; anchors.top: true; color: "transparent" }
LazyLoader { id: menu; active: false; A.ApplicationsMenu { barWindow: bar } }
// TEST_ONLY: one pointer move per animation frame avoids measuring IPC
// process startup instead of continuous production hover. These are Qt
// animation intervals, not hardware presentation timestamps.
FrameAnimation {
id: sweepFrames
property var intervals: []
property real origin: 0
property bool geometryChanged: false
property bool imageChanged: false
property var baseline: []
running: false
onTriggered: {
if (currentFrame > 1) intervals.push(frameTime * 1000);
const cycle = (elapsedTime * 0.7) % 2;
input.mouseMove(dock.surfaceBody, 51 + 420 * (cycle <= 1 ? cycle : 2 - cycle), 70);
const tiles=Array.from(dock.contentItemRow.children).filter(t => t.modelData);
for (let i=0; i<tiles.length; ++i) {
const t=tiles[i], b=baseline[i];
if (t.x!==b.x || t.width!==b.width || t.children[1].width!==46 || dock.width!==origin)
geometryChanged=true;
if (t.children[1].children[0].status!==b.status) imageChanged=true;
}
if (elapsedTime >= 4.5) running=false;
}
}
TestCase { id: input; name: "DockInput"; when: false }
IpcHandler {
target: "dockTest"
function state(): string {
return JSON.stringify({ready:A.FavoritesService.ready, ids:A.FavoritesService.ids,
error:A.FavoritesService.error, phase:dock.phase, revealed:dock.revealed,
entries:dock.entries, theme:A.FavoritesService.iconTheme,
menu:menu.active ? menu.item.favoriteIds : null,
searchFocused:menu.active && menu.item.searchFocused,
navFocused:menu.active && menu.item.navigationFocused,
gridFocused:menu.active && menu.item.gridFocused,
screenWidth:dock.screen.width,
bodyY:dock.surfaceBody.y, width:dock.width, pointerX:dock.pointerX,
tileWidth:dock.contentItemRow.children[0] ? dock.contentItemRow.children[0].width : 0,
tiles:Array.from(dock.contentItemRow.children).filter(t => t.modelData).map(t => ({
x:t.x, width:t.width, growth:t.growth, scale:t.children[1].scale,
visualLeft:t.children[1].mapToItem(dock.contentItemRow.parent.parent,0,0).x,
visualRight:t.children[1].mapToItem(dock.contentItemRow.parent.parent,46,0).x,
iconWidth:t.children[1].width, imageStatus:t.children[1].children[0].status})),
context:!!dock.contextEntry});
}
function startFrames(): void {
sweepFrames.intervals=[]; sweepFrames.origin=dock.width;
sweepFrames.baseline=Array.from(dock.contentItemRow.children).filter(t => t.modelData)
.map(t => ({x:t.x,width:t.width,status:t.children[1].children[0].status}));
sweepFrames.start();
}
function frames(): string {
return JSON.stringify({running:sweepFrames.running, intervals:sweepFrames.intervals,
geometryChanged:sweepFrames.geometryChanged,imageChanged:sweepFrames.imageChanged});
}
function edge(): void { input.mouseMove(dock.edgeArea, dock.edgeArea.width/2, 2); }
function body(): void { input.mouseMove(dock.surfaceBody, 35, 70); }
function sweep(x:real): void { input.mouseMove(dock.surfaceBody, x, 70); }
function scrollEnd(): void {
const v=dock.contentItemRow.parent.parent; v.contentX=Math.max(0,v.contentWidth-v.width);
}
function scrollStart(): void { dock.contentItemRow.parent.parent.contentX=0; }
function leave(): void { input.mouseMove(dock.contentItem, 1, 1); }
function menuToggle(id:string): void { if (!menu.active) menu.active=true; menu.item.toggleFavorite(id); }
function dockToggle(id:string): void { A.FavoritesService.toggle(id); }
function move(id:string, before:string): void { A.FavoritesService.move(id,before); }
function closeMenu(): void { menu.active=false; }
function tab(): void { input.keyClick(Qt.Key_Tab); }
function clickPin(): void {
const action=input.findChild(dock.contextCard,"dock-pin-action");
input.mouseClick(action,action.width/2,action.height/2);
}
function rightClick(): void {
input.mouseClick(dock.contentItemRow.children[0],27,65,Qt.RightButton);
}
function dragFirstAfterSecond(): void {
const first=dock.contentItemRow.children[0];
input.mousePress(first,27,65,Qt.LeftButton);
input.mouseMove(first,125,65,100);
input.mouseRelease(first,125,65,Qt.LeftButton);
}
function activateFirst(): void { input.mouseClick(dock.contentItemRow.children[0],27,65); }
function context(): void { dock.openContext(dock.entries[0],dock.width/2); }
function closeContext(): void { dock.closeContext(); }
function suppress(value:bool): void { dock.suppressed=value; }
function focus(id:string): void { A.FavoritesService.focusWindow(id); }
}
}
'''.replace('SOURCE', SOURCE.as_uri()))
lab.env['QT_QPA_PLATFORM'] = 'wayland'
process = lab.start(['quickshell', '-p', str(qml)])
def call(name, *args):
r = subprocess.run(['quickshell','ipc','-p',str(qml),'call','dockTest',name,*map(str,args)],
env=lab.env, text=True, capture_output=True, timeout=8)
if r.returncode:
logs = '\n'.join(p.read_text()[-9000:] for p in lab.directory.glob('child-*.log'))
raise AssertionError((name,r.stdout,r.stderr,logs))
return json.loads(r.stdout) if r.stdout.strip() else None
def wait(predicate, seconds=8):
end = time.monotonic()+seconds
while time.monotonic()<end:
state=call('state')
if predicate(state): return state
time.sleep(.08)
raise AssertionError(call('state'))
time.sleep(1.2)
initial=wait(lambda s:s['ready'])
assert initial['ids']==NINE and initial['phase']=='hidden', initial
ARTIFACTS.mkdir(parents=True,exist_ok=True)
def capture(name):
subprocess.run(['grim',str(ARTIFACTS / (name+'.png'))],env=lab.env,check=True)
capture('closed')
call('edge'); wait(lambda s:s['revealed']); time.sleep(.35)
capture('open')
resting=call('state')
call('body'); time.sleep(.3); zoom=call('state'); capture('zoom')
lab.command('output HEADLESS-1 bg #aab4c8 solid_color')
time.sleep(.2); capture('zoom-light')
lab.command('output HEADLESS-1 bg #11111b solid_color')
assert zoom['tileWidth']==54 and zoom['tiles'][0]['scale']>1, zoom
assert zoom['tiles'][0]['visualLeft']>=0,zoom
# Exercise production hover across slot boundaries. Layout and image
# readiness must remain stable while visual transforms change.
samples=[]
for x in range(45, 480, 6):
call('sweep',x)
state=call('state')
assert state['phase']=='shown',state
assert state['width']==resting['width'],state
assert [(t['x'],t['width'],t['iconWidth']) for t in state['tiles']]==[(t['x'],t['width'],t['iconWidth']) for t in resting['tiles']],state
assert sum(t['growth']>0 for t in state['tiles'])<=3,state
assert [t['imageStatus'] for t in state['tiles']]==[t['imageStatus'] for t in resting['tiles']],state
samples.append({'pointerX':state['pointerX'],'scales':[t['scale'] for t in state['tiles']]})
call('startFrames')
time.sleep(4.8)
frames=call('frames')
assert not frames['running'] and len(frames['intervals'])>30,frames
assert not frames['geometryChanged'] and not frames['imageChanged'],frames
intervals=sorted(frames['intervals'])
timing={'frames':len(intervals),'meanMs':sum(intervals)/len(intervals),
'p95Ms':intervals[int((len(intervals)-1)*.95)],'maxMs':max(intervals)}
call('leave'); wait(lambda s:s['phase']=='hidden')
call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
call('rightClick'); wait(lambda s:s['phase']=='held'); time.sleep(.7)
assert call('state')['context']
call('clickPin'); wait(lambda s:'firefox.desktop' not in s['ids'])
call('dockToggle','firefox.desktop'); wait(lambda s:'firefox.desktop' in s['ids'])
call('move','firefox.desktop','kitty.desktop'); wait(lambda s:s['ids']==NINE)
call('leave'); wait(lambda s:s['phase']=='hidden')
# Refused move leaves both interfaces at the same durable list.
call('move','kitty.desktop','removed.desktop')
wait(lambda s:bool(s['error']))
assert call('state')['ids']==NINE
call('menuToggle','fixture.desktop'); wait(lambda s:'fixture.desktop' in s['ids'] and s['menu']==s['ids'])
# Gio catalog monitoring replaces the placeholder without dropping its ID.
desktop=Path(lab.env['HOME'])/'.local/share/applications/fixture.desktop'
desktop.parent.mkdir(parents=True,exist_ok=True)
desktop.write_text('[Desktop Entry]\nType=Application\nName=Installed Fixture\nExec=/usr/bin/true\nIcon=firefox\n')
wait(lambda s:any(e['id']=='fixture.desktop' and not e.get('missing') for e in s['entries']))
desktop.unlink()
wait(lambda s:any(e['id']=='fixture.desktop' and e.get('missing') for e in s['entries']))
# Keep the virtual keyboard alive: the headless seat has no physical
# keyboard, so destroying wtype would remove keyboard capabilities and
# deactivate the QML focus window before we could sample its focus item.
keyboard=lab.start(['wtype','-s','300','-k','Tab','-s','1500','-k','Tab',
'-s','1500','-k','Tab','-s','1500'])
wait(lambda s:s['navFocused'])
wait(lambda s:s['gridFocused'])
wait(lambda s:s['searchFocused'])
keyboard.wait(timeout=8)
# An independent legacy CLI writer is observed by menu and dock live.
subprocess.run(['python','-B',str(SOURCE/'applications/backend.py'),'favorites','add','external.desktop'],
env=lab.env,check=True,capture_output=True)
wait(lambda s:'external.desktop' in s['ids'] and s['menu']==s['ids'])
call('dockToggle','external.desktop'); wait(lambda s:'external.desktop' not in s['ids'])
call('dockToggle','fixture.desktop'); wait(lambda s:'fixture.desktop' not in s['ids'] and s['menu']==s['ids'])
call('move','kitty.desktop','firefox.desktop'); reordered=wait(lambda s:s['ids'][0]=='kitty.desktop')
assert json.loads(path.read_text())['ids']==reordered['ids']
call('closeMenu')
call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
call('dragFirstAfterSecond')
dragged=wait(lambda s:s['ids'][0]=='firefox.desktop')
assert dragged['ids'][1]=='kitty.desktop',dragged
call('leave'); wait(lambda s:s['phase']=='hidden')
# No focus changes while the pointer reveals, zooms, hides, or opens menus.
win=lab.kitty('kitty')
time.sleep(.5)
focused=lambda: next(n['id'] for n in lab.windows() if n.get('focused'))
assert focused()==win
for action in ('edge','body','context','leave','closeContext'):
call(action); time.sleep(.25); assert focused()==win, action
call('suppress','true'); assert call('state')['phase']=='hidden'
call('suppress','false')
wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==1 for e in s['entries']))
proxy.disconnect()
time.sleep(2)
second=lab.kitty('kitty')
wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==2 for e in s['entries']))
lab.command(f'[con_id={win}] floating enable')
lab.command(f'[con_id={win}] move scratchpad')
call('focus',str(win)); time.sleep(.3); assert focused()==win
lab.command(f'[con_id={second}] kill')
wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==1 for e in s['entries']))
# Firefox runs with the private HOME/profile and no inherited session bus.
profile=lab.directory/'firefox-profile'; profile.mkdir()
firefox=lab.start(['firefox','--no-remote','--profile',str(profile),'--new-window','about:blank'])
firefox_state=wait(lambda s:any(e['id']=='firefox.desktop' and e['windows'] for e in s['entries']),20)
firewin=next(e for e in firefox_state['entries'] if e['id']=='firefox.desktop')['windows'][0]['id']
call('closeContext'); call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
call('activateFirst'); time.sleep(.3)
assert str(focused())==firewin
for action in ('edge','body','leave'):
call(action); time.sleep(.25); assert str(focused())==firewin
# Layer-shell surfaces cannot enter the Session V2 toplevel inventory.
assert not any('labfy-sway-dock' in str(n.get('app_id','')) for n in lab.windows())
lab.command('output HEADLESS-1 mode 800x600')
resized=wait(lambda s:s['screenWidth']==800)
assert resized['width']<=776,resized
# Magnified end icons keep their visual margin at both scroll limits.
lab.command('output HEADLESS-1 mode 400x600')
wait(lambda s:s['screenWidth']==400)
call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
call('scrollEnd'); call('sweep',325); time.sleep(.15)
end=call('state')
assert end['tiles'][-1]['scale']>1 and end['tiles'][-1]['visualRight']<=end['width']-24,end
call('scrollStart'); call('sweep',51); time.sleep(.15)
start=call('state')
assert start['tiles'][0]['scale']>1 and start['tiles'][0]['visualLeft']>=0,start
lab.command('output HEADLESS-1 mode 1280x720')
wait(lambda s:s['screenWidth']==1280)
# Existing empty state must stay empty, including a bridge restart.
for identifier in list(call('state')['ids']): call('dockToggle',identifier)
wait(lambda s:not s['ids'])
process.terminate(); process.wait(timeout=5)
process=lab.start(['quickshell','-p',str(qml)])
time.sleep(1)
assert wait(lambda s:s['ready'])['ids']==[]
(ARTIFACTS/'result.json').write_text(json.dumps(dict(status='PASS',initial=initial,hoverSamples=samples,animationTiming=timing,frameSweep=frames,
reordered=reordered['ids'],checks=['shared favorites','menu and dock mutations','durable reorder',
'empty restart','autohide','context hold','kitty multi-window','floating scratchpad explicit focus',
'window close','no hover focus','real pointer drag','fixed layout transform hover', 'stable SVG readiness', 'lateral sweep 73 samples','IPC reconnect','Firefox private profile','desktop install/remove monitoring','external writer','keyboard menu navigation','resolution change','SwayFX dock blur disabled']),indent=2)+'\n')
proxy.close()
print('Dock headless: PASS',lab.directory)