"""Real guest window-management checks. All input targets this harness's private QEMU."""
import argparse
import pathlib
import re
import subprocess
import statistics
import sys
import time
from PIL import Image, ImageChops, ImageDraw
STATE = re.compile(r'^XFE_WINDOW_STATE\|(\d+)\|([^|]+)\|(-?\d+)\|(-?\d+)\|(\d+)\|(\d+)\|([01])\|([01])\|([01])$', re.M)
APPEARANCE = re.compile(r'^XFE_DESKTOP_APPEARANCE\|(Light|Dark)\|([0-9A-F]{6})\|Persistent\|([01])$', re.M)
CHROME = re.compile(r'^XFE_CHROME_FRAME\|(\d+)\|Min\|(\d+)\|Close\|(\d+)\|Title\|(\d+)\|Resize\|(\d+)$', re.M)
def parse_render_stats(log):
"""Return complete, validated native statistics; never accept a serial tail.
A malformed newline-terminated record is a protocol failure, not a sample
to silently skip. Only the final not-yet-newline-terminated fragment waits
for a later read, even if some of its fields already look valid.
"""
required = {'Frames', 'Partial', 'Pixels', 'Glyphs', 'Fallback', 'LastRenderTicks', 'Frequency',
'MotionReuses', 'MotionPixels', 'ScanoutPixelsSaved'}
result = []
# Split only on the protocol terminator: splitlines() also treats control
# characters inside a malformed field as line boundaries.
for line_number, line in enumerate(log.split('\n')[:-1], 1):
if line.endswith('\r'): line = line[:-1]
if not line.startswith('XFE_RENDER_STATS'): continue
def malformed(reason):
raise ValueError(f'Malformed complete XFE_RENDER_STATS record at line {line_number}: {reason}; {line[:512]!r}')
fields = line.split('|')
if fields[0] != 'XFE_RENDER_STATS' or len(fields) < 4 or len(fields) % 2:
malformed('expected a timestamp followed by complete name/value pairs')
def integer(value, name):
if re.fullmatch(r'[0-9]+', value) is None:
malformed(name + ' must be an ASCII nonnegative integer')
try: parsed = int(value)
except ValueError: malformed(name + ' is outside the supported integer range')
if parsed > 9_223_372_036_854_775_807:
malformed(name + ' exceeds the native signed 64-bit range')
return parsed
value = {'Timestamp': integer(fields[1], 'Timestamp')}
for index in range(2, len(fields), 2):
name = fields[index]
if re.fullmatch(r'[A-Za-z][A-Za-z0-9]*', name) is None:
malformed('invalid field name ' + repr(name))
if name in value: malformed('duplicate field ' + name)
value[name] = integer(fields[index + 1], name)
missing = required.difference(value)
if missing: malformed('missing required fields ' + ', '.join(sorted(missing)))
if value['Frequency'] <= 0: malformed('Frequency must be greater than zero')
result.append(value)
return result
def run_desktop_checks(qmp, read_log, wait_log, screenshot, native_width, native_height, scale, pointer, input_rate=500, target_hz=240, desktop_v4=False):
assert 240 <= input_rate <= 2000
checks = []
def key(code, down):
qmp('input-send-event', {'events': [{'type': 'key', 'data': {'down': down, 'key': {'type': 'qcode', 'data': code}}}]})
time.sleep(.04)
def tap(code):
key(code, True); key(code, False)
time.sleep(.08)
def chord(modifier, code):
key(modifier, True); tap(code); key(modifier, False)
def move(x, y):
target = [round(x * scale), round(y * scale)]
while pointer != target:
dx = max(-80, min(80, target[0] - pointer[0])); dy = max(-80, min(80, target[1] - pointer[1]))
qmp('input-send-event', {'events': [
{'type': 'rel', 'data': {'axis': 'x', 'value': dx}},
{'type': 'rel', 'data': {'axis': 'y', 'value': dy}}]})
pointer[0] += dx; pointer[1] += dy
time.sleep(.018)
def button(down):
qmp('input-send-event', {'events': [{'type': 'btn', 'data': {'down': down, 'button': 'left'}}]})
time.sleep(.065)
def click(x, y):
move(x, y); button(True); button(False)
def double_click(x, y):
move(x, y)
button(True); button(False); button(True); button(False)
def drag(x, y, target_x, target_y):
move(x, y); button(True); move(target_x, target_y); button(False)
def states(start):
result = []
for match in STATE.finditer(read_log()[start:]):
groups = match.groups()
result.append(dict(zip(('Id', 'Application', 'X', 'Y', 'Width', 'Height', 'Minimized', 'Maximized', 'Snapped'),
(int(groups[0]), groups[1], *(int(value) for value in groups[2:])))))
return result
def wait_state(start, predicate, description):
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
for state in reversed(states(start)):
if predicate(state): return state
log = read_log()
assert '[FAILFAST]' not in log and '*** UNHANDLED EXCEPTION' not in log, description
time.sleep(.04)
raise AssertionError(description + ': no matching real guest state\n' + read_log()[start:][-5000:])
def app_open(application, x, y):
start = len(read_log()); click(x, y)
value = wait_state(start, lambda state: state['Application'] == application and not state['Minimized'], 'Open ' + application)
wait_log('XFE_DESKTOP_ACTIVE|' + application, start)
return value
def capture(name):
time.sleep(.12)
path = screenshot(name)
assert path is not None, 'The screenshot callback must return its saved native PNG path'
with Image.open(path) as saved:
assert saved.size == (native_width, native_height), saved.size
return saved.convert('RGB').copy()
def raw_frame(name):
return screenshot(name, convert=False)
def open_raw(path):
with Image.open(path) as saved:
assert saved.size == (native_width, native_height)
frame = saved.convert('RGB').copy()
frame.save(path.with_suffix('.png'))
return frame
def changed_pixels(before, after, bounds, excluded=()):
left, top, width, height = bounds
box = (round(left * scale), round(top * scale), round((left + width) * scale), round((top + height) * scale))
difference = ImageChops.difference(before, after)
if excluded:
erase = ImageDraw.Draw(difference)
for x, y in excluded:
erase.rectangle((round((x - 25) * scale), round((y - 25) * scale), round((x + 25) * scale), round((y + 25) * scale)), fill=(0, 0, 0))
return sum(max(pixel) > 3 for pixel in difference.crop(box).getdata())
def assert_hover_press(name, bounds, point):
away = (round(native_width / scale) - 35, 80)
move(*away); idle = capture(name + '-Idle')
move(*point); hovered = capture(name + '-Hover')
button(True); pressed = capture(name + '-Pressed')
hover_changes = changed_pixels(idle, hovered, bounds, (away, point))
press_changes = changed_pixels(hovered, pressed, bounds, (point,))
assert hover_changes > 12, (name, 'hover background outside the cursor did not change', hover_changes)
assert press_changes > 12, (name, 'pressed background outside the cursor did not change', press_changes)
button(False)
return {'HoverChangedNativePixels': hover_changes, 'PressedChangedNativePixels': press_changes}
def caption_motion(window, name, offset, field):
left = window['X'] + window['Width'] - offset
hover = (left - 14, window['Y'] + 29)
away = (left - 14, window['Y'] + 37)
roi = (round((left - 9) * scale), round((window['Y'] + 7) * scale), round((left + 9) * scale), round((window['Y'] + 21) * scale))
move(*away); time.sleep(.25)
idle = raw_frame(name + '-Idle')
start = len(read_log())
captures = []
def progress_values():
return [int(match[field]) for match in CHROME.finditer(read_log()[start:]) if int(match[1]) == window['Id']]
def wait_progress(predicate, description):
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
values = progress_values()
if values and predicate(values): return values[-1]
time.sleep(.002)
raise AssertionError((name, description, progress_values()))
def paused_raw(label):
# A 3840x2160 PPM writes ~24 MiB. Even without PNG compression its
# disk/QMP round trip can outlast the remaining reverse animation.
# Freeze the *already presented real frame* only for inspection;
# no guest clock or animation state is changed by this harness.
before_stop = time.monotonic(); qmp('stop'); stopped = time.monotonic()
try:
values = progress_values()
path = raw_frame(label)
captured = time.monotonic()
finally:
qmp('cont')
captures.append({'Name': label, 'RenderedProgressPermilleBeforeCapture': values[-1] if values else None,
'StopRoundTripMilliseconds': (stopped - before_stop) * 1000,
'PpmWriteMilliseconds': (captured - stopped) * 1000,
'InspectionPauseMilliseconds': (time.monotonic() - stopped) * 1000,
'NativePpmBytes': path.stat().st_size})
return path
move(*hover)
wait_progress(lambda values: 100 <= values[-1] <= 450, 'No real entering intermediate frame')
entering = paused_raw(name + '-Entering-Mid')
# Prepare a longer but unfinished entering phase before reversing.
# The sampled entering frame and reverse request need not share one
# short 55 ms interval while a full-resolution PPM is still writing.
reverse_requested_from = wait_progress(lambda values: 500 <= values[-1] <= 850,
'No unfinished entering phase before reversal')
move(*away)
def descending_non_symmetric(values):
if len(values) < 2 or not 0 < values[-1] < values[-2] < 1000: return False
# X is rotationally symmetric at progress 0, 500 and 1000. Select
# an actual descending frame away from those visually equal poses.
return 50 <= values[-1] <= 950 and (field != 3 or not 420 <= values[-1] <= 580)
wait_progress(descending_non_symmetric, 'No real descending intermediate frame')
reversing = paused_raw(name + '-Reversing-Mid')
time.sleep(.25)
restored = raw_frame(name + '-Restored')
move(*hover); time.sleep(.25)
completed = raw_frame(name + '-Completed')
move(*away); time.sleep(.25)
paths = (idle, entering, reversing, restored, completed)
frames = [open_raw(path).crop(roi) for path in paths]
def difference(first, last):
return sum(max(pixel) > 3 for pixel in ImageChops.difference(first, last).getdata())
entered = difference(frames[0], frames[1]); reversed_mid = difference(frames[0], frames[2])
mid_to_end = difference(frames[1], frames[4]); direction_change = difference(frames[1], frames[2])
restored_difference = difference(frames[0], frames[3])
values = []
for match in CHROME.finditer(read_log()[start:]):
if int(match[1]) == window['Id']: values.append(int(match[field]))
intermediate = [value for value in values if 0 < value < 1000]
descending = any(0 < last < first < 1000 for first, last in zip(values, values[1:]))
assert entered > 3 and reversed_mid > 3 and mid_to_end > 3 and direction_change > 3, (name, 'missing real intermediate/reverse pixels', entered, reversed_mid, mid_to_end, direction_change)
assert restored_difference == 0 and intermediate and descending, (name, 'animation jumped or failed to reverse smoothly', restored_difference, values)
return {'EnteringChangedPixels': entered, 'ReverseIntermediateChangedPixels': reversed_mid,
'EnteringToEndChangedPixels': mid_to_end, 'EnteringToReverseChangedPixels': direction_change,
'RestoredDifferencePixels': restored_difference, 'ProgressPermille': values,
'HasDescendingIntermediate': descending, 'RawCaptureBeforePngCompression': True,
'CaptureSelection': 'Actual presented intermediate/descending Chrome telemetry; inspection-only QMP pause during native PPM write',
'ReverseRequestedFromPermille': reverse_requested_from, 'IntermediateCaptures': captures,
'Screenshots': [path.with_suffix('.png').name for path in paths]}
visual = {}
# Close only windows in this test guest; the existing user-owned VM is never addressed.
for _ in range(15): tap('esc')
if desktop_v4:
move(round(native_width / scale) - 35, 80)
desktop_frame = capture('Desktop-No-Topbar-Native')
top_pixel = desktop_frame.getpixel((native_width // 2, round(15 * scale)))
empty_pixel = desktop_frame.getpixel((native_width - round(140 * scale), round(70 * scale)))
# The wallpaper now has a vertical gradient. Verify horizontal
# continuity and the absence of a sharp header edge, rather than
# requiring two different heights to have the old flat color.
strip = [(desktop_frame.getpixel((native_width // 2, round(y * scale))),
desktop_frame.getpixel((native_width - round(140 * scale), round(y * scale))))
for y in range(15, 71)]
assert all(left == right for left, right in strip), 'Unexpected top-strip chrome or text over the wallpaper'
maximum_step = max(abs(strip[index][0][channel] - strip[index - 1][0][channel])
for index in range(1, len(strip)) for channel in range(3))
assert maximum_step <= 1, ('A sharp system top-bar edge remains in the gradient', maximum_step)
visual['NoTopbar'] = {'NativeTopPixel': top_pixel, 'NativeDesktopPixel': empty_pixel,
'HorizontalContinuity': True, 'MaximumVerticalChannelStep': maximum_step}
checks.append('desktop top is clear wallpaper with no system top bar')
start = len(read_log())
visual['DesktopButton'] = assert_hover_press('Desktop-Files-Button', (22, 84, 100, 74), (30, 108))
files = wait_state(start, lambda state: state['Application'] == 'Files' and not state['Minimized'], 'Desktop button opens Files')
checks.append('desktop button hover and press change real native pixels outside the cursor')
original = files.copy()
start = len(read_log())
double_click(files['X'] + files['Width'] // 2, files['Y'] + 16)
maximized = wait_state(start, lambda state: state['Id'] == files['Id'] and state['Maximized'], 'Title double-click maximizes')
assert (maximized['X'], maximized['Y'], maximized['Width'], maximized['Height']) == (0, 0 if desktop_v4 else 42, round(native_width / scale), round(native_height / scale) - (48 if desktop_v4 else 90)), maximized
checks.append('title double-click maximizes to work area without covering taskbar')
screenshot('Window-Maximized')
start = len(read_log())
double_click(maximized['Width'] // 2, maximized['Y'] + 16)
files = wait_state(start, lambda state: state['Id'] == files['Id'] and not state['Maximized'], 'Title double-click restores')
assert all(files[field] == original[field] for field in ('X', 'Y', 'Width', 'Height')), (original, files)
checks.append('second title double-click restores exact bounds')
start = len(read_log())
drag(files['X'] + 110, files['Y'] + 16, files['X'] + 150, files['Y'] + 46)
files = wait_state(start, lambda state: state['Id'] == files['Id'] and state['X'] == original['X'] + 40 and state['Y'] == original['Y'] + 30, 'Title drag follows pointer')
checks.append('title drag moves the window while preserving size')
before_resize = files.copy(); start = len(read_log())
drag(files['X'] + files['Width'] - 9, files['Y'] + files['Height'] - 9,
files['X'] + files['Width'] + 71, files['Y'] + files['Height'] + 41)
expected_width = min(before_resize['Width'] + 80, round(native_width / scale) - before_resize['X'])
expected_height = min(before_resize['Height'] + 50, round(native_height / scale) - 48 - before_resize['Y'])
files = wait_state(start, lambda state: state['Id'] == files['Id'] and state['Width'] == expected_width and state['Height'] == expected_height, 'Bottom-right resize')
checks.append('bottom-right drag resizes with work-area constraints')
screenshot('Window-Resized')
if desktop_v4:
visual['CaptionMotion'] = {'Minimize': caption_motion(files, 'Caption-Minimize', 48, 2),
'Close': caption_motion(files, 'Caption-Close', 16, 3)}
checks.append('minimize hover shows a real intermediate rotation and reverses continuously before settling')
checks.append('close hover captures intermediate X rotation and continuous reverse before its symmetric endpoint')
visual['FilesToolbar'] = assert_hover_press('Files-Up-Button', (files['X'] + 6, files['Y'] + 36, 54, 26), (files['X'] + 8, files['Y'] + 38))
checks.append('Files toolbar hover and press change real native pixels outside the cursor')
move(round(native_width / scale) - 35, 80)
caption_frame = capture('Window-Caption-TwoButtons')
title_color = caption_frame.getpixel((round((files['X'] + files['Width'] // 2 + 50) * scale), round((files['Y'] + 14) * scale)))
old_square = caption_frame.crop((round((files['X'] + files['Width'] - 57) * scale), round((files['Y'] + 9) * scale),
round((files['X'] + files['Width'] - 37) * scale), round((files['Y'] + (13 if desktop_v4 else 17)) * scale)))
former_square_pixels = sum(max(abs(pixel[channel] - title_color[channel]) for channel in range(3)) > 3 for pixel in old_square.getdata())
assert former_square_pixels == 0, ('A maximize-square glyph remains above the new minimize line', former_square_pixels)
corner_box = (round(files['X'] * scale), round(files['Y'] * scale), round((files['X'] + 12) * scale), round((files['Y'] + 12) * scale))
corner_colors = len(set(caption_frame.crop(corner_box).getdata()))
assert corner_colors >= 8, ('Window corner does not expose blended antialiasing coverage at native resolution', corner_colors)
visual['Caption'] = {'FormerMaximizeSquarePixels': former_square_pixels, 'NativeCornerDistinctColors': corner_colors, 'NativeScreenshot': 'Window-Caption-TwoButtons.png'}
checks.append('caption shows minimize and close without the former maximize-square glyph')
checks.append('native rounded window corner contains blended antialiasing coverage colors')
taskbar_y = round(native_height / scale) - 24
start = len(read_log()); click(100, taskbar_y)
wait_state(start, lambda state: state['Id'] == files['Id'] and state['Minimized'], 'Taskbar minimizes active window')
start = len(read_log()); click(100, taskbar_y)
restored = wait_state(start, lambda state: state['Id'] == files['Id'] and not state['Minimized'], 'Taskbar restores window')
assert all(restored[field] == files[field] for field in ('X', 'Y', 'Width', 'Height')), (files, restored)
checks.append('taskbar minimizes and restores the same live window and bounds')
terminal = app_open('Terminal', 60, 402)
browser = app_open('Browser', 60, 308)
start = len(read_log()); key('alt', True)
for application in ('Terminal', 'Files', 'Browser', 'Terminal'):
step = len(read_log()); tap('tab'); wait_log('XFE_DESKTOP_ACTIVE|' + application, step)
key('alt', False)
checks.append('held Alt+Tab cycles through three windows repeatedly and commits on Alt release')
start = len(read_log()); key('alt', True); key('shift', True); tap('tab')
wait_log('XFE_DESKTOP_ACTIVE|Files', start)
key('shift', False); key('alt', False)
checks.append('Alt+Shift+Tab selects the previous window in MRU order')
screenshot('Window-AltTab-Committed')
start = len(read_log()); chord('meta_l', 'd')
for target in (files, terminal, browser):
wait_state(start, lambda state, identity=target['Id']: state['Id'] == identity and state['Minimized'], 'Raw Win+D hides each live window')
start = len(read_log()); chord('meta_l', 'd')
for target in (files, terminal, browser):
wait_state(start, lambda state, identity=target['Id']: state['Id'] == identity and not state['Minimized'], 'Raw Win+D restores each live window')
checks.append('raw Win+D toggles desktop without losing window state')
start = len(read_log()); tap('meta_l'); tap('ret')
opened = wait_state(start, lambda state: state['Application'] == 'Files' and state['Id'] > max(files['Id'], terminal['Id'], browser['Id']), 'Raw Win opens launcher; Enter opens Files')
checks.append('raw Win make/break opens the launcher and Enter starts its selected application')
start = len(read_log()); chord('alt', 'f4')
wait_log('XFE_DESKTOP_ACTIVE|Files', start)
checks.append('raw Alt+F4 closes the foreground launcher window and returns to previous window')
taskbar_frame = capture('Window-Taskbar-Final')
sample_x = native_width - round(140 * scale)
taskbar_pixel = taskbar_frame.getpixel((sample_x, native_height - round(8 * scale)))
desktop_pixel = taskbar_frame.getpixel((sample_x, native_height - round(60 * scale)))
assert taskbar_pixel != desktop_pixel, ('Native bottom taskbar band is absent', taskbar_pixel, desktop_pixel)
visual['NativeTaskbarBand'] = {'NativeBottomPixel': taskbar_pixel, 'NativeDesktopPixel': desktop_pixel, 'FrameSize': [native_width, native_height], 'HostViewportMeasured': False}
checks.append('native framebuffer includes the full bottom taskbar band at unchanged resolution')
def render_stats():
return parse_render_stats(read_log())
def next_stats(previous_timestamp=-1):
deadline = time.monotonic() + 6
while time.monotonic() < deadline:
values = render_stats()
if values and values[-1]['Timestamp'] > previous_timestamp: return values[-1]
time.sleep(.02)
raise AssertionError('Guest render statistics did not advance')
def measure(begin, end, duration, dispatch_count, raw_events_per_dispatch, host_sample_seconds):
elapsed = (end['Timestamp'] - begin['Timestamp']) / end['Frequency']
frames = end['Frames'] - begin['Frames']; pixels = end['Pixels'] - begin['Pixels']
assert elapsed > 0 and frames > 0, (begin, end)
assert end['Fallback'] - begin['Fallback'] == 0, (begin, end)
assert end['Partial'] - begin['Partial'] > 0, (begin, end)
active = []
observed = [value for value in render_stats() if begin['Timestamp'] <= value['Timestamp'] <= end['Timestamp']]
observed_gaps = [(last['Timestamp'] - first['Timestamp']) / last['Frequency'] for first, last in zip(observed, observed[1:])]
assert all(gap > 0 for gap in observed_gaps), ('Native statistics timestamps did not advance', observed)
maximum_observed_gap = max(observed_gaps, default=0)
values = [value for value in observed if value['Timestamp'] <= begin['Timestamp'] + round(duration * begin['Frequency'])]
for first, last in zip(values, values[1:]):
if first['Timestamp'] <= begin['Timestamp']: continue
interval = (last['Timestamp'] - first['Timestamp']) / last['Frequency']
count = last['Frames'] - first['Frames']
copied_pixels = last['Pixels'] - first['Pixels']
assert count >= 0 and copied_pixels >= 0 and (count > 0 or copied_pixels == 0), (first, last)
# A complete interval with no frame is observed stutter, and must
# remain in the distribution instead of inflating its median FPS.
active.append({'Seconds': interval, 'Frames': count, 'RendererFramesPerSecond': count / interval,
'AverageCopiedPixelsPerFrame': copied_pixels / count if count else 0,
'FullCopyFallbacks': last['Fallback'] - first['Fallback']})
assert active, ('No complete stable activity window was sampled',
{'CompleteRecordsIncludingBoundaries':len(observed),'MaximumObservedStatsGapSeconds':maximum_observed_gap,
'BeginTimestamp':begin['Timestamp'],'EndTimestamp':end['Timestamp'],'InputMotionSeconds':duration})
return {'SampleWindowSecondsIncludingIdle': elapsed, 'InputMotionSeconds': duration, 'FramesIncludingIdleWindow': frames,
'RendererFramesPerSecond': frames / elapsed, 'AverageCopiedPixelsPerFrame': pixels / frames,
'WholeWindowIncludesTrailingIdle': True, 'ActiveStableWindows': active,
'ActiveStableMedianRendererFramesPerSecond': statistics.median(value['RendererFramesPerSecond'] for value in active),
'StableWindowRatesIncludeZeroFrameIntervals': True, 'ZeroFrameStableWindows':sum(value['Frames']==0 for value in active),
'MaximumStableStatsGapSeconds':max(value['Seconds'] for value in active),
'MaximumObservedStatsGapSeconds':maximum_observed_gap,
'HostSampleWindowSecondsIncludingIdle': host_sample_seconds, 'HostWholeWindowRendererFramesPerSecond': frames / host_sample_seconds,
'HostInputDispatchesSent': dispatch_count, 'HostInputDispatchesPerSecond': dispatch_count / duration,
'HostRawInputEventsSent': dispatch_count * raw_events_per_dispatch,
'HostRawInputEventsPerSecond': dispatch_count * raw_events_per_dispatch / duration, 'HostInputDispatchRateRequested': input_rate,
'FullScreenPixels': native_width * native_height,
'PartialFrames': end['Partial'] - begin['Partial'], 'FullCopyFallbacks': end['Fallback'] - begin['Fallback'],
'MotionReuses': end.get('MotionReuses', 0) - begin.get('MotionReuses', 0),
'MotionPixels': end.get('MotionPixels', 0) - begin.get('MotionPixels', 0),
'LastRenderMilliseconds': end['LastRenderTicks'] * 1000 / end['Frequency'],
'TargetHz': target_hz, 'TargetMeaning': 'EDID renderer target; this is not a VSync or guaranteed presentation-rate measurement'}
def timed_input(send_event):
began = time.monotonic(); index = 0
while time.monotonic() - began < 3.1:
send_event(index); index += 1
due = began + index / input_rate
delay = due - time.monotonic()
if delay > 0: time.sleep(delay)
return time.monotonic() - began, index
# The right side is unobstructed at both supported 1280/800 and 4K/200% layouts.
move(round(native_width / scale) - 35, 80)
baseline_stats = next_stats(render_stats()[-1]['Timestamp'] if render_stats() else -1)
baseline_wall = time.monotonic()
def cursor_event(index):
dx = 2 if (index // 12) % 2 == 0 else -2
qmp('input-send-event', {'events': [{'type': 'rel', 'data': {'axis': 'x', 'value': dx}}]})
pointer[0] += dx
duration, events = timed_input(cursor_event)
end_stats = next_stats(baseline_stats['Timestamp'] + round(duration * baseline_stats['Frequency']))
cursor_stats = measure(baseline_stats, end_stats, duration, events, 1, time.monotonic() - baseline_wall)
assert cursor_stats['AverageCopiedPixelsPerFrame'] < native_width * native_height / 10, cursor_stats
cursor_position = (round(native_width / scale) - 35, 80)
move(cursor_position[0], cursor_position[1] + 70)
arrow_background = capture('Cursor-Arrow-Background')
move(*cursor_position)
arrow_frame = capture('Cursor-Arrow-Native')
cx, cy = (round(value * scale) for value in cursor_position)
radius = round(25 * scale)
arrow_box = (cx - radius, cy - radius, min(native_width, cx + radius + 1), cy + radius + 1)
before_pixels = arrow_background.crop(arrow_box).getdata()
arrow_pixels = arrow_frame.crop(arrow_box).getdata()
colored = [pixel for old, pixel in zip(before_pixels, arrow_pixels) if max(abs(pixel[channel] - old[channel]) for channel in range(3)) > 3]
distinct_colors = len(set(colored))
blue_core_pixels = sum(pixel[2] - pixel[0] > 25 and pixel[2] - pixel[1] > 12 for pixel in colored)
assert distinct_colors >= 16 and blue_core_pixels >= 12, ('Arrow needs continuous antialiased blue core and blended halo', distinct_colors, blue_core_pixels)
visual['ArrowCursor'] = {'ChangedNativePixels': len(colored), 'DistinctBlendedColors': distinct_colors, 'BlueCorePixels': blue_core_pixels, 'NativeScreenshot': 'Cursor-Arrow-Native.png'}
checks.append('native arrow cursor retains a blue core and at least sixteen blended edge/halo colors')
checks.append('cursor-only native-resolution movement uses damaged copy with zero full-copy fallback')
editor = app_open('Editor', 60, 214)
# Every line differs; saving its exact length confirms that burst key events were consumed.
source = ''.join('row%02d\n' % index for index in range(80))
for character in source:
code = 'ret' if character == '\n' else character
qmp('input-send-event', {'events': [
{'type': 'key', 'data': {'down': True, 'key': {'type': 'qcode', 'data': code}}},
{'type': 'key', 'data': {'down': False, 'key': {'type': 'qcode', 'data': code}}}]})
time.sleep(.012)
start = len(read_log()); chord('ctrl', 's')
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if re.search(r'^XFE_DESKTOP_SAVED\|[^\r\n]+\|' + str(len(source)) + r'$', read_log()[start:], re.M): break
time.sleep(.04)
else: raise AssertionError('The scroll-test document did not save its expected length')
move(editor['X'] + 150, editor['Y'] + 150)
screenshot('Cursor-Text-Native')
baseline_stats = next_stats(render_stats()[-1]['Timestamp'])
baseline_wall = time.monotonic()
def scroll_event(index):
wheel = 'wheel-up' if (index // 4) % 2 == 0 else 'wheel-down'
qmp('input-send-event', {'events': [
{'type': 'btn', 'data': {'down': True, 'button': wheel}},
{'type': 'btn', 'data': {'down': False, 'button': wheel}}]})
duration, events = timed_input(scroll_event)
end_stats = next_stats(baseline_stats['Timestamp'] + round(duration * baseline_stats['Frequency']))
scroll_stats = measure(baseline_stats, end_stats, duration, events, 2, time.monotonic() - baseline_wall)
screenshot('Scroll-LongDocument-Native')
checks.append('long-document wheel scrolling retains partial scanout and zero full-copy fallback')
move(editor['X'] + editor['Width'] - 9, editor['Y'] + editor['Height'] - 9)
screenshot('Cursor-Resize-Native')
settings_point = (50, 492) if desktop_v4 else (round(native_width / scale) - 60, 22)
settings = app_open('Settings', *settings_point)
# Fresh desktops now start dark. Make the following Dark click an actual
# transition so the existing state/hover/pixel checks still verify a change.
preparation_start = len(read_log())
click(settings['X'] + 28, settings['Y'] + 93)
wait_log('XFE_DESKTOP_APPEARANCE|Light|657DE2|Persistent|0', preparation_start)
dark_start = len(read_log())
visual['SettingsDarkButton'] = assert_hover_press('Settings-Dark-Button',
(settings['X'] + 140, settings['Y'] + 88, 104, 34), (settings['X'] + 144, settings['Y'] + 93))
wait_log('XFE_DESKTOP_APPEARANCE|Dark|657DE2|Persistent|0', dark_start)
move(round(native_width / scale) - 35, 80)
dark_frame = capture('Settings-Dark-Native')
corner = (native_width - round(140 * scale), native_height - round(60 * scale))
dark_pixel = dark_frame.getpixel(corner)
assert sum(dark_pixel) / 3 < 100, ('Dark theme did not darken the native desktop', dark_pixel)
checks.append('Settings Dark button hover/press and native dark desktop pixels agree with guest appearance state')
light_start = len(read_log())
visual['SettingsLightButton'] = assert_hover_press('Settings-Light-Button',
(settings['X'] + 24, settings['Y'] + 88, 104, 34), (settings['X'] + 28, settings['Y'] + 93))
wait_log('XFE_DESKTOP_APPEARANCE|Light|657DE2|Persistent|0', light_start)
move(round(native_width / scale) - 35, 80)
light_frame = capture('Settings-Light-Native')
light_pixel = light_frame.getpixel(corner)
assert sum(light_pixel) / 3 > 200 and light_pixel != dark_pixel, ('Light theme did not restore the native desktop', light_pixel, dark_pixel)
checks.append('Settings Light button hover/press restores a visibly light native desktop')
visual['SettingsAccentField'] = assert_hover_press('Settings-Accent-Field',
(settings['X'] + 24, settings['Y'] + 202, 220, 34), (settings['X'] + 28, settings['Y'] + 207))
custom_start = len(read_log())
chord('shift', '3')
for code in '14a8b4': tap(code)
tap('ret')
wait_log('XFE_DESKTOP_APPEARANCE|Light|14A8B4|Persistent|0', custom_start)
move(round(native_width / scale) - 35, 80)
accent_frame = capture('Settings-Custom-Accent-Native')
accent_pixels = changed_pixels(light_frame, accent_frame, (settings['X'] + 20, settings['Y'] + 38, 280, 25) if desktop_v4 else (45, 8, 250, 28))
assert accent_pixels > 20, ('Guest accent state changed without redrawing real title ink', accent_pixels)
visual['Appearance'] = {'DarkDesktopPixel': dark_pixel, 'LightDesktopPixel': light_pixel,
'CustomAccent': '#14A8B4', 'ChangedAccentNativePixels' if desktop_v4 else 'ChangedHeaderNativePixels': accent_pixels, 'Persistent': False}
checks.append('Settings accent field hover/press and custom RGB keyboard input recolor real native title pixels')
invalid_start = len(read_log())
click(settings['X'] + 28, settings['Y'] + 207)
chord('shift', '3'); tap('1'); tap('2'); tap('ret')
capture('Settings-Invalid-Accent-Native')
latest = APPEARANCE.findall(read_log())[-1]
assert latest == ('Light', '14A8B4', '0'), ('Invalid short accent changed the actual appearance', latest)
assert all(match == latest for match in APPEARANCE.findall(read_log()[invalid_start:])), 'Invalid accent emitted a different appearance'
checks.append('invalid short accent is rejected while preserving the prior appearance')
reset_start = len(read_log()); click(settings['X'] + 44, settings['Y'] + 165)
wait_log('XFE_DESKTOP_APPEARANCE|Light|657DE2|Persistent|0', reset_start)
chord('alt', 'f4')
settings = app_open('Settings', *settings_point)
capture('Settings-Ram-Appearance-Reopened')
assert APPEARANCE.findall(read_log())[-1] == ('Light', '657DE2', '0')
checks.append('Blue preset restores the original accent and reopening Settings retains the RAM session appearance')
performance = {'Cursor': cursor_stats, 'Scroll': scroll_stats}
if desktop_v4:
chord('alt', 'f4')
step = len(read_log()); click(100, round(native_height / scale) - 24)
files = wait_state(step, lambda state: state['Id'] == files['Id'] and not state['Minimized'], 'Activate Files for native motion')
for kind in ('Drag', 'Resize'):
if kind == 'Drag': move(files['X'] + 120, files['Y'] + 16)
else: move(files['X'] + files['Width'] - 9, files['Y'] + files['Height'] - 9)
button(True)
baseline_stats = next_stats(render_stats()[-1]['Timestamp'])
baseline_wall = time.monotonic()
def motion_event(index):
delta = 2 if (index // 12) % 2 == 0 else -2
events = [{'type': 'rel', 'data': {'axis': 'x', 'value': delta}}]
pointer[0] += delta
if kind == 'Resize':
events.append({'type': 'rel', 'data': {'axis': 'y', 'value': delta}}); pointer[1] += delta
qmp('input-send-event', {'events': events})
duration, events = timed_input(motion_event)
end_stats = next_stats(baseline_stats['Timestamp'] + round(duration * baseline_stats['Frequency']))
performance[kind] = measure(baseline_stats, end_stats, duration, events, 1 if kind == 'Drag' else 2, time.monotonic() - baseline_wall)
step = len(read_log()); button(False)
# At dense input the bounded guest drain can publish intermediate
# bounds while the actual release is still queued. Correlate with
# its real dispatch acknowledgement before reading final bounds;
# do not treat any same-window report as a completed gesture.
release_deadline = time.monotonic() + 10
while time.monotonic() < release_deadline:
release = re.search(r'^XFE_NATIVE_POINTER\|[^\r\n]*\|Left\|0\|[^\r\n]*\r?\n', read_log()[step:], re.M)
if release:
released_at = step + release.end()
break
time.sleep(.01)
else:
raise AssertionError(kind + ': actual native pointer release was not acknowledged')
files = wait_state(released_at, lambda state: state['Id'] == files['Id'] and not state['Minimized'], 'Native motion settles after release')
if kind == 'Drag': assert performance[kind]['MotionReuses'] > 0 and performance[kind]['MotionPixels'] > 0, 'Native pointer-free body reuse was not exercised'
move(round(native_width / scale) - 35, 80); time.sleep(.3)
partial_frame = capture('Window-' + kind + '-Settled')
restore_start = len(read_log()); chord('meta_l', 'd'); chord('meta_l', 'd')
restored_state = wait_state(restore_start, lambda state: state['Id'] == files['Id'] and not state['Minimized'], 'Win+D restores motion state for full redraw comparison')
assert all(restored_state[key] == files[key] for key in ('X', 'Y', 'Width', 'Height', 'Maximized', 'Snapped')), (kind, files, restored_state)
time.sleep(.3)
redrawn_frame = capture('Window-' + kind + '-FullRedraw')
residue = sum(any(pixel) for pixel in ImageChops.difference(partial_frame, redrawn_frame).getdata())
assert residue == 0, (kind, 'motion left pixels differing from forced full redraw', residue)
visual[kind + 'Motion'] = {'FullRedrawDifferencePixels': residue, 'SameWindowBounds': True, 'HostViewportMeasured': False}
checks.append('native ' + kind.lower() + ' motion is measured at dense input and settles without any residue against full redraw')
return {'Checks': checks, 'Performance': performance, 'VisualEvidence': visual,
'PerformanceGoalHz': target_hz, 'VSyncConfirmed': False}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--resolution', default='0x0')
parser.add_argument('--edid-resolution', default='3840x2160')
parser.add_argument('--refresh-hint', type=int, default=240)
parser.add_argument('--accel', choices=['tcg', 'whpx'], default='tcg')
parser.add_argument('--input-rate', type=int, default=500)
parser.add_argument('--desktop-ui-only', action='store_true')
parser.add_argument('--desktop-v4', action='store_true')
parser.add_argument('--verified-image')
parser.add_argument('--verified-boot-result')
options = parser.parse_args()
test = pathlib.Path(__file__).resolve().parent / 'CSharpBoot/TestBoot.py'
arguments = [sys.executable, str(test), '--desktop-ui', '--resolution', options.resolution,
'--edid-resolution', options.edid_resolution, '--refresh-hint', str(options.refresh_hint), '--video-memory-mib', '64',
'--accel', options.accel, '--input-rate', str(options.input_rate)]
if options.verified_image: arguments += ['--verified-image', options.verified_image]
if options.verified_boot_result: arguments += ['--verified-boot-result', options.verified_boot_result]
if options.desktop_ui_only: arguments += ['--desktop-ui-only']
if options.desktop_v4: arguments += ['--desktop-v4']
return subprocess.call(arguments)
if __name__ == '__main__': raise SystemExit(main())
"""Real guest window-management checks. All input targets this harness's private QEMU."""
import argparse
import pathlib
import re
import subprocess
import statistics
import sys
import time
from PIL import Image, ImageChops, ImageDraw
STATE = re.compile(r'^XFE_WINDOW_STATE\|(\d+)\|([^|]+)\|(-?\d+)\|(-?\d+)\|(\d+)\|(\d+)\|([01])\|([01])\|([01])$', re.M)
APPEARANCE = re.compile(r'^XFE_DESKTOP_APPEARANCE\|(Light|Dark)\|([0-9A-F]{6})\|Persistent\|([01])$', re.M)
CHROME = re.compile(r'^XFE_CHROME_FRAME\|(\d+)\|Min\|(\d+)\|Close\|(\d+)\|Title\|(\d+)\|Resize\|(\d+)$', re.M)
def parse_render_stats(log):
"""Return complete, validated native statistics; never accept a serial tail.
A malformed newline-terminated record is a protocol failure, not a sample
to silently skip. Only the final not-yet-newline-terminated fragment waits
for a later read, even if some of its fields already look valid.
"""
required = {'Frames', 'Partial', 'Pixels', 'Glyphs', 'Fallback', 'LastRenderTicks', 'Frequency',
'MotionReuses', 'MotionPixels', 'ScanoutPixelsSaved'}
result = []
# Split only on the protocol terminator: splitlines() also treats control
# characters inside a malformed field as line boundaries.
for line_number, line in enumerate(log.split('\n')[:-1], 1):
if line.endswith('\r'): line = line[:-1]
if not line.startswith('XFE_RENDER_STATS'): continue
def malformed(reason):
raise ValueError(f'Malformed complete XFE_RENDER_STATS record at line {line_number}: {reason}; {line[:512]!r}')
fields = line.split('|')
if fields[0] != 'XFE_RENDER_STATS' or len(fields) < 4 or len(fields) % 2:
malformed('expected a timestamp followed by complete name/value pairs')
def integer(value, name):
if re.fullmatch(r'[0-9]+', value) is None:
malformed(name + ' must be an ASCII nonnegative integer')
try: parsed = int(value)
except ValueError: malformed(name + ' is outside the supported integer range')
if parsed > 9_223_372_036_854_775_807:
malformed(name + ' exceeds the native signed 64-bit range')
return parsed
value = {'Timestamp': integer(fields[1], 'Timestamp')}
for index in range(2, len(fields), 2):
name = fields[index]
if re.fullmatch(r'[A-Za-z][A-Za-z0-9]*', name) is None:
malformed('invalid field name ' + repr(name))
if name in value: malformed('duplicate field ' + name)
value[name] = integer(fields[index + 1], name)
missing = required.difference(value)
if missing: malformed('missing required fields ' + ', '.join(sorted(missing)))
if value['Frequency'] <= 0: malformed('Frequency must be greater than zero')
result.append(value)
return result
def run_desktop_checks(qmp, read_log, wait_log, screenshot, native_width, native_height, scale, pointer, input_rate=500, target_hz=240, desktop_v4=False):
assert 240 <= input_rate <= 2000
checks = []
def key(code, down):
qmp('input-send-event', {'events': [{'type': 'key', 'data': {'down': down, 'key': {'type': 'qcode', 'data': code}}}]})
time.sleep(.04)
def tap(code):
key(code, True); key(code, False)
time.sleep(.08)
def chord(modifier, code):
key(modifier, True); tap(code); key(modifier, False)
def move(x, y):
target = [round(x * scale), round(y * scale)]
while pointer != target:
dx = max(-80, min(80, target[0] - pointer[0])); dy = max(-80, min(80, target[1] - pointer[1]))
qmp('input-send-event', {'events': [
{'type': 'rel', 'data': {'axis': 'x', 'value': dx}},
{'type': 'rel', 'data': {'axis': 'y', 'value': dy}}]})
pointer[0] += dx; pointer[1] += dy
time.sleep(.018)
def button(down):
qmp('input-send-event', {'events': [{'type': 'btn', 'data': {'down': down, 'button': 'left'}}]})
time.sleep(.065)
def click(x, y):
move(x, y); button(True); button(False)
def double_click(x, y):
move(x, y)
button(True); button(False); button(True); button(False)
def drag(x, y, target_x, target_y):
move(x, y); button(True); move(target_x, target_y); button(False)
def states(start):
result = []
for match in STATE.finditer(read_log()[start:]):
groups = match.groups()
result.append(dict(zip(('Id', 'Application', 'X', 'Y', 'Width', 'Height', 'Minimized', 'Maximized', 'Snapped'),
(int(groups[0]), groups[1], *(int(value) for value in groups[2:])))))
return result
def wait_state(start, predicate, description):
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
for state in reversed(states(start)):
if predicate(state): return state
log = read_log()
assert '[FAILFAST]' not in log and '*** UNHANDLED EXCEPTION' not in log, description
time.sleep(.04)
raise AssertionError(description + ': no matching real guest state\n' + read_log()[start:][-5000:])
def app_open(application, x, y):
start = len(read_log()); click(x, y)
value = wait_state(start, lambda state: state['Application'] == application and not state['Minimized'], 'Open ' + application)
wait_log('XFE_DESKTOP_ACTIVE|' + application, start)
return value
def capture(name):
time.sleep(.12)
path = screenshot(name)
assert path is not None, 'The screenshot callback must return its saved native PNG path'
with Image.open(path) as saved:
assert saved.size == (native_width, native_height), saved.size
return saved.convert('RGB').copy()
def raw_frame(name):
return screenshot(name, convert=False)
def open_raw(path):
with Image.open(path) as saved:
assert saved.size == (native_width, native_height)
frame = saved.convert('RGB').copy()
frame.save(path.with_suffix('.png'))
return frame
def changed_pixels(before, after, bounds, excluded=()):
left, top, width, height = bounds
box = (round(left * scale), round(top * scale), round((left + width) * scale), round((top + height) * scale))
difference = ImageChops.difference(before, after)
if excluded:
erase = ImageDraw.Draw(difference)
for x, y in excluded:
erase.rectangle((round((x - 25) * scale), round((y - 25) * scale), round((x + 25) * scale), round((y + 25) * scale)), fill=(0, 0, 0))
return sum(max(pixel) > 3 for pixel in difference.crop(box).getdata())
def assert_hover_press(name, bounds, point):
away = (round(native_width / scale) - 35, 80)
move(*away); idle = capture(name + '-Idle')
move(*point); hovered = capture(name + '-Hover')
button(True); pressed = capture(name + '-Pressed')
hover_changes = changed_pixels(idle, hovered, bounds, (away, point))
press_changes = changed_pixels(hovered, pressed, bounds, (point,))
assert hover_changes > 12, (name, 'hover background outside the cursor did not change', hover_changes)
assert press_changes > 12, (name, 'pressed background outside the cursor did not change', press_changes)
button(False)
return {'HoverChangedNativePixels': hover_changes, 'PressedChangedNativePixels': press_changes}
def caption_motion(window, name, offset, field):
left = window['X'] + window['Width'] - offset
hover = (left - 14, window['Y'] + 29)
away = (left - 14, window['Y'] + 37)
roi = (round((left - 9) * scale), round((window['Y'] + 7) * scale), round((left + 9) * scale), round((window['Y'] + 21) * scale))
move(*away); time.sleep(.25)
idle = raw_frame(name + '-Idle')
start = len(read_log())
captures = []
def progress_values():
return [int(match[field]) for match in CHROME.finditer(read_log()[start:]) if int(match[1]) == window['Id']]
def wait_progress(predicate, description):
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
values = progress_values()
if values and predicate(values): return values[-1]
time.sleep(.002)
raise AssertionError((name, description, progress_values()))
def paused_raw(label):
# A 3840x2160 PPM writes ~24 MiB. Even without PNG compression its
# disk/QMP round trip can outlast the remaining reverse animation.
# Freeze the *already presented real frame* only for inspection;
# no guest clock or animation state is changed by this harness.
before_stop = time.monotonic(); qmp('stop'); stopped = time.monotonic()
try:
values = progress_values()
path = raw_frame(label)
captured = time.monotonic()
finally:
qmp('cont')
captures.append({'Name': label, 'RenderedProgressPermilleBeforeCapture': values[-1] if values else None,
'StopRoundTripMilliseconds': (stopped - before_stop) * 1000,
'PpmWriteMilliseconds': (captured - stopped) * 1000,
'InspectionPauseMilliseconds': (time.monotonic() - stopped) * 1000,
'NativePpmBytes': path.stat().st_size})
return path
move(*hover)
wait_progress(lambda values: 100 <= values[-1] <= 450, 'No real entering intermediate frame')
entering = paused_raw(name + '-Entering-Mid')
# Prepare a longer but unfinished entering phase before reversing.
# The sampled entering frame and reverse request need not share one
# short 55 ms interval while a full-resolution PPM is still writing.
reverse_requested_from = wait_progress(lambda values: 500 <= values[-1] <= 850,
'No unfinished entering phase before reversal')
move(*away)
def descending_non_symmetric(values):
if len(values) < 2 or not 0 < values[-1] < values[-2] < 1000: return False
# X is rotationally symmetric at progress 0, 500 and 1000. Select
# an actual descending frame away from those visually equal poses.
return 50 <= values[-1] <= 950 and (field != 3 or not 420 <= values[-1] <= 580)
wait_progress(descending_non_symmetric, 'No real descending intermediate frame')
reversing = paused_raw(name + '-Reversing-Mid')
time.sleep(.25)
restored = raw_frame(name + '-Restored')
move(*hover); time.sleep(.25)
completed = raw_frame(name + '-Completed')
move(*away); time.sleep(.25)
paths = (idle, entering, reversing, restored, completed)
frames = [open_raw(path).crop(roi) for path in paths]
def difference(first, last):
return sum(max(pixel) > 3 for pixel in ImageChops.difference(first, last).getdata())
entered = difference(frames[0], frames[1]); reversed_mid = difference(frames[0], frames[2])
mid_to_end = difference(frames[1], frames[4]); direction_change = difference(frames[1], frames[2])
restored_difference = difference(frames[0], frames[3])
values = []
for match in CHROME.finditer(read_log()[start:]):
if int(match[1]) == window['Id']: values.append(int(match[field]))
intermediate = [value for value in values if 0 < value < 1000]
descending = any(0 < last < first < 1000 for first, last in zip(values, values[1:]))
assert entered > 3 and reversed_mid > 3 and mid_to_end > 3 and direction_change > 3, (name, 'missing real intermediate/reverse pixels', entered, reversed_mid, mid_to_end, direction_change)
assert restored_difference == 0 and intermediate and descending, (name, 'animation jumped or failed to reverse smoothly', restored_difference, values)
return {'EnteringChangedPixels': entered, 'ReverseIntermediateChangedPixels': reversed_mid,
'EnteringToEndChangedPixels': mid_to_end, 'EnteringToReverseChangedPixels': direction_change,
'RestoredDifferencePixels': restored_difference, 'ProgressPermille': values,
'HasDescendingIntermediate': descending, 'RawCaptureBeforePngCompression': True,
'CaptureSelection': 'Actual presented intermediate/descending Chrome telemetry; inspection-only QMP pause during native PPM write',
'ReverseRequestedFromPermille': reverse_requested_from, 'IntermediateCaptures': captures,
'Screenshots': [path.with_suffix('.png').name for path in paths]}
visual = {}
# Close only windows in this test guest; the existing user-owned VM is never addressed.
for _ in range(15): tap('esc')
if desktop_v4:
move(round(native_width / scale) - 35, 80)
desktop_frame = capture('Desktop-No-Topbar-Native')
top_pixel = desktop_frame.getpixel((native_width // 2, round(15 * scale)))
empty_pixel = desktop_frame.getpixel((native_width - round(140 * scale), round(70 * scale)))
# The wallpaper now has a vertical gradient. Verify horizontal
# continuity and the absence of a sharp header edge, rather than
# requiring two different heights to have the old flat color.
strip = [(desktop_frame.getpixel((native_width // 2, round(y * scale))),
desktop_frame.getpixel((native_width - round(140 * scale), round(y * scale))))
for y in range(15, 71)]
assert all(left == right for left, right in strip), 'Unexpected top-strip chrome or text over the wallpaper'
maximum_step = max(abs(strip[index][0][channel] - strip[index - 1][0][channel])
for index in range(1, len(strip)) for channel in range(3))
assert maximum_step <= 1, ('A sharp system top-bar edge remains in the gradient', maximum_step)
visual['NoTopbar'] = {'NativeTopPixel': top_pixel, 'NativeDesktopPixel': empty_pixel,
'HorizontalContinuity': True, 'MaximumVerticalChannelStep': maximum_step}
checks.append('desktop top is clear wallpaper with no system top bar')
start = len(read_log())
visual['DesktopButton'] = assert_hover_press('Desktop-Files-Button', (22, 84, 100, 74), (30, 108))
files = wait_state(start, lambda state: state['Application'] == 'Files' and not state['Minimized'], 'Desktop button opens Files')
checks.append('desktop button hover and press change real native pixels outside the cursor')
original = files.copy()
start = len(read_log())
double_click(files['X'] + files['Width'] // 2, files['Y'] + 16)
maximized = wait_state(start, lambda state: state['Id'] == files['Id'] and state['Maximized'], 'Title double-click maximizes')
assert (maximized['X'], maximized['Y'], maximized['Width'], maximized['Height']) == (0, 0 if desktop_v4 else 42, round(native_width / scale), round(native_height / scale) - (48 if desktop_v4 else 90)), maximized
checks.append('title double-click maximizes to work area without covering taskbar')
screenshot('Window-Maximized')
start = len(read_log())
double_click(maximized['Width'] // 2, maximized['Y'] + 16)
files = wait_state(start, lambda state: state['Id'] == files['Id'] and not state['Maximized'], 'Title double-click restores')
assert all(files[field] == original[field] for field in ('X', 'Y', 'Width', 'Height')), (original, files)
checks.append('second title double-click restores exact bounds')
start = len(read_log())
drag(files['X'] + 110, files['Y'] + 16, files['X'] + 150, files['Y'] + 46)
files = wait_state(start, lambda state: state['Id'] == files['Id'] and state['X'] == original['X'] + 40 and state['Y'] == original['Y'] + 30, 'Title drag follows pointer')
checks.append('title drag moves the window while preserving size')
before_resize = files.copy(); start = len(read_log())
drag(files['X'] + files['Width'] - 9, files['Y'] + files['Height'] - 9,
files['X'] + files['Width'] + 71, files['Y'] + files['Height'] + 41)
expected_width = min(before_resize['Width'] + 80, round(native_width / scale) - before_resize['X'])
expected_height = min(before_resize['Height'] + 50, round(native_height / scale) - 48 - before_resize['Y'])
files = wait_state(start, lambda state: state['Id'] == files['Id'] and state['Width'] == expected_width and state['Height'] == expected_height, 'Bottom-right resize')
checks.append('bottom-right drag resizes with work-area constraints')
screenshot('Window-Resized')
if desktop_v4:
visual['CaptionMotion'] = {'Minimize': caption_motion(files, 'Caption-Minimize', 48, 2),
'Close': caption_motion(files, 'Caption-Close', 16, 3)}
checks.append('minimize hover shows a real intermediate rotation and reverses continuously before settling')
checks.append('close hover captures intermediate X rotation and continuous reverse before its symmetric endpoint')
visual['FilesToolbar'] = assert_hover_press('Files-Up-Button', (files['X'] + 6, files['Y'] + 36, 54, 26), (files['X'] + 8, files['Y'] + 38))
checks.append('Files toolbar hover and press change real native pixels outside the cursor')
move(round(native_width / scale) - 35, 80)
caption_frame = capture('Window-Caption-TwoButtons')
title_color = caption_frame.getpixel((round((files['X'] + files['Width'] // 2 + 50) * scale), round((files['Y'] + 14) * scale)))
old_square = caption_frame.crop((round((files['X'] + files['Width'] - 57) * scale), round((files['Y'] + 9) * scale),
round((files['X'] + files['Width'] - 37) * scale), round((files['Y'] + (13 if desktop_v4 else 17)) * scale)))
former_square_pixels = sum(max(abs(pixel[channel] - title_color[channel]) for channel in range(3)) > 3 for pixel in old_square.getdata())
assert former_square_pixels == 0, ('A maximize-square glyph remains above the new minimize line', former_square_pixels)
corner_box = (round(files['X'] * scale), round(files['Y'] * scale), round((files['X'] + 12) * scale), round((files['Y'] + 12) * scale))
corner_colors = len(set(caption_frame.crop(corner_box).getdata()))
assert corner_colors >= 8, ('Window corner does not expose blended antialiasing coverage at native resolution', corner_colors)
visual['Caption'] = {'FormerMaximizeSquarePixels': former_square_pixels, 'NativeCornerDistinctColors': corner_colors, 'NativeScreenshot': 'Window-Caption-TwoButtons.png'}
checks.append('caption shows minimize and close without the former maximize-square glyph')
checks.append('native rounded window corner contains blended antialiasing coverage colors')
taskbar_y = round(native_height / scale) - 24
start = len(read_log()); click(100, taskbar_y)
wait_state(start, lambda state: state['Id'] == files['Id'] and state['Minimized'], 'Taskbar minimizes active window')
start = len(read_log()); click(100, taskbar_y)
restored = wait_state(start, lambda state: state['Id'] == files['Id'] and not state['Minimized'], 'Taskbar restores window')
assert all(restored[field] == files[field] for field in ('X', 'Y', 'Width', 'Height')), (files, restored)
checks.append('taskbar minimizes and restores the same live window and bounds')
terminal = app_open('Terminal', 60, 402)
browser = app_open('Browser', 60, 308)
start = len(read_log()); key('alt', True)
for application in ('Terminal', 'Files', 'Browser', 'Terminal'):
step = len(read_log()); tap('tab'); wait_log('XFE_DESKTOP_ACTIVE|' + application, step)
key('alt', False)
checks.append('held Alt+Tab cycles through three windows repeatedly and commits on Alt release')
start = len(read_log()); key('alt', True); key('shift', True); tap('tab')
wait_log('XFE_DESKTOP_ACTIVE|Files', start)
key('shift', False); key('alt', False)
checks.append('Alt+Shift+Tab selects the previous window in MRU order')
screenshot('Window-AltTab-Committed')
start = len(read_log()); chord('meta_l', 'd')
for target in (files, terminal, browser):
wait_state(start, lambda state, identity=target['Id']: state['Id'] == identity and state['Minimized'], 'Raw Win+D hides each live window')
start = len(read_log()); chord('meta_l', 'd')
for target in (files, terminal, browser):
wait_state(start, lambda state, identity=target['Id']: state['Id'] == identity and not state['Minimized'], 'Raw Win+D restores each live window')
checks.append('raw Win+D toggles desktop without losing window state')
start = len(read_log()); tap('meta_l'); tap('ret')
opened = wait_state(start, lambda state: state['Application'] == 'Files' and state['Id'] > max(files['Id'], terminal['Id'], browser['Id']), 'Raw Win opens launcher; Enter opens Files')
checks.append('raw Win make/break opens the launcher and Enter starts its selected application')
start = len(read_log()); chord('alt', 'f4')
wait_log('XFE_DESKTOP_ACTIVE|Files', start)
checks.append('raw Alt+F4 closes the foreground launcher window and returns to previous window')
taskbar_frame = capture('Window-Taskbar-Final')
sample_x = native_width - round(140 * scale)
taskbar_pixel = taskbar_frame.getpixel((sample_x, native_height - round(8 * scale)))
desktop_pixel = taskbar_frame.getpixel((sample_x, native_height - round(60 * scale)))
assert taskbar_pixel != desktop_pixel, ('Native bottom taskbar band is absent', taskbar_pixel, desktop_pixel)
visual['NativeTaskbarBand'] = {'NativeBottomPixel': taskbar_pixel, 'NativeDesktopPixel': desktop_pixel, 'FrameSize': [native_width, native_height], 'HostViewportMeasured': False}
checks.append('native framebuffer includes the full bottom taskbar band at unchanged resolution')
def render_stats():
return parse_render_stats(read_log())
def next_stats(previous_timestamp=-1):
deadline = time.monotonic() + 6
while time.monotonic() < deadline:
values = render_stats()
if values and values[-1]['Timestamp'] > previous_timestamp: return values[-1]
time.sleep(.02)
raise AssertionError('Guest render statistics did not advance')
def measure(begin, end, duration, dispatch_count, raw_events_per_dispatch, host_sample_seconds):
elapsed = (end['Timestamp'] - begin['Timestamp']) / end['Frequency']
frames = end['Frames'] - begin['Frames']; pixels = end['Pixels'] - begin['Pixels']
assert elapsed > 0 and frames > 0, (begin, end)
assert end['Fallback'] - begin['Fallback'] == 0, (begin, end)
assert end['Partial'] - begin['Partial'] > 0, (begin, end)
active = []
observed = [value for value in render_stats() if begin['Timestamp'] <= value['Timestamp'] <= end['Timestamp']]
observed_gaps = [(last['Timestamp'] - first['Timestamp']) / last['Frequency'] for first, last in zip(observed, observed[1:])]
assert all(gap > 0 for gap in observed_gaps), ('Native statistics timestamps did not advance', observed)
maximum_observed_gap = max(observed_gaps, default=0)
values = [value for value in observed if value['Timestamp'] <= begin['Timestamp'] + round(duration * begin['Frequency'])]
for first, last in zip(values, values[1:]):
if first['Timestamp'] <= begin['Timestamp']: continue
interval = (last['Timestamp'] - first['Timestamp']) / last['Frequency']
count = last['Frames'] - first['Frames']
copied_pixels = last['Pixels'] - first['Pixels']
assert count >= 0 and copied_pixels >= 0 and (count > 0 or copied_pixels == 0), (first, last)
# A complete interval with no frame is observed stutter, and must
# remain in the distribution instead of inflating its median FPS.
active.append({'Seconds': interval, 'Frames': count, 'RendererFramesPerSecond': count / interval,
'AverageCopiedPixelsPerFrame': copied_pixels / count if count else 0,
'FullCopyFallbacks': last['Fallback'] - first['Fallback']})
assert active, ('No complete stable activity window was sampled',
{'CompleteRecordsIncludingBoundaries':len(observed),'MaximumObservedStatsGapSeconds':maximum_observed_gap,
'BeginTimestamp':begin['Timestamp'],'EndTimestamp':end['Timestamp'],'InputMotionSeconds':duration})
return {'SampleWindowSecondsIncludingIdle': elapsed, 'InputMotionSeconds': duration, 'FramesIncludingIdleWindow': frames,
'RendererFramesPerSecond': frames / elapsed, 'AverageCopiedPixelsPerFrame': pixels / frames,
'WholeWindowIncludesTrailingIdle': True, 'ActiveStableWindows': active,
'ActiveStableMedianRendererFramesPerSecond': statistics.median(value['RendererFramesPerSecond'] for value in active),
'StableWindowRatesIncludeZeroFrameIntervals': True, 'ZeroFrameStableWindows':sum(value['Frames']==0 for value in active),
'MaximumStableStatsGapSeconds':max(value['Seconds'] for value in active),
'MaximumObservedStatsGapSeconds':maximum_observed_gap,
'HostSampleWindowSecondsIncludingIdle': host_sample_seconds, 'HostWholeWindowRendererFramesPerSecond': frames / host_sample_seconds,
'HostInputDispatchesSent': dispatch_count, 'HostInputDispatchesPerSecond': dispatch_count / duration,
'HostRawInputEventsSent': dispatch_count * raw_events_per_dispatch,
'HostRawInputEventsPerSecond': dispatch_count * raw_events_per_dispatch / duration, 'HostInputDispatchRateRequested': input_rate,
'FullScreenPixels': native_width * native_height,
'PartialFrames': end['Partial'] - begin['Partial'], 'FullCopyFallbacks': end['Fallback'] - begin['Fallback'],
'MotionReuses': end.get('MotionReuses', 0) - begin.get('MotionReuses', 0),
'MotionPixels': end.get('MotionPixels', 0) - begin.get('MotionPixels', 0),
'LastRenderMilliseconds': end['LastRenderTicks'] * 1000 / end['Frequency'],
'TargetHz': target_hz, 'TargetMeaning': 'EDID renderer target; this is not a VSync or guaranteed presentation-rate measurement'}
def timed_input(send_event):
began = time.monotonic(); index = 0
while time.monotonic() - began < 3.1:
send_event(index); index += 1
due = began + index / input_rate
delay = due - time.monotonic()
if delay > 0: time.sleep(delay)
return time.monotonic() - began, index
# The right side is unobstructed at both supported 1280/800 and 4K/200% layouts.
move(round(native_width / scale) - 35, 80)
baseline_stats = next_stats(render_stats()[-1]['Timestamp'] if render_stats() else -1)
baseline_wall = time.monotonic()
def cursor_event(index):
dx = 2 if (index // 12) % 2 == 0 else -2
qmp('input-send-event', {'events': [{'type': 'rel', 'data': {'axis': 'x', 'value': dx}}]})
pointer[0] += dx
duration, events = timed_input(cursor_event)
end_stats = next_stats(baseline_stats['Timestamp'] + round(duration * baseline_stats['Frequency']))
cursor_stats = measure(baseline_stats, end_stats, duration, events, 1, time.monotonic() - baseline_wall)
assert cursor_stats['AverageCopiedPixelsPerFrame'] < native_width * native_height / 10, cursor_stats
cursor_position = (round(native_width / scale) - 35, 80)
move(cursor_position[0], cursor_position[1] + 70)
arrow_background = capture('Cursor-Arrow-Background')
move(*cursor_position)
arrow_frame = capture('Cursor-Arrow-Native')
cx, cy = (round(value * scale) for value in cursor_position)
radius = round(25 * scale)
arrow_box = (cx - radius, cy - radius, min(native_width, cx + radius + 1), cy + radius + 1)
before_pixels = arrow_background.crop(arrow_box).getdata()
arrow_pixels = arrow_frame.crop(arrow_box).getdata()
colored = [pixel for old, pixel in zip(before_pixels, arrow_pixels) if max(abs(pixel[channel] - old[channel]) for channel in range(3)) > 3]
distinct_colors = len(set(colored))
blue_core_pixels = sum(pixel[2] - pixel[0] > 25 and pixel[2] - pixel[1] > 12 for pixel in colored)
assert distinct_colors >= 16 and blue_core_pixels >= 12, ('Arrow needs continuous antialiased blue core and blended halo', distinct_colors, blue_core_pixels)
visual['ArrowCursor'] = {'ChangedNativePixels': len(colored), 'DistinctBlendedColors': distinct_colors, 'BlueCorePixels': blue_core_pixels, 'NativeScreenshot': 'Cursor-Arrow-Native.png'}
checks.append('native arrow cursor retains a blue core and at least sixteen blended edge/halo colors')
checks.append('cursor-only native-resolution movement uses damaged copy with zero full-copy fallback')
editor = app_open('Editor', 60, 214)
# Every line differs; saving its exact length confirms that burst key events were consumed.
source = ''.join('row%02d\n' % index for index in range(80))
for character in source:
code = 'ret' if character == '\n' else character
qmp('input-send-event', {'events': [
{'type': 'key', 'data': {'down': True, 'key': {'type': 'qcode', 'data': code}}},
{'type': 'key', 'data': {'down': False, 'key': {'type': 'qcode', 'data': code}}}]})
time.sleep(.012)
start = len(read_log()); chord('ctrl', 's')
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if re.search(r'^XFE_DESKTOP_SAVED\|[^\r\n]+\|' + str(len(source)) + r'$', read_log()[start:], re.M): break
time.sleep(.04)
else: raise AssertionError('The scroll-test document did not save its expected length')
move(editor['X'] + 150, editor['Y'] + 150)
screenshot('Cursor-Text-Native')
baseline_stats = next_stats(render_stats()[-1]['Timestamp'])
baseline_wall = time.monotonic()
def scroll_event(index):
wheel = 'wheel-up' if (index // 4) % 2 == 0 else 'wheel-down'
qmp('input-send-event', {'events': [
{'type': 'btn', 'data': {'down': True, 'button': wheel}},
{'type': 'btn', 'data': {'down': False, 'button': wheel}}]})
duration, events = timed_input(scroll_event)
end_stats = next_stats(baseline_stats['Timestamp'] + round(duration * baseline_stats['Frequency']))
scroll_stats = measure(baseline_stats, end_stats, duration, events, 2, time.monotonic() - baseline_wall)
screenshot('Scroll-LongDocument-Native')
checks.append('long-document wheel scrolling retains partial scanout and zero full-copy fallback')
move(editor['X'] + editor['Width'] - 9, editor['Y'] + editor['Height'] - 9)
screenshot('Cursor-Resize-Native')
settings_point = (50, 492) if desktop_v4 else (round(native_width / scale) - 60, 22)
settings = app_open('Settings', *settings_point)
# Fresh desktops now start dark. Make the following Dark click an actual
# transition so the existing state/hover/pixel checks still verify a change.
preparation_start = len(read_log())
click(settings['X'] + 28, settings['Y'] + 93)
wait_log('XFE_DESKTOP_APPEARANCE|Light|657DE2|Persistent|0', preparation_start)
dark_start = len(read_log())
visual['SettingsDarkButton'] = assert_hover_press('Settings-Dark-Button',
(settings['X'] + 140, settings['Y'] + 88, 104, 34), (settings['X'] + 144, settings['Y'] + 93))
wait_log('XFE_DESKTOP_APPEARANCE|Dark|657DE2|Persistent|0', dark_start)
move(round(native_width / scale) - 35, 80)
dark_frame = capture('Settings-Dark-Native')
corner = (native_width - round(140 * scale), native_height - round(60 * scale))
dark_pixel = dark_frame.getpixel(corner)
assert sum(dark_pixel) / 3 < 100, ('Dark theme did not darken the native desktop', dark_pixel)
checks.append('Settings Dark button hover/press and native dark desktop pixels agree with guest appearance state')
light_start = len(read_log())
visual['SettingsLightButton'] = assert_hover_press('Settings-Light-Button',
(settings['X'] + 24, settings['Y'] + 88, 104, 34), (settings['X'] + 28, settings['Y'] + 93))
wait_log('XFE_DESKTOP_APPEARANCE|Light|657DE2|Persistent|0', light_start)
move(round(native_width / scale) - 35, 80)
light_frame = capture('Settings-Light-Native')
light_pixel = light_frame.getpixel(corner)
assert sum(light_pixel) / 3 > 200 and light_pixel != dark_pixel, ('Light theme did not restore the native desktop', light_pixel, dark_pixel)
checks.append('Settings Light button hover/press restores a visibly light native desktop')
visual['SettingsAccentField'] = assert_hover_press('Settings-Accent-Field',
(settings['X'] + 24, settings['Y'] + 202, 220, 34), (settings['X'] + 28, settings['Y'] + 207))
custom_start = len(read_log())
chord('shift', '3')
for code in '14a8b4': tap(code)
tap('ret')
wait_log('XFE_DESKTOP_APPEARANCE|Light|14A8B4|Persistent|0', custom_start)
move(round(native_width / scale) - 35, 80)
accent_frame = capture('Settings-Custom-Accent-Native')
accent_pixels = changed_pixels(light_frame, accent_frame, (settings['X'] + 20, settings['Y'] + 38, 280, 25) if desktop_v4 else (45, 8, 250, 28))
assert accent_pixels > 20, ('Guest accent state changed without redrawing real title ink', accent_pixels)
visual['Appearance'] = {'DarkDesktopPixel': dark_pixel, 'LightDesktopPixel': light_pixel,
'CustomAccent': '#14A8B4', 'ChangedAccentNativePixels' if desktop_v4 else 'ChangedHeaderNativePixels': accent_pixels, 'Persistent': False}
checks.append('Settings accent field hover/press and custom RGB keyboard input recolor real native title pixels')
invalid_start = len(read_log())
click(settings['X'] + 28, settings['Y'] + 207)
chord('shift', '3'); tap('1'); tap('2'); tap('ret')
capture('Settings-Invalid-Accent-Native')
latest = APPEARANCE.findall(read_log())[-1]
assert latest == ('Light', '14A8B4', '0'), ('Invalid short accent changed the actual appearance', latest)
assert all(match == latest for match in APPEARANCE.findall(read_log()[invalid_start:])), 'Invalid accent emitted a different appearance'
checks.append('invalid short accent is rejected while preserving the prior appearance')
reset_start = len(read_log()); click(settings['X'] + 44, settings['Y'] + 165)
wait_log('XFE_DESKTOP_APPEARANCE|Light|657DE2|Persistent|0', reset_start)
chord('alt', 'f4')
settings = app_open('Settings', *settings_point)
capture('Settings-Ram-Appearance-Reopened')
assert APPEARANCE.findall(read_log())[-1] == ('Light', '657DE2', '0')
checks.append('Blue preset restores the original accent and reopening Settings retains the RAM session appearance')
performance = {'Cursor': cursor_stats, 'Scroll': scroll_stats}
if desktop_v4:
chord('alt', 'f4')
step = len(read_log()); click(100, round(native_height / scale) - 24)
files = wait_state(step, lambda state: state['Id'] == files['Id'] and not state['Minimized'], 'Activate Files for native motion')
for kind in ('Drag', 'Resize'):
if kind == 'Drag': move(files['X'] + 120, files['Y'] + 16)
else: move(files['X'] + files['Width'] - 9, files['Y'] + files['Height'] - 9)
button(True)
baseline_stats = next_stats(render_stats()[-1]['Timestamp'])
baseline_wall = time.monotonic()
def motion_event(index):
delta = 2 if (index // 12) % 2 == 0 else -2
events = [{'type': 'rel', 'data': {'axis': 'x', 'value': delta}}]
pointer[0] += delta
if kind == 'Resize':
events.append({'type': 'rel', 'data': {'axis': 'y', 'value': delta}}); pointer[1] += delta
qmp('input-send-event', {'events': events})
duration, events = timed_input(motion_event)
end_stats = next_stats(baseline_stats['Timestamp'] + round(duration * baseline_stats['Frequency']))
performance[kind] = measure(baseline_stats, end_stats, duration, events, 1 if kind == 'Drag' else 2, time.monotonic() - baseline_wall)
step = len(read_log()); button(False)
# At dense input the bounded guest drain can publish intermediate
# bounds while the actual release is still queued. Correlate with
# its real dispatch acknowledgement before reading final bounds;
# do not treat any same-window report as a completed gesture.
release_deadline = time.monotonic() + 10
while time.monotonic() < release_deadline:
release = re.search(r'^XFE_NATIVE_POINTER\|[^\r\n]*\|Left\|0\|[^\r\n]*\r?\n', read_log()[step:], re.M)
if release:
released_at = step + release.end()
break
time.sleep(.01)
else:
raise AssertionError(kind + ': actual native pointer release was not acknowledged')
files = wait_state(released_at, lambda state: state['Id'] == files['Id'] and not state['Minimized'], 'Native motion settles after release')
if kind == 'Drag': assert performance[kind]['MotionReuses'] > 0 and performance[kind]['MotionPixels'] > 0, 'Native pointer-free body reuse was not exercised'
move(round(native_width / scale) - 35, 80); time.sleep(.3)
partial_frame = capture('Window-' + kind + '-Settled')
restore_start = len(read_log()); chord('meta_l', 'd'); chord('meta_l', 'd')
restored_state = wait_state(restore_start, lambda state: state['Id'] == files['Id'] and not state['Minimized'], 'Win+D restores motion state for full redraw comparison')
assert all(restored_state[key] == files[key] for key in ('X', 'Y', 'Width', 'Height', 'Maximized', 'Snapped')), (kind, files, restored_state)
time.sleep(.3)
redrawn_frame = capture('Window-' + kind + '-FullRedraw')
residue = sum(any(pixel) for pixel in ImageChops.difference(partial_frame, redrawn_frame).getdata())
assert residue == 0, (kind, 'motion left pixels differing from forced full redraw', residue)
visual[kind + 'Motion'] = {'FullRedrawDifferencePixels': residue, 'SameWindowBounds': True, 'HostViewportMeasured': False}
checks.append('native ' + kind.lower() + ' motion is measured at dense input and settles without any residue against full redraw')
return {'Checks': checks, 'Performance': performance, 'VisualEvidence': visual,
'PerformanceGoalHz': target_hz, 'VSyncConfirmed': False}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--resolution', default='0x0')
parser.add_argument('--edid-resolution', default='3840x2160')
parser.add_argument('--refresh-hint', type=int, default=240)
parser.add_argument('--accel', choices=['tcg', 'whpx'], default='tcg')
parser.add_argument('--input-rate', type=int, default=500)
parser.add_argument('--desktop-ui-only', action='store_true')
parser.add_argument('--desktop-v4', action='store_true')
parser.add_argument('--verified-image')
parser.add_argument('--verified-boot-result')
options = parser.parse_args()
test = pathlib.Path(__file__).resolve().parent / 'CSharpBoot/TestBoot.py'
arguments = [sys.executable, str(test), '--desktop-ui', '--resolution', options.resolution,
'--edid-resolution', options.edid_resolution, '--refresh-hint', str(options.refresh_hint), '--video-memory-mib', '64',
'--accel', options.accel, '--input-rate', str(options.input_rate)]
if options.verified_image: arguments += ['--verified-image', options.verified_image]
if options.verified_boot_result: arguments += ['--verified-boot-result', options.verified_boot_result]
if options.desktop_ui_only: arguments += ['--desktop-ui-only']
if options.desktop_v4: arguments += ['--desktop-v4']
return subprocess.call(arguments)
if __name__ == '__main__': raise SystemExit(main())