#!/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= 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()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)