Dotfiles_ArchAP/tests/dock/headless.py

447 lines
24 KiB
Python

#!/usr/bin/env python3
"""Private Sway only; production Dock/Menu, real Python bridge and input paths."""
import json
import os
import shutil
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 = Path(os.environ.get('LABFY_DOCK_TEST_ARTIFACTS',
str(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')
# Capture helper in private HOME, bound only to the private Wayland socket.
capture_tool=Path(lab.env['HOME'])/'.local/libexec/labfy-wlthumbs'
capture_tool.parent.mkdir(parents=True,exist_ok=True)
shutil.copy2(Path.home()/'.local/libexec/labfy-wlthumbs',capture_tool)
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,
separators:A.FavoritesService.separators,
dividerIds:Array.from(dock.contentItemRow.children).filter(t=>t.modelData && t.modelData.kind).map(t=>t.modelData.id),
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,
bodyX:dock.surfaceBody.x, bodyWidth:dock.surfaceBody.width,
sensorWidth:dock.edgeArea.width,
bodyMaskWidth:dock.bodyMaskRegion.width,
dismissMaskWidth:dock.dismissMaskRegion.width,
contextMaskWidth:dock.contextMaskRegion.width,
previewMaskWidth:dock.previewMaskRegion.width,
previewVisible:dock.previewVisible,
pendingPreviewId:dock.pendingPreviewId,
previewArmRunning:dock.previewArmRunning,
previewGeneration:dock.previewGeneration,
previewWindowIds:dock.previewEntry ? dock.previewEntry.windows.map(w=>w.id) : [],
previewCardCount:Array.from(dock.previewCardsRow.children).filter(c=>c.windowData).length,
previewImageCount:Object.keys(dock.previewImages).length,
hoveredTiles:Array.from(dock.contentItemRow.children)
.filter(t=>t.modelData && t.children[3] && t.children[3].containsMouse)
.map(t=>t.modelData.id),
dockPressed:dock.pressed,
dotCounts:Array.from(dock.contentItemRow.children)
.filter(t=>t.modelData && !t.modelData.kind)
.map(t=>({id:t.modelData.id,dots:t.dotCount})),
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,
contextAlpha:dock.contextCard.color.a,
dismissVisible:dock.contextDismissArea.visible});
}
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 edgeLeft(): void { input.mouseMove(dock.edgeArea, 2, 2); }
function edgeRight(): void { input.mouseMove(dock.edgeArea, dock.edgeArea.width-3, 2); }
function body(): void { input.mouseMove(dock.surfaceBody, 35, 70); }
function hoverPinned(id:string): void {
const tile=Array.from(dock.contentItemRow.children).find(t=>t.modelData && t.modelData.id===id);
if(!tile) throw new Error('No tile '+id);
input.mouseMove(tile.children[3],tile.width/2,65);
}
function hoverPreview(): void { input.mouseMove(dock.previewPopupItem,25,30); }
function clickPreview(index:int): void {
const cards=Array.from(dock.previewCardsRow.children).filter(c=>c.windowData);
if(index<0 || index>=cards.length) throw new Error('No preview card');
input.mouseClick(cards[index],cards[index].width/2,cards[index].height/2);
}
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 addSeparatorAt(position:int): void { A.FavoritesService.addSeparator(position); }
function moveSeparatorTo(id:string,position:int): void { A.FavoritesService.moveSeparator(id,position); }
function removeSeparator(id:string): void { A.FavoritesService.removeSeparator(id); }
function openSeparatorMenu(): void {
const tile=Array.from(dock.contentItemRow.children).find(t=>t.modelData && t.modelData.kind==="separator");
if (!tile) throw new Error("missing separator tile");
input.mouseClick(tile,tile.width/2,65,Qt.RightButton);
}
function removeSeparatorFromMenu(): void {
const action=input.findChild(dock.contextCard,"dock-remove-separator");
input.mouseClick(action,action.width/2,action.height/2);
}
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 hoverContext(): void { input.mouseMove(dock.contextCard,15,15); }
function clickAside(): void {
const area=dock.contextDismissArea;
input.mouseClick(area,5,Math.min(95,area.height-8));
}
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.surfaceBody.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
# Regression: hidden popup areas must NOT subtract holes from the
# four-pixel sensor (especially at the centered dock position).
assert initial['sensorWidth']==initial['screenWidth'], initial
assert initial['previewMaskWidth']==0 and not initial['previewVisible'],initial
assert all(initial[key]==0 for key in
('bodyMaskWidth', 'dismissMaskWidth', 'contextMaskWidth')), 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')
# Separator mutations must be durable and visible to the same live model.
call('edge'); wait(lambda s:s['revealed'])
call('addSeparatorAt',2)
state=wait(lambda s:len(s['separators'])==1 and len(s['dividerIds'])==1)
separator_id=state['separators'][0]['id']
assert state['separators'][0]['position']==2,state
assert state['dividerIds']==[separator_id],state
separator_file=Path(lab.env['XDG_STATE_HOME'])/'labfy-sway/dock-separators.json'
assert json.loads(separator_file.read_text())['separators'][0]['id']==separator_id
call('moveSeparatorTo',separator_id,3)
wait(lambda s:s['separators'][0]['position']==3)
call('openSeparatorMenu')
wait(lambda s:s['context'] and s['phase']=='held')
call('removeSeparatorFromMenu')
wait(lambda s:not s['separators'] and not s['dividerIds'] and not s['context'])
assert json.loads(separator_file.read_text())['separators']==[]
call('leave'); wait(lambda s:s['phase']=='hidden')
# Translucent menu; hover keeps it open, clicking beside closes at once;
# moving away allows a short grace without a visible close button.
call('edge'); wait(lambda s:s['revealed'])
call('rightClick')
opened=wait(lambda s:s['context'])
assert opened['dismissVisible'],opened
assert opened['dismissMaskWidth'] > 0 and opened['contextMaskWidth'] > 0, opened
assert abs(opened['contextAlpha'] - .85) < .01,opened
call('hoverContext'); time.sleep(.72)
assert call('state')['context'],call('state')
call('clickAside')
after_close=wait(lambda s:not s['context'])
assert after_close['dismissMaskWidth']==0 and after_close['contextMaskWidth']==0, after_close
call('edge'); wait(lambda s:s['revealed'])
call('rightClick'); wait(lambda s:s['context'])
call('leave'); wait(lambda s:not s['context'],2)
hidden_after_popup=wait(lambda s:s['phase']=='hidden')
assert all(hidden_after_popup[key]==0 for key in
('bodyMaskWidth', 'dismissMaskWidth', 'contextMaskWidth')), hidden_after_popup
# A fresh center-edge hover must work after dismissing a popup.
call('edge')
center=wait(lambda s:s['revealed'])
assert center['sensorWidth']==center['screenWidth'], center
call('leave'); wait(lambda s:s['phase']=='hidden')
# Every horizontal position of the four-pixel strip must arm the same
# centered dock, including both physical screen corners.
assert initial['width']==initial['screenWidth'], initial
assert initial['sensorWidth']==initial['screenWidth'], initial
assert initial['bodyWidth']<initial['width'], initial
assert abs(initial['bodyX']*2 + initial['bodyWidth']-initial['width'])<1.1, initial
for corner in ('edgeLeft', 'edgeRight'):
call(corner)
revealed=wait(lambda s:s['revealed'])
assert revealed['sensorWidth']==revealed['screenWidth'], revealed
assert abs(revealed['bodyX']*2+revealed['bodyWidth']-revealed['width'])<1.1, revealed
call('leave'); wait(lambda s:s['phase']=='hidden')
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
# Three real test windows must show three dots and three selectable cards.
extra1=lab.kitty('kitty')
extra2=lab.kitty('kitty')
grouped=wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==3 for e in s['entries']))
dots=next(t for t in grouped['dotCounts'] if t['id']=='kitty.desktop')
assert dots['dots']==3,grouped
focus_before=focused()
call('edge'); wait(lambda s:s['revealed'])
call('hoverPinned','kitty.desktop')
peek=wait(lambda s:s['previewVisible'] and s['previewCardCount']==3,12)
assert peek['previewMaskWidth']>0 and len(peek['previewWindowIds'])==3,peek
assert focused()==focus_before,'Hover changed focus'
call('hoverPreview'); time.sleep(.8)
active_preview=call('state')
assert active_preview['previewVisible'],'Preview must stay open on hover'
captured=wait(lambda s:s['previewImageCount']>=1,5)
assert captured['previewImageCount'] <= 3,captured
print('PRIVATE_CAPTURED_PREVIEWS=',captured['previewImageCount'])
target=int(peek['previewWindowIds'][0])
call('clickPreview',0)
wait(lambda s:not s['previewVisible'])
for _ in range(20):
if focused()==target:break
time.sleep(.1)
assert focused()==target,(focused(),target)
call('leave'); wait(lambda s:s['phase']=='hidden')
cache=Path(lab.env['XDG_RUNTIME_DIR'])/'labfy-dock-window-thumbnails'
for _ in range(30):
if not cache.exists() or not list(cache.iterdir()):break
time.sleep(.15)
assert not cache.exists() or not list(cache.iterdir()),cache
# 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']==800 and resized['bodyWidth']<=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['bodyWidth']-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','full-width left/right edge activation','centered visual dock',
'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)