3434 * Requires macOS with Screen-Recording AND Accessibility granted to the
3535 driving app; SKIPS (never fails, never fabricates) when input cannot be
3636 delivered — a dropped synthetic click must never look like success.
37- * SNAPSHOT-FIRST, REVERT-AFTER; NEVER deletes the VM or ANY snapshot.
38- * Requires the Parallels VM window to be open + resumable; SKIPS otherwise.
39-
40- Env overrides: ``OAFLOW_PARALLELS_VM_UUID``, ``OAFLOW_CITRIX_WINDOW_TITLE``
41- (default "Windows 11").
37+ * Requires one exact preserved base snapshot to be current before mutation.
38+ * Creates one exact owned snapshot, restores the base, verifies it, and
39+ deletes only that owned snapshot. A cleanup failure fails the proof.
40+ * Requires the Parallels VM window to be open + resumable.
41+
42+ Env overrides: ``OAFLOW_PARALLELS_VM_UUID``,
43+ ``OAFLOW_PARALLELS_BASE_SNAPSHOT_ID``, ``OAFLOW_PARALLELS_STORAGE_PATH``, and
44+ ``OAFLOW_CITRIX_WINDOW_TITLE`` (default "Windows 11").
4245"""
4346
4447from __future__ import annotations
6366
6467_SCRIPT_DIR = Path (__file__ ).resolve ().parents [2 ] / "scripts" / "desktop"
6568WINDOW_TITLE = os .environ .get ("OAFLOW_CITRIX_WINDOW_TITLE" , "Windows 11" )
69+ BASE_SNAPSHOT_ENV = "OAFLOW_PARALLELS_BASE_SNAPSHOT_ID"
70+ HOST_STORAGE_PATH_ENV = "OAFLOW_PARALLELS_STORAGE_PATH"
6671
6772
6873# -- environment guards (skip cleanly, never fail spuriously) ----------------
@@ -141,6 +146,52 @@ def _db_get(vm, pid: int) -> dict:
141146 return json .loads ((r .stdout or "null" ).strip () or "null" )
142147
143148
149+ def _restore_pixel_vm (
150+ vm ,
151+ * ,
152+ base_snapshot_id : str ,
153+ owned_snapshot_id : str | None ,
154+ vm_touched : bool ,
155+ ) -> None :
156+ """Restore the exact base, delete only the owned snapshot, and suspend."""
157+
158+ from openadapt_flow .backends .parallels_vm import ParallelsError
159+
160+ if owned_snapshot_id is None and not vm_touched :
161+ return
162+ cleanup_errors : list [Exception ] = []
163+ if owned_snapshot_id is not None :
164+ try :
165+ vm .restore_base_and_delete_owned_snapshot (
166+ base_snapshot_id = base_snapshot_id ,
167+ owned_snapshot_id = owned_snapshot_id ,
168+ )
169+ except Exception as exc : # noqa: BLE001
170+ cleanup_errors .append (exc )
171+ elif vm_touched :
172+ try :
173+ vm .revert (base_snapshot_id )
174+ except Exception as exc : # noqa: BLE001
175+ cleanup_errors .append (exc )
176+ try :
177+ state = vm .status ()
178+ if state in {"running" , "paused" }:
179+ vm .suspend ()
180+ state = vm .status ()
181+ if state != "suspended" :
182+ raise ParallelsError (
183+ f"preserved base did not finish suspended (state={ state !r} )"
184+ )
185+ vm .require_current_snapshot (base_snapshot_id )
186+ except Exception as exc : # noqa: BLE001
187+ cleanup_errors .append (exc )
188+ if cleanup_errors :
189+ details = "; " .join (repr (error ) for error in cleanup_errors )
190+ raise RuntimeError (
191+ f"pixel proof failed to restore its exact base: { details } "
192+ ) from cleanup_errors [0 ]
193+
194+
144195# -- the proof ---------------------------------------------------------------
145196
146197
@@ -151,7 +202,10 @@ def test_citrix_pixel_only_record_replay_identity_verify_halt(tmp_path) -> None:
151202 record_desktop_demo ,
152203 structural_armed_coverage ,
153204 )
154- from openadapt_flow .backends .parallels_vm import DEFAULT_VM_UUID , ParallelsVM
205+ from openadapt_flow .backends .parallels_vm import (
206+ DEFAULT_VM_UUID ,
207+ ParallelsVM ,
208+ )
155209 from openadapt_flow .backends .remote_display import (
156210 RemoteDisplayBackend ,
157211 RemoteDisplayError ,
@@ -162,29 +216,46 @@ def test_citrix_pixel_only_record_replay_identity_verify_halt(tmp_path) -> None:
162216 from openadapt_flow .runtime .effects .onscreen import OnScreenReadbackVerifier
163217
164218 uuid = os .environ .get ("OAFLOW_PARALLELS_VM_UUID" , DEFAULT_VM_UUID )
219+ base_snapshot_id = os .environ .get (BASE_SNAPSHOT_ENV )
220+ if not base_snapshot_id :
221+ raise RuntimeError (
222+ f"{ BASE_SNAPSHOT_ENV } is required for snapshot-safe qualification"
223+ )
165224 vm = ParallelsVM (uuid )
166-
167- # The VM must already be running (Parallels Standard cannot prlctl-start; a
168- # suspended VM is resumed by the operator or the GUI play button). Skip
169- # cleanly rather than fail if it is not reachable.
170- if vm .status () != "running" or not _guest_ready (vm ):
171- pytest .skip ("Parallels VM not running / guest tools unreachable" )
172-
173- backend = RemoteDisplayBackend (
174- owner_substr = "Parallels Desktop" , title_substr = WINDOW_TITLE
175- )
225+ snap_id : str | None = None
226+ vm_touched = False
227+ active_error : BaseException | None = None
176228 try :
177- backend .ensure_foreground ()
178- except RemoteDisplayError as e :
179- pytest .skip (f"remote-display client window not foregroundable: { e } " )
229+ storage_path = os .environ .get (HOST_STORAGE_PATH_ENV , os .getcwd ())
230+ vm .require_host_free_space (storage_path = storage_path )
231+ # This read-only check must pass before the first mutation. Reverting
232+ # the same current id then binds the live working state to that exact
233+ # base before resume, focus, snapshot, deployment, or actuation.
234+ vm .require_current_snapshot (base_snapshot_id )
235+ vm_touched = True
236+ vm .revert (base_snapshot_id )
237+ vm .require_current_snapshot (base_snapshot_id )
238+ vm .ensure_running ()
239+ if vm .status () != "running" or not _guest_ready (vm ):
240+ raise RuntimeError ("Parallels VM or guest tools did not become ready" )
241+
242+ backend = RemoteDisplayBackend (
243+ owner_substr = "Parallels Desktop" , title_substr = WINDOW_TITLE
244+ )
245+ try :
246+ backend .ensure_foreground ()
247+ except RemoteDisplayError as exc :
248+ raise RuntimeError (
249+ "remote-display client window is not foregroundable"
250+ ) from exc
251+
252+ snap_id = vm .snapshot (
253+ f"oaflow-citrix-{ int (time .time ())} " , description = "citrix pixel e2e"
254+ )
255+ # A snapshot on a running VM briefly disturbs guest tools.
256+ if not _guest_ready (vm ):
257+ raise RuntimeError ("guest tools did not recover after pixel snapshot" )
180258
181- # SNAPSHOT FIRST — reverted in finally (never deleted).
182- snap_id = vm .snapshot (
183- f"oaflow-citrix-{ int (time .time ())} " , description = "citrix pixel e2e"
184- )
185- # A snapshot on a running VM briefly disturbs guest tools; wait for it back.
186- _guest_ready (vm )
187- try :
188259 # ---- deploy + launch the stand-in clinical app (pixel target) ------
189260 _deploy_and_launch (vm , drift = "none" )
190261 backend .ensure_foreground ()
@@ -323,8 +394,18 @@ def driver(rec) -> None:
323394 assert not amb_report .success , (
324395 "run must HALT under render drift rather than click a guessed target"
325396 )
397+ except BaseException as exc :
398+ active_error = exc
399+ raise
326400 finally :
327401 try :
328- vm .revert (snap_id ) # never delete — revert only
329- except Exception as e : # noqa: BLE001
330- print (f"[citrix] WARNING: revert to { snap_id } failed: { e } " )
402+ _restore_pixel_vm (
403+ vm ,
404+ base_snapshot_id = base_snapshot_id ,
405+ owned_snapshot_id = snap_id ,
406+ vm_touched = vm_touched ,
407+ )
408+ except Exception as cleanup_error :
409+ if active_error is None :
410+ raise
411+ active_error .add_note (f"cleanup error: { cleanup_error !r} " )
0 commit comments