swayfx-mosaic/tooling/probes/step20c-rotated-partial-popup/run.py

538 lines
25 KiB
Python

"""EXPERIMENTAL applied-state/input headless campaign. See README."""
import argparse
from PIL import Image, ImageDraw
import sys
from budget import Budget
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
import tempfile
import time
parser = argparse.ArgumentParser()
parser.add_argument('lab', type=Path)
parser.add_argument('mode', choices=('control', 'private_standard', 'private_mosaic'))
parser.add_argument('--cycles', type=int, default=3)
parser.add_argument('--asi-mode', type=int, choices=(0, 1, 2), default=0)
parser.add_argument('--test-hold', action='store_true')
parser.add_argument('--test-timeout', action='store_true')
parser.add_argument('--moves', type=int, default=18)
parser.add_argument('--max-seconds', type=int, default=120)
parser.add_argument('--max-captures', type=int, default=16)
parser.add_argument('--max-bytes', type=int, default=30_000_000)
args = parser.parse_args()
if not (1 <= args.max_seconds <= 120 and 1 <= args.max_captures <= 16 and
1 <= args.max_bytes <= 30_000_000):
parser.error('static R90 run bounds exceeded')
if not 1 <= args.cycles <= 30 or not 1 <= args.moves <= 40:
parser.error('cycle/move bounds exceeded')
lab = args.lab.resolve()
if not (lab / 'nested-client').is_file():
parser.error('prepared LAB required')
budget = Budget(lab)
budget.reserve(args.mode)
run = lab / 'runs' / f'{args.mode}-static-r90-{time.time_ns()}'
run.mkdir(parents=True, mode=0o700)
(run / 'fixture-source.py').write_text(Path(__file__).read_text())
# WHY: five asymmetric markers identify whether PNG uses logical or buffer axes.
# CONTRACT: predeclared logical centers and distinct exact RGB codes.
marker_path = run / 'marker.png'
marker = Image.new('RGB', (800, 600), (30, 30, 46))
draw = ImageDraw.Draw(marker)
for name, box, color in (
('TL', (15, 15, 44, 44), (255, 32, 32)),
('TR', (755, 15, 784, 44), (32, 255, 32)),
('BL', (15, 555, 44, 584), (32, 32, 255)),
('BR', (755, 555, 784, 584), (255, 255, 32)),
('C', (385, 285, 414, 314), (192, 32, 255))):
draw.rectangle(box, fill=color)
marker.save(marker_path)
runtime = Path(tempfile.mkdtemp(prefix='mtd-', dir='/tmp'))
if len(os.fsencode(str(runtime / 'sway-ipc.4294967295.2147483647.sock'))) >= 108:
raise RuntimeError('IPC socket path exceeds sockaddr_un')
(run / 'runtime-path').write_text(str(runtime) + '\n')
(run / 'config').write_text(
'output HEADLESS-1 resolution 750x1000 scale 1.25 transform 90 position -800 0\n'
'output HEADLESS-2 resolution 800x600 position 0 0\n'
'workspace 1 output HEADLESS-1\nworkspace 2 output HEADLESS-2\n'
'focus_follows_mouse no\n')
(run / 'config').write_text((run / 'config').read_text() + f'output * bg {marker_path} fill\n')
env = os.environ.copy()
env.update(XDG_RUNTIME_DIR=str(runtime), WLR_BACKENDS='headless',
WLR_HEADLESS_OUTPUTS='2', WLR_RENDERER='pixman')
for key in ('SWAYSOCK', 'I3SOCK', 'WAYLAND_DISPLAY', 'LD_LIBRARY_PATH'):
env.pop(key, None)
compositor_env = env.copy()
compositor_env['ASI_MODE'] = str(args.asi_mode)
if args.test_hold or args.test_timeout:
compositor_env['ASI_TEST_HOLD_AFTER_PRESENT'] = '1'
readback_dir = None
if args.asi_mode:
readback_dir = lab / 'buffers' / run.name
readback_dir.mkdir(parents=True, mode=0o700)
compositor_env['BI_OUTPUT_DIR'] = str(readback_dir)
(run / 'asi-mode.json').write_text(json.dumps({'mode': args.asi_mode,
'readback_dir': str(readback_dir) if readback_dir else None}) + '\n')
binary = lab / 'standard-build/sway/sway' if args.mode == 'control' else lab / 'mosaic-build/sway/swayfx-mosaic'
if args.mode != 'control':
compositor_env['LD_LIBRARY_PATH'] = str(lab / 'scenefx-build')
files, children, events = [], [], []
started = time.monotonic()
capture_count = 0
capture_bytes = 0
def record(kind, **fields):
entry = {'monotonic_ns': time.monotonic_ns(), 'kind': kind, **fields}
events.append(entry)
with (run / 'events.jsonl').open('a') as out:
out.write(json.dumps(entry) + '\n')
return entry
def spawn(argv, log_name, *, stdin=False, debug=False):
log = (run / log_name).open('w')
files.append(log)
child_env = env.copy()
if debug:
child_env['WAYLAND_DEBUG'] = '1'
proc = subprocess.Popen(argv, env=child_env, stdin=subprocess.PIPE if stdin else None,
stdout=log, stderr=subprocess.STDOUT,
start_new_session=True, text=stdin)
children.append(proc)
return proc
def wait(predicate, seconds=12):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if time.monotonic() - started > args.max_seconds:
raise RuntimeError('run duration bound exceeded')
result = predicate()
if result:
return result
if compositor.poll() is not None:
raise RuntimeError(f'compositor exited {compositor.returncode}')
if 'pointer' in globals() and pointer.poll() is not None:
raise RuntimeError(f'pointer exited {pointer.returncode}')
if 'client' in globals() and client.poll() is not None:
raise RuntimeError(f'client exited {client.returncode}')
time.sleep(.04)
raise RuntimeError('timeout waiting for event')
def ipc(*argv):
completed = subprocess.run(['swaymsg', '-s', str(sock), *argv], env=env,
text=True, capture_output=True, check=True, timeout=8)
result = json.loads(completed.stdout)
record('ipc', argv=list(argv), result=result)
return result
def command(value):
result = ipc(value)
if not isinstance(result, list) or not result or not all(x.get('success') for x in result):
raise RuntimeError(f'IPC command rejected: {value}: {result}')
def find(node, predicate):
if isinstance(node, dict):
if predicate(node):
return node
for child in node.get('nodes', []) + node.get('floating_nodes', []):
found = find(child, predicate)
if found:
return found
return None
def tree():
return ipc('-t', 'get_tree')
def workspace():
return find(tree(), lambda n: n.get('type') == 'workspace' and n.get('name') == '1')
def owner():
return find(tree(), lambda n: n.get('app_id') == 'org.mosaic.NestedGrabProbe')
def client_text():
return (run / 'client.log').read_text()
def count(marker):
return client_text().count(marker)
def configured(name):
matches = re.findall(rf'{name} ROLE_CONFIGURE x=(-?\d+) y=(-?\d+) w=(\d+) h=(\d+)',
client_text())
if not matches:
raise RuntimeError(f'no {name} role configure')
return tuple(map(int, matches[-1]))
def snapshot(tag):
global capture_count, capture_bytes
t = tree()
outputs = ipc('-t', 'get_outputs')
(run / f'tree-{tag}.json').write_text(json.dumps(t, indent=2) + '\n')
(run / f'outputs-{tag}.json').write_text(json.dumps(outputs, indent=2) + '\n')
expected = {'HEADLESS-1': (-800, 0, 800, 600), 'HEADLESS-2': (0, 0, 800, 600)}
actual = {o['name']: tuple(o['rect'][k] for k in ('x','y','width','height')) for o in outputs if o['active']}
rotated = True
h1 = next(o for o in outputs if o['name'] == 'HEADLESS-1')
h2 = next(o for o in outputs if o['name'] == 'HEADLESS-2')
wanted_mode = (750, 1000) if rotated else (1000, 750)
if (actual != expected or h1['scale'] != 1.25 or h2['scale'] != 1 or
h2['transform'] != 'normal' or h1['transform'] != ('90' if rotated else 'normal') or
(h1['current_mode']['width'], h1['current_mode']['height']) != wanted_mode):
raise RuntimeError(f'applied rotation geometry gate failed: {actual} {h1}')
record('state', tag=tag, workspaces=ipc('-t','get_workspaces'), workspace=workspace(), owner=owner(), outputs=outputs)
for output, side in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')):
if capture_count >= args.max_captures:
raise RuntimeError('capture count bound exceeded')
path = run / f'{side}-{tag}.png'
before = time.monotonic_ns()
subprocess.run(['grim', '-s', str(next(o['scale'] for o in outputs if o['name']==output)), '-o', output, str(path)], env=env, check=True,
stdout=subprocess.DEVNULL, timeout=10)
after = time.monotonic_ns()
capture_count += 1
capture_bytes += path.stat().st_size
if capture_bytes > args.max_bytes:
raise RuntimeError('capture byte bound exceeded')
out = next(o for o in outputs if o['name']==output)
dimensions = list(Image.open(path).size)
record('capture_geometry',tag=tag,output=output,dimensions=dimensions,rect=out['rect'],scale=out['scale'],transform=out['transform'],mode=out['current_mode'], physical_expected=[out['current_mode']['width'],out['current_mode']['height']])
record('capture', tag=tag, output=output, path=path.name,
before_ns=before, after_ns=after, size=path.stat().st_size)
def inject(action, x, y):
outputs = [o for o in ipc('-t','get_outputs') if o['active']]
min_x = min(o['rect']['x'] for o in outputs)
min_y = min(o['rect']['y'] for o in outputs)
max_x = max(o['rect']['x']+o['rect']['width'] for o in outputs)
max_y = max(o['rect']['y']+o['rect']['height'] for o in outputs)
if not any(o['rect']['x']<=x<o['rect']['x']+o['rect']['width'] and o['rect']['y']<=y<o['rect']['y']+o['rect']['height'] for o in outputs):
raise ValueError('global point is outside applied outputs')
sent_x,sent_y = int(x-min_x),int(y-min_y)
width,height = max_x-min_x,max_y-min_y
marker=f'INJECT_DONE {action} {sent_x},{sent_y}'
before=(run/'pointer.log').read_text().count(marker)
record('pointer_request',action=action,global_point=[x,y],sent=[sent_x,sent_y],extent=[width,height],minimum=[min_x,min_y])
pointer.stdin.write(f'{action} {sent_x} {sent_y} {width} {height}\n');pointer.stdin.flush()
wait(lambda: (run/'pointer.log').read_text().count(marker)>before)
def client_command(value):
marker = f'COMMAND {value} DONE'
before = count(marker)
record('client_request', value=value)
client.stdin.write(value + '\n')
client.stdin.flush()
wait(lambda: count(marker) > before)
def scroll(target):
current = workspace()['mosaic']['rows'][0]['scroll_x']
command(f'layout mosaic scroll {target-current:g}')
wait(lambda: workspace()['mosaic']['rows'][0]['scroll_x'] == target)
record('camera_observed', target=target, owner_id=owner()['id'])
def open_chain(cycle):
a_before, b_before = count('POPUP_A MAP'), count('POPUP_B MAP')
o = owner()
scroll_x = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0
top_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + 100)
inject('move', top_x, 150)
inject('left', top_x, 150)
wait(lambda: count('POPUP_A MAP') > a_before)
snapshot(f'{cycle}-S1-A')
a_cfg = configured('POPUP_A')
a_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + 40)
a_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + 35)
inject('move', a_x, a_y)
inject('left', a_x, a_y)
wait(lambda: count('POPUP_B MAP') > b_before)
if readback_dir:
(readback_dir / 'enable').touch()
snapshot(f'{cycle}-S2-B')
b_cfg = configured('POPUP_B')
b_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + b_cfg[0] + 35)
b_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + b_cfg[1] + 30)
inject('move', b_x, b_y)
inject('right', b_x, b_y)
time.sleep(.12)
snapshot(f'{cycle}-S3-visible')
record('chain', cycle=cycle, owner_id=o['id'], scroll_x=scroll_x,
a_configure=a_cfg, b_configure=b_cfg,
top_global=[top_x, 150], a_global=[a_x, a_y], b_global=[b_x, b_y])
def cycle_once(number):
label = f'cycle-{number:03d}'
record('cycle_start', cycle=number)
snapshot(f'{label}-S0-reference')
open_chain(label)
if args.mode == 'private_mosaic':
scroll(1280)
snapshot(f'{label}-S4-hidden-immediate')
time.sleep(.18)
snapshot(f'{label}-S4-hidden-stable')
inject('move', -700, 190)
time.sleep(.1)
snapshot(f'{label}-S4-hidden-hover')
scroll(400)
snapshot(f'{label}-S5-return-immediate')
time.sleep(.18)
snapshot(f'{label}-S5-return-stable')
client_command('close_b')
snapshot(f'{label}-S6-close-B-immediate')
time.sleep(.18)
snapshot(f'{label}-S6-close-B-stable')
a_cfg = configured('POPUP_A')
o = owner()
scroll_x = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0
a_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + 20)
a_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + 20)
before_a = len(re.findall(r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1', client_text()))
inject('move', a_x, a_y)
inject('right', a_x, a_y)
wait(lambda: len(re.findall(r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1',
client_text())) > before_a)
snapshot(f'{label}-S6-A-interaction')
client_command('close_a')
snapshot(f'{label}-S7-close-A-immediate')
time.sleep(.18)
snapshot(f'{label}-S7-close-A-stable')
record('cycle_end', cycle=number, owner_id=owner()['id'], client_alive=client.poll() is None)
compositor_log = (run / 'compositor.log').open('w')
files.append(compositor_log)
compositor = None
failure = None
try:
compositor = subprocess.Popen([str(binary), '-c', str(run / 'config'), '-d'],
env=compositor_env, stdout=compositor_log,
stderr=subprocess.STDOUT, start_new_session=True)
def sockets():
s = next(iter(runtime.glob('sway-ipc.*.sock')), None)
w = next((p for p in runtime.glob('wayland-*') if p.suffix != '.lock'), None)
return (s, w) if s and w else None
sock, way = wait(sockets)
env.update(SWAYSOCK=str(sock), WAYLAND_DISPLAY=way.name)
time.sleep(1.0)
snapshot('startup')
pointer = spawn([str(lab / 'pointer-daemon'), '1600', '600'], 'pointer.log', stdin=True)
wait(lambda: 'POINTER_READY' in (run / 'pointer.log').read_text())
command('workspace 1')
command('layout mosaic' if args.mode == 'private_mosaic' else 'layout splith')
spawn(['kitty', '--class', 'A', '-o', 'cursor_blink_interval=0',
'-o', 'background=#aa2222', '/usr/bin/sleep', '900'], 'A.log')
wait(lambda: len(workspace()['nodes']) >= 1)
client = spawn([str(lab / 'nested-client'), 'rot_cross'], 'client.log', stdin=True, debug=True)
wait(lambda: owner() and 'TOPLEVEL MAP' in client_text())
for name in ('C', 'D'):
spawn(['kitty', '--class', name, '-o', 'cursor_blink_interval=0',
'/usr/bin/sleep', '900'], f'{name}.log')
time.sleep(.25)
command('workspace 2')
spawn(['stdbuf', '-oL', 'wev'], 'wev.log')
wait(lambda: find(tree(), lambda n: n.get('app_id') == 'wev'))
command('workspace 1')
command('[app_id="org.mosaic.NestedGrabProbe"] focus')
if args.mode == 'private_mosaic':
scroll(640)
record('initial_camera', value=640)
time.sleep(.25)
# EXPERIMENTAL fragment inserted inside the inherited bounded runner's try.
# WHY: persistence observations precede reference clicks that can dismiss grabs.
# CONTRACT: four reserved dynamic actions per mode; no additional camera pair.
dynamic_stage = 'D0'
initial_ws_id = workspace()['id']
initial_owner_id = owner()['id']
ref_right = find(tree(), lambda n: n.get('app_id') == 'wev')['id']
command('workspace 1')
left_proc = spawn(['stdbuf','-oL','wev'], 'wev-left.log')
wait(lambda: find(tree(), lambda n: n.get('app_id') == 'wev' and n['id'] != ref_right))
ref_left = find(tree(), lambda n: n.get('app_id') == 'wev' and n['id'] != ref_right)['id']
command('shadows disable'); command('blur disable')
def closed():
client_command('close_a')
time.sleep(.12)
def calibrate(tag):
outputs = {o['name']: o for o in ipc('-t','get_outputs') if o['active']}
active = {w['output']:w for w in ipc('-t','get_workspaces') if w['visible']}
for out, con_id, logname in [('HEADLESS-1',ref_left,'wev-left.log'),('HEADLESS-2',ref_right,'wev.log')]:
name = active[out]['name']
command(f'[con_id={con_id}] move container to workspace {name}')
command(f'[con_id={con_id}] floating enable')
command(f'[con_id={con_id}] border none')
command(f'[con_id={con_id}] resize set 200 150')
ox=outputs[out]['rect']['x']+50; oy=400
command(f'[con_id={con_id}] move absolute position {ox} {oy}')
time.sleep(.25)
# WHY: after return both references can temporarily share workspace 1.
# CONTRACT: relocate BOTH before scoring input, avoiding mutual occlusion.
for out, con_id, logname in [('HEADLESS-1',ref_left,'wev-left.log'),('HEADLESS-2',ref_right,'wev.log')]:
ox=outputs[out]['rect']['x']+50; oy=400
text=lambda: (run/logname).read_text()
# Force a different surface, then accept actual enter or motion.
inject('move', outputs['HEADLESS-2' if out=='HEADLESS-1' else 'HEADLESS-1']['rect']['x']+10,10)
before=len(text());inject('move',ox+20,oy+30)
pattern=r'(?:enter:[^\n]*|motion:[^\n]*)x, y: ([\d.]+), ([\d.]+)'
wait(lambda: re.search(pattern,text()[before:]))
ex,ey=map(float,re.findall(pattern,text()[before:])[-1])
if ex != 20 or not 0 <= ey < 100:
raise RuntimeError(f'calibration interior mismatch {tag} {out}: {ex},{ey}')
before=len(text());inject('move',ox+60,oy+50)
wait(lambda: f'x, y: {ex+40:.6f}, {ey+20:.6f}' in text()[before:])
before=len(text())
inject('left',ox+60,oy+50);inject('right',ox+60,oy+50)
wait(lambda: 'button: 272' in text()[before:] and 'button: 273' in text()[before:])
record('pointer_calibration',tag=tag,output=out,con_id=con_id,global_point=[ox+60,oy+50],local_expected=[ex+40,ey+20],surface_origin=[ox+20-ex,oy+30-ey],passed=True)
for x in (-2,-1,0,1,2):inject('move',x,550)
inject('move',outputs['HEADLESS-1']['rect']['x']+110,450)
command('workspace 1')
def stable(tag, expected_owner):
# CONTRACT: repeated geometry and paired settled captures are evidence,
# not proof that intermediate configurations each had a presentation.
time.sleep(.35)
before=ipc('-t','get_outputs')
time.sleep(.12)
after=ipc('-t','get_outputs')
if before != after:
raise RuntimeError(f'unstable output IPC at {tag}')
ws=workspace();o=owner()
current=next(w for w in ipc('-t','get_workspaces') if w['id']==initial_ws_id)
if ws['id']!=initial_ws_id or o['id']!=initial_owner_id or current['output']!=expected_owner:
raise RuntimeError(f'workspace/owner identity mismatch at {tag}')
snapshot(tag)
time.sleep(.16);snapshot(tag+'-repeat')
record('identity',tag=tag,workspace_id=ws['id'],owner_id=o['id'],output=current['output'],layout=ws['layout'],order=[n['id'] for n in ws['nodes']],mosaic=ws.get('mosaic'),focused=current['focused'])
def owner_hit(tag):
o=owner();ws=workspace()
scroll_x=ws['mosaic']['rows'][0]['scroll_x'] if args.mode=='private_mosaic' else 0
point=[int(o['rect']['x']-scroll_x+o['window_rect']['x']+100),150]
before=len(client_text());inject('move',*point);inject('right',*point)
delivered='POINTER_BUTTON target=TOPLEVEL' in client_text()[before:]
record('owner_hit',tag=tag,point=point,delivered=delivered,events=client_text()[before:])
if not delivered:raise RuntimeError(f'visible owner input failed {tag}')
# WHY: exact colors identify PNG orientation before interpreting output pixels.
# CONTRACT: marker positions/colors were fixed before the first transition.
colors = {'TL':(255,32,32),'TR':(32,255,32),'BL':(32,32,255),
'BR':(255,255,32),'C':(192,32,255)}
def marker_gate(tag, rotated):
im = Image.open(run / f'left-{tag}.png').convert('RGB')
points = {name: [] for name in colors}
reverse = {v:k for k,v in colors.items()}
for y in range(im.height):
for x in range(im.width):
name = reverse.get(im.getpixel((x,y)))
if name: points[name].append((x,y))
centers = {}
for name, positions in points.items():
if len(positions) < 100:
raise RuntimeError(f'{tag}: marker {name} absent ({len(positions)})')
centers[name] = [sum(p[0] for p in positions)/len(positions),
sum(p[1] for p in positions)/len(positions)]
logical = (im.size == (1000,750) and
centers['TL'][0] < centers['TR'][0] and
centers['TL'][1] < centers['BL'][1])
physical = (im.size == (750,1000) and
centers['TL'][0] > centers['BL'][0] and
centers['TL'][1] < centers['TR'][1])
frame = 'logical-oriented PNG' if logical else 'physical-buffer-oriented PNG' if physical else 'unknown'
record('marker_calibration', tag=tag, dimensions=list(im.size),
centers=centers, counts={k:len(v) for k,v in points.items()},
png_frame=frame)
if (rotated and not (logical or physical)) or (not rotated and not logical):
raise RuntimeError(f'{tag}: PNG frame not established')
return frame
# CONTRACT: static R90 from compositor startup; no transform command.
marker_gate('startup', True)
calibrate('static-R90')
command('[app_id="org.mosaic.NestedGrabProbe"] focus')
snapshot('reference')
owner_hit('static-R90')
open_chain('rot-cross')
o=owner(); ws=workspace(); a_cfg=configured('POPUP_A'); b_cfg=configured('POPUP_B')
sx=ws['mosaic']['rows'][0]['scroll_x'] if args.mode=='private_mosaic' else 0
base_x=o['rect']['x']-sx+o['window_rect']['x']
base_y=o['rect']['y']+o['window_rect']['y']
a_box=[base_x+a_cfg[0],base_y+a_cfg[1],base_x+a_cfg[0]+a_cfg[2],base_y+a_cfg[1]+a_cfg[3]]
b_box=[a_box[0]+b_cfg[0],a_box[1]+b_cfg[1],a_box[0]+b_cfg[0]+b_cfg[2],a_box[1]+b_cfg[1]+b_cfg[3]]
record('applied_popup_geometry',owner_id=o['id'], owner_rect=o['rect'],window_rect=o['window_rect'],
camera=sx,a_configure=a_cfg,b_configure=b_cfg,a_box=a_box,b_box=b_box,
a_h1=[max(-800,a_box[0]),a_box[1],min(0,a_box[2]),a_box[3]],
a_h2=[max(0,a_box[0]),a_box[1],min(800,a_box[2]),a_box[3]],
b_h1=[max(-800,b_box[0]),b_box[1],min(0,b_box[2]),b_box[3]],
b_h2=[max(0,b_box[0]),b_box[1],min(800,b_box[2]),b_box[3]])
if not (b_box[0] < 0 < b_box[2] and b_box[2]>=16):
record('geometry_gate',result='NOT EXERCISED',box=b_box)
else:
record('geometry_gate',result='CROSSING',box=b_box)
# WHY: evaluate a shared y within B and scan both sides of the boundary.
y=int((max(0,b_box[1])+min(600,b_box[3]))/2)
for x in range(-3,4):
before=len(client_text()); inject('move',x,y)
record('border_hover',point=[x,y],client_events=client_text()[before:])
inside_x=max(-30,int((max(-800,b_box[0])+min(0,b_box[2]))/2))
before=len(client_text()); inject('move',inside_x,y); inject('right',inside_x,y)
record('visible_b_button',point=[inside_x,y],client_events=client_text()[before:])
snapshot('after-visible-button')
# A left/right roles may dismiss on exterior click. Preserve it last.
if b_box[2]>0:
outside_x=min(30,int(b_box[2]/2))
before=len(client_text()); inject('move',outside_x,y); inject('right',outside_x,y)
record('forbidden_b_button',point=[outside_x,y],client_events=client_text()[before:])
time.sleep(.15)
snapshot('after-forbidden-button')
except Exception as exc:
failure = repr(exc)
record('failure', error=failure)
finally:
for proc in children + ([compositor] if compositor is not None else []):
if proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGTERM)
except ProcessLookupError:
pass
for proc in children + ([compositor] if compositor is not None else []):
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait(timeout=5)
(run / 'exit-status.json').write_text(json.dumps({
'failure': failure, 'compositor': None if compositor is None else compositor.returncode,
'children': [{'argv': p.args, 'returncode': p.returncode} for p in children],
'captures': capture_count, 'capture_bytes': capture_bytes,
'duration_seconds': time.monotonic() - started}, indent=2) + '\n')
for file in files:
file.close()
shutil.rmtree(runtime)
record('cleanup', runtime_removed=not runtime.exists())
print(f'RUN={run} FAILURE={failure} CAPTURES={capture_count}')
if failure:
raise SystemExit(1)