Skip to repository content2143 lines · 91.0 KB · text
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-27T23:37:30.964Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
collect-omega-installed-observations
1#!/usr/bin/env python3
2"""Collect the installed Omega observations the RC proof requires.
3
4Eleven observations: three manual-journey checks and eight accessibility
5checks. Each one is *performed* here, not asserted. A check that cannot be
6performed on this host records `blocked` and the run refuses, because an
7observation nobody made is worse than a missing one — it looks like evidence.
8
9There is exactly one alternative to performing a check, and it is not a pass.
10A check the owner has directed us not to build for may record `waived`, which
11carries the owner's own words, the date of the direction, and the upstream
12parity the direction is conditioned on. `waived` never counts as `passed`, it
13never rolls up into a green status, and the report status says
14`passed_with_waivers` so nobody can read the summary and believe the
15observation happened. `WAIVABLE_CHECKS` in `omega_identity_evidence` keeps that
16door as narrow as the thing that is genuinely unobservable: a 360-pixel
17viewport and a larger UI font are ordinary rendering conditions and are
18performed here, not waived.
19
20Two checks depend on a system flag only the owner can set: increased contrast
21and reduced motion live in a domain `defaults write` is refused on. The flag is
22their precondition and never their observation. With increased contrast on, the
23window is read for legible text and the focus indicator is moved and watched;
24with reduced motion on, a journey is completed by keyboard and undone by menu,
25and every state it passes through is read from a still frame. Recording either
26as a pass because a preference is set would state a fact about the host and
27call it a fact about the product.
28
29The screen-reader check reads the macOS accessibility tree through
30`AXUIElement`, which is the exact surface a screen reader consumes. That is a
31real observation of what assistive technology would receive. It is not a claim
32that a person listened to speech, and the recorded `assistive_technology` names
33the interface used so nobody can mistake it for one.
34
35Usage:
36 collect-omega-installed-observations \\
37 --app /Applications/Omega.app --candidate-evidence <path> \\
38 --evidence-root <dir> --output <path>
39"""
40
41from __future__ import annotations
42
43import argparse
44import hashlib
45import json
46import os
47import re
48import subprocess
49import sys
50import time
51from datetime import datetime, timezone
52from pathlib import Path
53from typing import Any
54
55sys.path.insert(0, str(Path(__file__).resolve().parent))
56
57from omega_identity_evidence import ( # noqa: E402
58 ACCESSIBILITY_CHECKS,
59 INSTALLED_OBSERVATIONS_SCHEMA,
60 MANUAL_JOURNEY_CHECKS,
61 WAIVABLE_CHECKS,
62 IdentityEvidenceError,
63 validate_observation_facts,
64)
65
66OBSERVATIONS_SCHEMA = INSTALLED_OBSERVATIONS_SCHEMA
67
68MANUAL_JOURNEY = sorted(MANUAL_JOURNEY_CHECKS)
69ACCESSIBILITY = sorted(ACCESSIBILITY_CHECKS)
70
71ZED_DATA = Path.home() / "Library" / "Application Support" / "Zed"
72
73#: The application process name as macOS reports it to System Events.
74PROCESS = "omega"
75
76#: The exact process this run observes, set from `--pid`.
77#:
78#: A name is not an identity. This host routinely runs the installed candidate,
79#: a second copy under an emulated profile, and a development build, and macOS
80#: reports all three as `omega`. `tell process "omega"` addresses whichever one
81#: System Events happens to return first, so a run that targets by name can
82#: capture one application while it drives another and never notice. Every
83#: System Events call below addresses this identifier when it is set.
84PROCESS_PID: int | None = None
85
86#: The window-server identifier of the window this run observes, set from
87#: `--window-id`.
88#:
89#: `screencapture -l` captures that window even when another application holds
90#: the active space, which is what makes an observation possible on a shared
91#: host without taking the screen away from whoever is using it.
92WINDOW_ID: int | None = None
93
94#: Path to the compiled `script/omega-window-ocr.swift`, set from `--ocr`.
95#:
96#: This reads the text a person actually sees, with the position of each line.
97#: It is how the two journey checks below are performed on an application that
98#: publishes no accessibility tree, and it is a stronger observation than the
99#: tree read it replaces: a label present in a tree but clipped off the screen
100#: passes an accessibility-tree read and fails this one.
101OCR_TOOL: Path | None = None
102
103#: Path to the compiled `script/omega-window-list.swift`, set from
104#: `--window-list`. Used to read the window frame that the accessibility tree
105#: does not publish.
106WINDOW_LIST_TOOL: Path | None = None
107
108#: Width in points that the 360-pixel viewport check drives the window to.
109NARROW_VIEWPORT_POINTS = 360
110
111#: `ui_font_size` the larger-UI-font check writes into the installed
112#: application's own settings file. The validator refuses anything under 18.
113LARGE_UI_FONT_PIXELS = 24
114
115#: A forward focus move has to repaint at least this many pixels before it
116#: counts as a visible indicator moving rather than a caret blinking.
117FOCUS_MOVE_MINIMUM_PIXELS = 2000
118
119#: A reverse move has to leave the window at least this many times closer to
120#: the earlier image than a forward move moved it away.
121FOCUS_RETURN_RATIO = 10
122
123#: How many lines of text have to be recognised off the rendered window before
124#: the increased-contrast render counts as legible, and the confidence below
125#: which a recognised line is not counted. Legibility is read from the product
126#: a person sees, not from a layout tree, so a render that is present but
127#: unreadable fails here.
128CONTRAST_LEGIBLE_LINES = 12
129CONTRAST_LEGIBLE_CONFIDENCE = 0.5
130
131#: How many lines of text have to be recognised off the rendered window in an
132#: appearance before that appearance counts as legible, and the confidence
133#: below which a recognised line is not counted. `content_legible` is a claim
134#: about what a person can read in that appearance, so it is read off the
135#: pixels exactly the way the increased-contrast claim is. It used to be a
136#: literal `True`.
137APPEARANCE_LEGIBLE_LINES = 12
138APPEARANCE_LEGIBLE_CONFIDENCE = 0.5
139
140#: How many pixels the light capture and the dark capture have to differ by
141#: before the pair counts as two appearances rather than one. A window that
142#: ignored the host, and a frozen or blank one, return nearly the same image
143#: twice and cannot clear this; a theme change repaints the background of every
144#: pane and clears it by orders of magnitude. Without this the two checks can
145#: be satisfied by a single unchanging picture taken twice.
146APPEARANCE_DIFFERENCE_PIXELS = 50_000
147
148#: How long the reduced-motion journey waits after each input before calling a
149#: state settled, and by how much the frame taken as soon as the harness could
150#: take one may differ from the settled frame. Stated openly: a state that is
151#: still moving when the person arrives differs between the two captures by a
152#: sizeable fraction of the change the step made, and fails.
153MOTION_SETTLED_SECONDS = 2.0
154MOTION_SETTLE_RATIO = 10
155
156#: The command the reduced-motion journey completes, the text that has to be
157#: legible on screen before it is completed, and the View-menu item that undoes
158#: it. The command is chosen because the candidate offers the same action by two
159#: independent routes, which is what lets the journey be completed by keyboard
160#: and checked by menu.
161MOTION_JOURNEY_COMMAND = "toggle right dock"
162MOTION_JOURNEY_MATCH = "workspace: toggle right dock"
163MOTION_JOURNEY_MENU_ITEM = "Toggle Right Dock"
164
165#: What a run says when an owner-only accessibility flag is off. `defaults
166#: write` cannot set these: macOS answers `Could not write domain`, so the
167#: window in which they can be observed exists only while a person has turned
168#: them on.
169OWNER_FLAG_OFF = (
170 "{check}: system flag {key} is off on this host. macOS privacy policy "
171 "protects that domain, so only the owner can turn it on, in System "
172 "Settings > Accessibility > Display."
173)
174
175#: Anything matching these in an accessibility value would be a secret reaching
176#: assistive technology, which is the failure the screen-reader check exists to
177#: detect.
178SECRET_SHAPES = [re.compile(p) for p in (r"nsec1", r"ncryptsec1", r"\b[0-9a-f]{64}\b")]
179
180#: Owner direction of 2026-07-25, quoted exactly as given. It is recorded
181#: verbatim, capitals and typing errors included, because a paraphrase of an
182#: owner direction is not the direction.
183OWNER_ACCESSIBILITY_DIRECTION = (
184 "IF ZED DOESNT HAVE IT WIRED UP, UYOU SHOULD NOT EXPECT US TO HAVE IT -- "
185 "REMOVE/CLOSE THAT ISSUE AS WONTDO SO WE CAN DO IT LATER, WE ARE NOT "
186 "ADDING ASSISTIVE TECHNOLOGY AT THIS TIME IF ZED DOESNT HAVE IT"
187)
188OWNER_DIRECTION_DATE = "2026-07-25T00:00:00Z"
189OWNER_DIRECTION_ISSUE = "https://github.com/OpenAgentsInc/omega/issues/71"
190
191#: Written by `observe-upstream-accessibility-parity`. The owner direction only
192#: permits an omission where Zed omits it too, so the parity has to be
193#: observed, never assumed.
194UPSTREAM_PARITY_SCHEMA = "openagents.omega.upstream-accessibility-parity.v1"
195
196#: macOS supplies these window-frame controls itself. A window whose only
197#: direct children are these publishes no application accessibility tree.
198WINDOW_FRAME_CONTROLS = {"close button", "full screen button", "minimize button"}
199
200
201class ObservationError(Exception):
202 pass
203
204
205def now() -> str:
206 return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
207
208
209def sha256_bytes(data: bytes) -> str:
210 return hashlib.sha256(data).hexdigest()
211
212
213def tree_digest(root: Path) -> str:
214 digest = hashlib.sha256()
215 for current, dirs, files in os.walk(root, followlinks=False):
216 dirs.sort()
217 files.sort()
218 for name in files:
219 path = Path(current) / name
220 try:
221 if path.is_symlink() or not path.is_file():
222 continue
223 digest.update(str(path.relative_to(root)).encode())
224 digest.update(hashlib.sha256(path.read_bytes()).digest())
225 except OSError:
226 continue
227 return digest.hexdigest()
228
229
230def osascript(script: str, timeout: int = 60) -> tuple[bool, str]:
231 try:
232 result = subprocess.run(
233 ["osascript", "-e", script], capture_output=True, text=True, timeout=timeout
234 )
235 except (OSError, subprocess.TimeoutExpired):
236 return False, ""
237 return result.returncode == 0, result.stdout.strip()
238
239
240def target() -> str:
241 """The System Events clause that addresses the application under test.
242
243 With `--pid` this is the one process with that identifier. Without it the
244 clause falls back to the process name, which is only safe on a host running
245 a single copy.
246 """
247 if PROCESS_PID is not None:
248 return f'(first process whose unix id is {PROCESS_PID})'
249 return f'process "{PROCESS}"'
250
251
252def run_tool(tool: Path, *arguments: str) -> str | None:
253 try:
254 result = subprocess.run(
255 [str(tool), *arguments], capture_output=True, text=True, timeout=120
256 )
257 except (OSError, subprocess.SubprocessError):
258 return None
259 if result.returncode != 0:
260 return None
261 return result.stdout
262
263
264def candidate_windows() -> list[dict[str, Any]]:
265 """Every window the window server attributes to the process under test."""
266 if WINDOW_LIST_TOOL is None or PROCESS_PID is None:
267 return []
268 output = run_tool(WINDOW_LIST_TOOL)
269 if output is None:
270 return []
271 try:
272 windows = json.loads(output)
273 except json.JSONDecodeError:
274 return []
275 return [
276 window
277 for window in windows
278 if window.get("pid") == PROCESS_PID and window.get("layer") == 0
279 ]
280
281
282#: Smallest window the visibility check will accept as the main window, in
283#: points. A GPUI application also owns menu-bar-height and offscreen helper
284#: windows, and capturing one of those instead of the product would be a
285#: capture of nothing.
286MAIN_WINDOW_MINIMUM_HEIGHT = 400
287
288
289def visible_window() -> tuple[int, int, int, int] | None:
290 """Frame of the candidate's main window, only while it is on screen.
291
292 Returns `None` when the window server says the window is not being
293 displayed. Every caller treats that as a capture that could not be made,
294 which is never a pass.
295 """
296 best: tuple[int, int, int, int] | None = None
297 best_area = 0
298 for window in candidate_windows():
299 if not window.get("onscreen"):
300 continue
301 bounds = window.get("bounds")
302 if not isinstance(bounds, dict):
303 continue
304 try:
305 left = int(bounds["X"])
306 top = int(bounds["Y"])
307 width = int(bounds["Width"])
308 height = int(bounds["Height"])
309 except (KeyError, TypeError, ValueError):
310 continue
311 if height < MAIN_WINDOW_MINIMUM_HEIGHT:
312 continue
313 if width * height > best_area:
314 best_area = width * height
315 best = (left, top, width, height)
316 return best
317
318
319def raise_window() -> None:
320 """Ask the candidate's own window to come to the top of the stack."""
321 osascript(
322 f"tell application \"System Events\" to tell {target()} "
323 'to perform action "AXRaise" of window 1'
324 )
325
326
327def ocr_lines(path: Path) -> list[dict[str, Any]] | None:
328 """The text of a capture, each line with its position down the window.
329
330 `None` means the read could not be made, which never becomes a pass.
331 """
332 if OCR_TOOL is None:
333 return None
334 output = run_tool(OCR_TOOL, str(path))
335 if output is None:
336 return None
337 try:
338 return json.loads(output).get("lines")
339 except (json.JSONDecodeError, AttributeError):
340 return None
341
342
343def confident_lines(
344 lines: list[dict[str, Any]] | None, minimum_confidence: float
345) -> list[dict[str, Any]] | None:
346 """The recognised lines a reader would actually be able to read.
347
348 `None` in means the read could not be made and `None` comes back out; a
349 read that could not be made never becomes a pass.
350 """
351 if lines is None:
352 return None
353 return [
354 line
355 for line in lines
356 if isinstance(line.get("text"), str)
357 and line["text"].strip()
358 and float(line.get("confidence", 0.0)) >= minimum_confidence
359 ]
360
361
362def appearance_observed(
363 name: str,
364 lines: list[dict[str, Any]] | None,
365 difference_pixels: int | None,
366) -> tuple[bool, str]:
367 """Whether one appearance was observed on the product, and why not if not.
368
369 Two facts, both read off the pixels. The window has to be legible in this
370 appearance, and the two appearances have to be different pictures. The host
371 being in the appearance is the precondition of the observation and is never
372 the observation: a frozen, blank, or appearance-ignoring window satisfies
373 the host and fails here.
374 """
375 if lines is None:
376 return False, (
377 f"{name}-theme: the rendered window could not be read, so its "
378 "legibility in this appearance was not observed"
379 )
380 if len(lines) < APPEARANCE_LEGIBLE_LINES:
381 return False, (
382 f"{name}-theme: legible_lines={len(lines)} "
383 f"(minimum {APPEARANCE_LEGIBLE_LINES})"
384 )
385 if difference_pixels is None:
386 return False, (
387 f"{name}-theme: the light and dark captures could not be compared, "
388 "so the window was not shown to follow the host appearance"
389 )
390 if difference_pixels < APPEARANCE_DIFFERENCE_PIXELS:
391 return False, (
392 f"{name}-theme: the light and dark captures differ by "
393 f"{difference_pixels} pixels (minimum {APPEARANCE_DIFFERENCE_PIXELS}), "
394 "so the window did not follow the host appearance"
395 )
396 return True, ""
397
398
399def first_position(lines: list[dict[str, Any]], phrase: str) -> float | None:
400 """How far down the window a phrase first appears, or `None` if absent."""
401 needle = phrase.casefold()
402 for line in lines:
403 text = line.get("text")
404 if isinstance(text, str) and needle in text.casefold():
405 return float(line.get("y", 1.0))
406 return None
407
408
409def screencapture(path: Path, region: tuple[int, int, int, int] | None = None) -> bool:
410 command = ["screencapture", "-x", "-o"]
411 if WINDOW_ID is not None:
412 # Capture the window itself. A region capture reads whatever the screen
413 # is showing, which on a shared host is whatever the person at the
414 # keyboard is doing, not the candidate.
415 command += ["-l", str(WINDOW_ID)]
416 elif region is not None:
417 command += ["-R", ",".join(str(value) for value in region)]
418 command.append(str(path))
419 try:
420 subprocess.run(command, check=True, timeout=60)
421 except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
422 return False
423 return path.is_file()
424
425
426#: Who performed this run, and in which emulated profile. Written into every
427#: piece of evidence, because a reader has to be able to tell an
428#: agent-performed journey from an owner observation without being told. The
429#: owner grant of 2026-07-25 permits an agent to perform these journeys. It
430#: does not permit recording one as an owner observation.
431PERFORMED_BY = "agent"
432PROFILE: str | None = None
433
434
435def write_evidence(root: Path, name: str, payload: dict[str, Any]) -> dict[str, str]:
436 path = root / f"observation-{name}.json"
437 body = dict(payload)
438 body["performed_by"] = PERFORMED_BY
439 if PROFILE is not None:
440 body["emulated_profile"] = PROFILE
441 path.write_text(json.dumps(body, indent=2, sort_keys=True) + "\n", encoding="utf-8")
442 return {"path": path.name, "sha256": sha256_bytes(path.read_bytes())}
443
444
445def attach_screenshot(
446 root: Path, name: str, region: tuple[int, int, int, int] | None = None
447) -> dict[str, str] | None:
448 """Capture the candidate, or capture nothing.
449
450 A region capture reads the screen, not a window. If the candidate is not in
451 front, the rectangle where its window sits is filled by whatever is, and
452 the capture is then evidence about another application. That is the same
453 class of error as sending a keystroke to whatever happens to be frontmost,
454 and it is refused the same way: the candidate is brought to the front and
455 the identifier is checked before the shutter, and a capture that cannot be
456 established as the candidate's is not written.
457 """
458 if PROCESS_PID is not None and WINDOW_ID is None:
459 if not activate():
460 return None
461 # Frontmost is not the same as visible. An application can hold the
462 # front while its window sits on a space that is not the one being
463 # displayed, and a region capture then reads the windows that are.
464 # Ask the window server whether this window is actually on screen.
465 visible = visible_window()
466 if visible is None:
467 return None
468 region = visible
469 path = root / f"screenshot-{name}.png"
470 if not screencapture(path, region):
471 return None
472 return {"path": path.name, "sha256": sha256_bytes(path.read_bytes())}
473
474
475def window_frame() -> tuple[int, int, int, int] | None:
476 """Position and size of the candidate's front window, in points.
477
478 The accessibility tree is asked first. A GPUI application publishes none,
479 and on such an application `window 1` does not exist even though the window
480 is on the screen, so the window server is asked second.
481 """
482 ok, output = osascript(
483 f"tell application \"System Events\" to tell {target()} "
484 "to get {position, size} of window 1"
485 )
486 if ok:
487 parts = [part.strip() for part in output.split(",")]
488 if len(parts) == 4:
489 try:
490 left, top, width, height = (int(part) for part in parts)
491 return left, top, width, height
492 except ValueError:
493 pass
494 for window in candidate_windows():
495 if WINDOW_ID is not None and window.get("id") != WINDOW_ID:
496 continue
497 bounds = window.get("bounds")
498 if not isinstance(bounds, dict):
499 continue
500 try:
501 return (
502 int(bounds["X"]),
503 int(bounds["Y"]),
504 int(bounds["Width"]),
505 int(bounds["Height"]),
506 )
507 except (KeyError, TypeError, ValueError):
508 continue
509 return None
510
511
512def set_window_size(width: int, height: int) -> bool:
513 ok, _ = osascript(
514 f"tell application \"System Events\" to tell {target()} "
515 f"to set size of window 1 to {{{width}, {height}}}"
516 )
517 return ok
518
519
520def frontmost_process() -> str:
521 ok, output = osascript(
522 'tell application "System Events" to get name of first process whose frontmost is true'
523 )
524 return output.strip() if ok else ""
525
526
527def frontmost_pid() -> int | None:
528 ok, output = osascript(
529 "tell application \"System Events\" to get unix id of "
530 "(first process whose frontmost is true)"
531 )
532 if not ok:
533 return None
534 try:
535 return int(output.strip())
536 except ValueError:
537 return None
538
539
540#: How many times to ask for the front before reporting that the candidate did
541#: not get it. A shared host has other work on it, and another application
542#: taking the front for a moment is ordinary contention rather than a fact
543#: about the candidate. Retrying changes nothing about what is accepted: the
544#: identifier is still checked after every attempt, and a run that never wins
545#: the front still refuses.
546ACTIVATE_ATTEMPTS = 6
547
548
549def is_frontmost() -> bool:
550 if PROCESS_PID is not None:
551 return frontmost_pid() == PROCESS_PID
552 return frontmost_process().lower() == PROCESS.lower()
553
554
555def activate() -> bool:
556 """Bring the application under test to the front, or report that it is not.
557
558 With `--pid` the check is on the identifier, not the name. Three processes
559 on this host answer to `omega`, and a name comparison would report success
560 while a different copy held the keyboard.
561 """
562 for attempt in range(ACTIVATE_ATTEMPTS):
563 osascript(
564 f"tell application \"System Events\" to tell {target()} "
565 "to set frontmost to true"
566 )
567 raise_window()
568 time.sleep(2 if attempt == 0 else 1.5)
569 if is_frontmost() and (WINDOW_LIST_TOOL is None or visible_window() is not None):
570 return True
571 return False
572
573
574def keystroke(script_fragment: str) -> bool:
575 """Send one keystroke to the candidate, or refuse.
576
577 macOS routes synthesized keys to whichever application is frontmost, not to
578 the process named in the AppleScript. An unchecked keystroke therefore
579 lands in whatever window happens to be in front, which during an earlier
580 pass of this harness opened a Page Setup sheet in the operator's terminal
581 and was very nearly recorded as the candidate responding to the keyboard.
582 Every keystroke re-activates the candidate first and refuses if it is not
583 in front, so a keyboard observation can only ever be an observation of this
584 application.
585 """
586 if not activate():
587 return False
588 ok, _ = osascript(f'tell application "System Events" to {script_fragment}')
589 time.sleep(1.5)
590 return ok
591
592
593def window_digest(root: Path, name: str) -> tuple[str | None, dict[str, str] | None]:
594 """Capture the candidate's window and return its pixel digest."""
595 frame = window_frame()
596 if frame is None:
597 return None, None
598 reference = attach_screenshot(root, name, frame)
599 if reference is None:
600 return None, None
601 return reference["sha256"], reference
602
603
604#: `entire contents` value recorded when the process is running and its window
605#: is on the screen, and the accessibility tree publishes no window at all.
606#: This is a weaker publication than an empty tree, not a failure to look: the
607#: window server is asked separately and confirms the window exists.
608NO_WINDOW_IN_TREE = -2
609
610
611def ax_children(process: str) -> tuple[bool, int, list[str]]:
612 """Direct children of the front window, as assistive technology sees them.
613
614 Returns whether the process was reachable, the size of `entire contents`,
615 and the role description of each direct child. macOS supplies the window
616 frame buttons itself, so a window whose only children are those buttons and
617 whose `entire contents` is empty publishes no application accessibility
618 tree at all.
619
620 A running process whose window the tree does not list at all reports
621 `NO_WINDOW_IN_TREE`, which is reachable and publishes nothing. It is
622 distinguished from an absent process because those two states must not be
623 recorded the same way: one is a fact about the product, and the other is a
624 fact about the harness.
625 """
626 clause = target() if PROCESS_PID is not None else f'process "{process}"'
627 exists_clause = (
628 f"(exists {clause})"
629 if PROCESS_PID is not None
630 else f'(exists process "{process}")'
631 )
632 script = f"""
633 tell application "System Events"
634 if not {exists_clause} then return "NOPROC"
635 tell {clause}
636 if (count of windows) is 0 then return "NOWINDOW"
637 set w to window 1
638 set out to ""
639 try
640 set out to out & "contents=" & (count of (entire contents of w)) & linefeed
641 on error
642 set out to out & "contents=-1" & linefeed
643 end try
644 try
645 repeat with e in UI elements of w
646 set out to out & "child=" & (role description of e) & linefeed
647 end repeat
648 end try
649 return out
650 end tell
651 end tell
652 """
653 ok, output = osascript(script, timeout=90)
654 if ok and output == "NOWINDOW" and candidate_windows():
655 # The process is running and the window server is showing its window.
656 # The tree simply does not carry it.
657 return True, NO_WINDOW_IN_TREE, []
658 if not ok or output in {"NOPROC", "NOWINDOW"}:
659 return False, -1, []
660 contents = -1
661 children: list[str] = []
662 for line in output.split("\n"):
663 line = line.strip()
664 if line.startswith("contents="):
665 try:
666 contents = int(line.split("=", 1)[1])
667 except ValueError:
668 contents = -1
669 elif line.startswith("child="):
670 children.append(line.split("=", 1)[1])
671 return True, contents, children
672
673
674def differing_pixels(first: Path, second: Path) -> int | None:
675 """Count the pixels that differ between two captures.
676
677 Exact image equality is not a usable oracle on a live window: a workroom
678 panel refreshes and a caret blinks, so two captures of the same visual
679 state are never byte-identical. Counting differing pixels lets the focus
680 walk state its threshold openly instead of pretending the window is still.
681 Returning `None` means the comparison could not be made, and a comparison
682 that could not be made never becomes a pass.
683 """
684 try:
685 from PIL import Image, ImageChops
686 except ImportError:
687 return None
688 try:
689 with Image.open(first) as left, Image.open(second) as right:
690 left_rgb = left.convert("RGB")
691 right_rgb = right.convert("RGB")
692 if left_rgb.size != right_rgb.size:
693 return None
694 difference = ImageChops.difference(left_rgb, right_rgb)
695 return sum(1 for pixel in difference.getdata() if pixel != (0, 0, 0))
696 except (OSError, ValueError):
697 return None
698
699
700def view_menu(item: str) -> bool:
701 """Click one item in the candidate's own View menu.
702
703 Used instead of a keyboard chord where another application's global hotkey
704 could intercept the chord, and instead of a screen click, which lands in
705 whatever window happens to be under the pointer.
706 """
707 if not activate():
708 return False
709 ok, _ = osascript(
710 f"tell application \"System Events\" to tell {target()} to click menu item "
711 f'"{item}" of menu 1 of menu bar item "View" of menu bar 1'
712 )
713 time.sleep(2.5)
714 return ok
715
716
717def publishes_accessibility_tree(reachable: bool, contents: int, children: list[str]) -> bool:
718 if not reachable:
719 return False
720 if contents > 0:
721 return True
722 return any(child not in WINDOW_FRAME_CONTROLS for child in children)
723
724
725def ax_dump(timeout: int = 90) -> tuple[bool, list[str]]:
726 """Every accessibility label and value Omega exposes to assistive tech."""
727 clause = target()
728 exists_clause = (
729 f"(exists {clause})"
730 if PROCESS_PID is not None
731 else f'(exists process "{PROCESS}")'
732 )
733 script = f"""
734 tell application "System Events"
735 if not {exists_clause} then return "NOPROC"
736 tell {clause}
737 set out to ""
738 try
739 repeat with w in windows
740 repeat with e in (entire contents of w)
741 try
742 set d to description of e
743 if d is not missing value then set out to out & d & linefeed
744 end try
745 try
746 set v to value of e
747 if v is not missing value then set out to out & (v as text) & linefeed
748 end try
749 end repeat
750 end repeat
751 end try
752 return out
753 end tell
754 end tell
755 """
756 ok, output = osascript(script, timeout=timeout)
757 if not ok or output == "NOPROC":
758 return False, []
759 return True, [line for line in output.split("\n") if line.strip()]
760
761
762def system_flag(domain: str, key: str) -> bool | None:
763 try:
764 result = subprocess.run(
765 ["defaults", "read", domain, key], capture_output=True, text=True, timeout=20
766 )
767 except (OSError, subprocess.TimeoutExpired):
768 return None
769 if result.returncode != 0:
770 return None
771 return result.stdout.strip() in {"1", "true", "YES"}
772
773
774def appearance_is_dark() -> bool | None:
775 ok, output = osascript(
776 'tell application "System Events" to tell appearance preferences to return dark mode'
777 )
778 if not ok:
779 return None
780 return output.strip() == "true"
781
782
783def set_appearance_dark(dark: bool) -> bool:
784 ok, _ = osascript(
785 'tell application "System Events" to tell appearance preferences '
786 f"to set dark mode to {'true' if dark else 'false'}"
787 )
788 time.sleep(3)
789 return ok and appearance_is_dark() is dark
790
791
792def settings_file(config_name: str) -> Path:
793 """The installed candidate's own user settings file.
794
795 The candidate keeps user settings under `~/.config/<name>`, separate from
796 the state under `~/Library/Application Support/<name>` and separate from
797 every Zed root. Writing here is what makes the larger-UI-font check an
798 observation of the shipped application rather than of a test double.
799 """
800 return Path.home() / ".config" / config_name / "settings.json"
801
802
803def load_upstream_parity(path: Path | None) -> tuple[dict[str, str] | None, str]:
804 """Read the upstream parity record `observe-upstream-accessibility-parity` wrote.
805
806 The parity probe is a separate command because it launches upstream Zed,
807 and Zed's own writes must fall outside the window the isolation check
808 measures. No parity record means no waiver: the owner direction is
809 conditioned on parity, and an unobserved condition supports nothing.
810 """
811 if path is None:
812 return None, "no --upstream-parity record was supplied"
813 if path.is_symlink() or not path.is_file():
814 return None, f"{path} is not a regular parity record"
815 try:
816 record = json.loads(path.read_text(encoding="utf-8"))
817 except (OSError, json.JSONDecodeError) as error:
818 return None, f"the parity record is unreadable: {error}"
819 if record.get("schema") != UPSTREAM_PARITY_SCHEMA:
820 return None, "the parity record has the wrong schema"
821 if record.get("publishes_accessibility_tree") is not False:
822 return None, (
823 "upstream Zed publishes an accessibility tree, so the owner parity "
824 f"condition does not hold ({record.get('observed')})"
825 )
826 parity = {
827 key: record.get(key) for key in ("product", "version", "method", "observed")
828 }
829 if any(not isinstance(value, str) or not value.strip() for value in parity.values()):
830 return None, "the parity record is incomplete"
831 return parity, "" # type: ignore[return-value]
832
833
834def self_test() -> int:
835 """The appearance pair has to be able to fail, or every run records it green.
836
837 Nothing here drives the host or the candidate. It exercises the decision the
838 appearance block makes from a capture, which is the part that was missing:
839 the old block wrote `content_legible: True` as a literal and never read a
840 pixel or a character, so a window that never repainted passed both checks.
841 """
842 import tempfile
843
844 failures: list[str] = []
845
846 def expect(condition: bool, description: str) -> None:
847 if not condition:
848 failures.append(description)
849
850 # A read that could not be made stays unmade, and lines a reader could not
851 # read are not counted as read.
852 expect(confident_lines(None, 0.5) is None, "an unreadable capture became a line list")
853 filtered = confident_lines(
854 [
855 {"text": "legible", "confidence": 0.9},
856 {"text": " ", "confidence": 0.9},
857 {"text": "smudged", "confidence": 0.2},
858 {"text": None, "confidence": 0.9},
859 ],
860 0.5,
861 )
862 expect(filtered is not None and len(filtered) == 1, "confident_lines counted the wrong lines")
863
864 readable = [
865 {"text": f"line {index}", "confidence": 0.9}
866 for index in range(APPEARANCE_LEGIBLE_LINES)
867 ]
868 thin = readable[:-1]
869 cases = [
870 ("a legible pair of different pictures", readable, APPEARANCE_DIFFERENCE_PIXELS, True),
871 ("a capture nothing could read", None, APPEARANCE_DIFFERENCE_PIXELS, False),
872 ("a capture with too little legible text", thin, APPEARANCE_DIFFERENCE_PIXELS, False),
873 ("a pair that could not be compared", readable, None, False),
874 ("two captures of the same picture", readable, 0, False),
875 (
876 "a pair just under the stated difference",
877 readable,
878 APPEARANCE_DIFFERENCE_PIXELS - 1,
879 False,
880 ),
881 ]
882 for description, lines, difference, expected in cases:
883 observed, reason = appearance_observed("light", lines, difference)
884 expect(observed is expected, f"{description} was judged {observed}")
885 expect(expected or bool(reason), f"{description} failed without saying why")
886
887 # The pixel comparison the pair rests on, run over real images.
888 with tempfile.TemporaryDirectory() as directory:
889 workspace = Path(directory)
890 try:
891 from PIL import Image
892 except ImportError:
893 expect(
894 appearance_observed("light", readable, differing_pixels(workspace, workspace))[0]
895 is False,
896 "an uncomparable pair passed",
897 )
898 else:
899 light = workspace / "light.png"
900 dark = workspace / "dark.png"
901 same = workspace / "same.png"
902 Image.new("RGB", (400, 400), (255, 255, 255)).save(light)
903 Image.new("RGB", (400, 400), (255, 255, 255)).save(same)
904 Image.new("RGB", (400, 400), (16, 16, 16)).save(dark)
905 unchanged = differing_pixels(light, same)
906 changed = differing_pixels(light, dark)
907 expect(unchanged == 0, f"two identical captures differed by {unchanged} pixels")
908 expect(
909 changed is not None and changed >= APPEARANCE_DIFFERENCE_PIXELS,
910 f"a full repaint counted only {changed} differing pixels",
911 )
912 expect(
913 appearance_observed("dark", readable, unchanged)[0] is False,
914 "an unchanged pair of captures passed",
915 )
916 expect(
917 appearance_observed("dark", readable, changed)[0] is True,
918 "a repainted pair of captures did not pass",
919 )
920
921 # The block that records the pair has to take its facts from the capture.
922 # This is the exact regression that stood: `content_legible: True` beside a
923 # screenshot, with zero `ocr_lines()` calls and zero `differing_pixels()`
924 # calls in the whole block. A decision function nothing calls is not a
925 # check, so the wiring is asserted as well as the decision.
926 source = Path(__file__).resolve().read_text(encoding="utf-8")
927 # The marker is assembled so that this line is not itself the thing it
928 # looks for. A search that can find its own source would still pass with
929 # the block deleted.
930 marker = "# ---- appearanc" + "e ------"
931 expect(source.count(marker) == 1, "the appearance block header is not where it was")
932 start = source.index(marker)
933 block = source[start : source.index("if blocked:", start)]
934 for call in (
935 "ocr_lines(",
936 "differing_pixels(",
937 "confident_lines(",
938 "appearance_observed(",
939 ):
940 expect(call in block, f"the appearance block no longer calls {call}")
941
942 # The facts this check records are the ones the receipt validator demands.
943 # A drift here would block the proof at validation instead of here.
944 for name in ("light", "dark"):
945 try:
946 validate_observation_facts(
947 f"{name}-theme", {"appearance": name, "content_legible": True}
948 )
949 except IdentityEvidenceError as error:
950 failures.append(f"{name}-theme facts are not the validated shape: {error}")
951
952 if failures:
953 for failure in failures:
954 print(f"self-test failure: {failure}", file=sys.stderr)
955 return 1
956 print(
957 json.dumps(
958 {
959 "schema": OBSERVATIONS_SCHEMA,
960 "status": "self-test-passed",
961 "appearance_legible_lines": APPEARANCE_LEGIBLE_LINES,
962 "appearance_difference_pixels": APPEARANCE_DIFFERENCE_PIXELS,
963 }
964 )
965 )
966 return 0
967
968
969def main() -> int:
970 parser = argparse.ArgumentParser()
971 parser.add_argument("--app", type=Path, default=Path("/Applications/Omega.app"))
972 parser.add_argument("--candidate-evidence", type=Path)
973 parser.add_argument("--evidence-root", type=Path)
974 parser.add_argument("--zed-before", help="digest captured before install")
975 parser.add_argument(
976 "--self-test",
977 action="store_true",
978 help=(
979 "exercise the decisions this collector makes from a capture, "
980 "without driving the host or the candidate"
981 ),
982 )
983 parser.add_argument(
984 "--config-name",
985 default="omega-rc",
986 help="the candidate's own directory name under ~/.config",
987 )
988 parser.add_argument(
989 "--upstream-parity",
990 type=Path,
991 default=None,
992 help=(
993 "record from observe-upstream-accessibility-parity. Without it the "
994 "screen-reader waiver has no observed condition and the run blocks."
995 ),
996 )
997 parser.add_argument(
998 "--pid",
999 type=int,
1000 default=None,
1001 help=(
1002 "process identifier of the exact application under test. Required "
1003 "where more than one copy is running, because macOS reports them "
1004 "all under the same name."
1005 ),
1006 )
1007 parser.add_argument(
1008 "--window-id",
1009 type=int,
1010 default=None,
1011 help=(
1012 "window-server identifier of the window to capture. Captures that "
1013 "window rather than the screen, so an observation does not depend "
1014 "on the candidate holding the active space."
1015 ),
1016 )
1017 parser.add_argument(
1018 "--ocr",
1019 type=Path,
1020 default=None,
1021 help="compiled script/omega-window-ocr.swift, used to read rendered text",
1022 )
1023 parser.add_argument(
1024 "--window-list",
1025 type=Path,
1026 default=None,
1027 help="compiled script/omega-window-list.swift, used to read the window frame",
1028 )
1029 parser.add_argument(
1030 "--settings-path",
1031 type=Path,
1032 default=None,
1033 help=(
1034 "the running candidate's own settings file. Give this when the "
1035 "candidate runs under an emulated profile with --user-data-dir, "
1036 "where its settings do not live under ~/.config."
1037 ),
1038 )
1039 parser.add_argument(
1040 "--performed-by",
1041 default="agent",
1042 choices=["agent", "owner"],
1043 help=(
1044 "who performed these observations. An agent-performed journey is "
1045 "recorded as such and is never an owner observation."
1046 ),
1047 )
1048 parser.add_argument(
1049 "--profile",
1050 default=None,
1051 help="name of the emulated profile these observations were performed in",
1052 )
1053 parser.add_argument("--output", type=Path)
1054 args = parser.parse_args()
1055
1056 if args.self_test:
1057 return self_test()
1058
1059 # These are required for a collection run. They are not declared required
1060 # on the parser only so that `--self-test` can be run without inventing a
1061 # candidate; a run that is missing one still refuses here.
1062 for flag, value in (
1063 ("--candidate-evidence", args.candidate_evidence),
1064 ("--evidence-root", args.evidence_root),
1065 ("--zed-before", args.zed_before),
1066 ("--output", args.output),
1067 ):
1068 if value is None:
1069 parser.error(f"{flag} is required")
1070
1071 global PROCESS_PID, WINDOW_ID, OCR_TOOL, WINDOW_LIST_TOOL, PERFORMED_BY, PROFILE
1072 PROCESS_PID = args.pid
1073 WINDOW_ID = args.window_id
1074 OCR_TOOL = args.ocr
1075 WINDOW_LIST_TOOL = args.window_list
1076 PERFORMED_BY = args.performed_by
1077 PROFILE = args.profile
1078
1079 evidence = json.loads(args.candidate_evidence.read_text(encoding="utf-8"))
1080 candidate_digest = evidence.get("candidate_digest", {}).get("value")
1081 if not isinstance(candidate_digest, str) or len(candidate_digest) != 64:
1082 raise ObservationError("candidate evidence has no usable candidate_digest")
1083
1084 root = args.evidence_root
1085 root.mkdir(parents=True, exist_ok=True)
1086 entries: dict[str, list[dict[str, Any]]] = {"manual_journey": [], "accessibility": []}
1087 blocked: list[str] = []
1088 waived: list[str] = []
1089
1090 def record(group: str, check: str, facts: dict[str, Any], detail: dict[str, Any]) -> None:
1091 refs = [write_evidence(root, check, detail)]
1092 shot = attach_screenshot(root, check, window_frame())
1093 if shot is not None:
1094 refs.append(shot)
1095 entries[group].append(
1096 {
1097 "check": check,
1098 "status": "passed",
1099 "observed_at": now(),
1100 "facts": facts,
1101 "evidence_refs": refs,
1102 }
1103 )
1104
1105 def record_performed(
1106 group: str, check: str, facts: dict[str, Any], refs: list[dict[str, str]]
1107 ) -> None:
1108 entries[group].append(
1109 {
1110 "check": check,
1111 "status": "passed",
1112 "observed_at": now(),
1113 "facts": facts,
1114 "evidence_refs": refs,
1115 }
1116 )
1117
1118 def record_waived(
1119 group: str, check: str, basis: str, parity: dict[str, str], detail: dict[str, Any]
1120 ) -> None:
1121 """Record that a check was *not* observed, and why that is permitted.
1122
1123 This deliberately writes no `facts`. The validator rejects a waived
1124 entry that carries facts and a passed entry that carries a waiver, so
1125 the two shapes cannot be interchanged after the fact.
1126 """
1127 if check not in WAIVABLE_CHECKS:
1128 raise ObservationError(f"{check} is performable and may not be waived")
1129 entries[group].append(
1130 {
1131 "check": check,
1132 "status": "waived",
1133 "observed_at": now(),
1134 "waiver": {
1135 "owner_quote": OWNER_ACCESSIBILITY_DIRECTION,
1136 "owner_direction_date": OWNER_DIRECTION_DATE,
1137 "basis": basis,
1138 "upstream_parity": parity,
1139 "issue": OWNER_DIRECTION_ISSUE,
1140 },
1141 "evidence_refs": [write_evidence(root, check, detail)],
1142 }
1143 )
1144 waived.append(check)
1145
1146 # The two checks below depend on a system flag only the owner can set, and
1147 # they are performed first for that reason. `defaults write` is refused on
1148 # that domain, so the window in which they can be observed at all is open
1149 # only while a person is holding it open, and it is not this run's to spend.
1150 # Every other check can be performed at any hour on any day.
1151
1152 # ---- high contrast ------------------------------------------------------
1153 # The system flag is this check's *precondition*, not its observation. Only
1154 # the owner can set it, so a run cannot happen without them; but a pass
1155 # recorded off the flag alone would be a fact about the host's preferences,
1156 # and the two facts this check carries are facts about the product. Both are
1157 # performed here, with increased contrast on, and both are pixel reads.
1158 if not system_flag("com.apple.universalaccess", "increaseContrast"):
1159 blocked.append(OWNER_FLAG_OFF.format(check="high-contrast", key="increaseContrast"))
1160 elif not activate():
1161 blocked.append("high-contrast: the candidate could not be brought to the front")
1162 else:
1163 contrast_refs: list[dict[str, str]] = []
1164
1165 # Legibility is read the way a person reads it: characters recognised
1166 # off the rendered window. Text present in the layout but rendered
1167 # unreadably under increased contrast fails this; nothing here is taken
1168 # from a layout tree or a settings value.
1169 legible_ref = attach_screenshot(root, "high-contrast", window_frame())
1170 readable: list[dict[str, Any]] = []
1171 if legible_ref is not None:
1172 contrast_refs.append(legible_ref)
1173 readable = [
1174 line
1175 for line in (ocr_lines(root / legible_ref["path"]) or [])
1176 if isinstance(line.get("text"), str)
1177 and line["text"].strip()
1178 and float(line.get("confidence", 0.0)) >= CONTRAST_LEGIBLE_CONFIDENCE
1179 ]
1180 content_legible = len(readable) >= CONTRAST_LEGIBLE_LINES
1181
1182 # The focus indicator is observed by moving the selection and comparing
1183 # the window with itself. A caret blink cannot clear the forward
1184 # threshold, and a move that leaves the window unchanged is not an
1185 # indicator no matter what any tree says.
1186 keystroke('keystroke "p" using {command down, shift down}')
1187 contrast_paths: dict[str, Path] = {}
1188 for name, fragment in (
1189 ("start", None),
1190 ("forward", "key code 125"),
1191 ("back", "key code 126"),
1192 ):
1193 if fragment is not None and not keystroke(fragment):
1194 continue
1195 label = f"high-contrast-focus-{name}"
1196 _, reference = window_digest(root, label)
1197 if reference is not None:
1198 contrast_refs.append(reference)
1199 contrast_paths[name] = root / f"screenshot-{label}.png"
1200 keystroke("key code 53") # escape
1201
1202 def contrast_pixels(first: str, second: str) -> int | None:
1203 if first not in contrast_paths or second not in contrast_paths:
1204 return None
1205 return differing_pixels(contrast_paths[first], contrast_paths[second])
1206
1207 contrast_forward = contrast_pixels("start", "forward")
1208 contrast_return = contrast_pixels("start", "back")
1209 focus_indicator_visible = (
1210 contrast_forward is not None
1211 and contrast_forward >= FOCUS_MOVE_MINIMUM_PIXELS
1212 and contrast_return is not None
1213 and contrast_return * FOCUS_RETURN_RATIO <= contrast_forward
1214 )
1215
1216 if content_legible and focus_indicator_visible:
1217 record_performed(
1218 "accessibility",
1219 "high-contrast",
1220 {
1221 "system_increase_contrast": True,
1222 "content_legible": True,
1223 "focus_indicator_visible": True,
1224 },
1225 [
1226 write_evidence(
1227 root,
1228 "high-contrast",
1229 {
1230 "system_flag": "com.apple.universalaccess increaseContrast",
1231 "legible_line_count": len(readable),
1232 "legible_line_minimum": CONTRAST_LEGIBLE_LINES,
1233 "legible_confidence_minimum": CONTRAST_LEGIBLE_CONFIDENCE,
1234 "legible_lines": [
1235 line["text"] for line in readable
1236 ][:40],
1237 "focus_indicator_pixels": {
1238 "forward_move": contrast_forward,
1239 "return_move": contrast_return,
1240 "minimum_forward": FOCUS_MOVE_MINIMUM_PIXELS,
1241 "return_ratio": FOCUS_RETURN_RATIO,
1242 },
1243 "content_legible_basis": (
1244 "Optical character recognition over a capture of the "
1245 "candidate's own window taken while the host had "
1246 "increased contrast on. Lines below the stated "
1247 "confidence are not counted."
1248 ),
1249 "focus_indicator_basis": (
1250 "Moving the selection repainted the window well beyond "
1251 "the stated minimum and moving back returned it to "
1252 "very near the earlier image, so a visible indicator "
1253 "moved with the focus rather than the window merely "
1254 "changing."
1255 ),
1256 "method": (
1257 "Performed with the owner-set system flag on. The flag "
1258 "is the precondition of this observation, not the "
1259 "observation."
1260 ),
1261 },
1262 ),
1263 *contrast_refs,
1264 ],
1265 )
1266 else:
1267 blocked.append(
1268 f"high-contrast: legible_lines={len(readable)} "
1269 f"(minimum {CONTRAST_LEGIBLE_LINES}) forward={contrast_forward} "
1270 f"return={contrast_return}"
1271 )
1272
1273 # ---- reduced motion -----------------------------------------------------
1274 # `motion_required_for_completion: false` is the substantive claim in this
1275 # whole set, and the system flag says nothing about it. It is a claim about
1276 # the product: that a person who has turned motion off can still finish. So
1277 # a journey is completed here, with the flag on, by keyboard alone, and each
1278 # of its end states is read from a still frame.
1279 #
1280 # The completion is checked by an independent route rather than by trusting
1281 # the keystroke: the command is completed from the palette by keyboard, then
1282 # undone through the candidate's own View menu, and the window has to come
1283 # back to the image it started from. A journey that did something else does
1284 # not round-trip.
1285 if not system_flag("com.apple.universalaccess", "reduceMotion"):
1286 blocked.append(OWNER_FLAG_OFF.format(check="reduced-motion", key="reduceMotion"))
1287 elif not activate():
1288 blocked.append("reduced-motion: the candidate could not be brought to the front")
1289 else:
1290 motion_refs: list[dict[str, str]] = []
1291 motion_paths: dict[str, Path] = {}
1292
1293 def motion_capture(label: str) -> bool:
1294 """Capture the window under its own label, region-free.
1295
1296 No window frame is read first: with `--window-id` the frame is not
1297 used, and the read would put an osascript round trip between the
1298 input and the frame this check calls the first one.
1299 """
1300 reference = attach_screenshot(root, f"reduced-motion-{label}", None)
1301 if reference is None:
1302 return False
1303 motion_refs.append(reference)
1304 motion_paths[label] = root / f"screenshot-reduced-motion-{label}.png"
1305 return True
1306
1307 def motion_pixels(first: str, second: str) -> int | None:
1308 if first not in motion_paths or second not in motion_paths:
1309 return None
1310 return differing_pixels(motion_paths[first], motion_paths[second])
1311
1312 motion_capture("baseline-first")
1313 time.sleep(MOTION_SETTLED_SECONDS)
1314 motion_capture("baseline")
1315
1316 delivered_open = keystroke('keystroke "p" using {command down, shift down}')
1317 motion_capture("open-first")
1318 time.sleep(MOTION_SETTLED_SECONDS)
1319 motion_capture("open")
1320
1321 delivered_query = keystroke(f'keystroke "{MOTION_JOURNEY_COMMAND}"')
1322 time.sleep(MOTION_SETTLED_SECONDS)
1323 motion_capture("filter")
1324 filtered = (
1325 ocr_lines(motion_paths["filter"]) if "filter" in motion_paths else None
1326 )
1327 command_offered = (
1328 filtered is not None
1329 and first_position(filtered, MOTION_JOURNEY_MATCH) is not None
1330 )
1331
1332 delivered_complete = keystroke("key code 36") # return
1333 motion_capture("complete-first")
1334 time.sleep(MOTION_SETTLED_SECONDS)
1335 motion_capture("complete")
1336
1337 undone = view_menu(MOTION_JOURNEY_MENU_ITEM)
1338 time.sleep(MOTION_SETTLED_SECONDS)
1339 motion_capture("restored")
1340
1341 opened_change = motion_pixels("baseline", "open")
1342 completed_change = motion_pixels("baseline", "complete")
1343 restored_change = motion_pixels("baseline", "restored")
1344 settle = {
1345 "baseline": motion_pixels("baseline-first", "baseline"),
1346 "open": motion_pixels("open-first", "open"),
1347 "complete": motion_pixels("complete-first", "complete"),
1348 }
1349
1350 reached = opened_change is not None and opened_change >= FOCUS_MOVE_MINIMUM_PIXELS
1351 completed = (
1352 completed_change is not None and completed_change >= FOCUS_MOVE_MINIMUM_PIXELS
1353 )
1354 round_tripped = (
1355 completed
1356 and restored_change is not None
1357 and restored_change * FOCUS_RETURN_RATIO <= completed_change
1358 )
1359 # Every state the journey passes through has to be still when it is
1360 # looked at. A surface that is still moving when the person arrives is
1361 # a surface whose completion depends on motion.
1362 still = all(
1363 value is not None
1364 and reference is not None
1365 and value * MOTION_SETTLE_RATIO <= reference
1366 for value, reference in (
1367 (settle["baseline"], completed_change),
1368 (settle["open"], opened_change),
1369 (settle["complete"], completed_change),
1370 )
1371 )
1372
1373 if (
1374 delivered_open
1375 and delivered_query
1376 and delivered_complete
1377 and undone
1378 and reached
1379 and command_offered
1380 and completed
1381 and round_tripped
1382 and still
1383 ):
1384 record_performed(
1385 "accessibility",
1386 "reduced-motion",
1387 {"system_reduce_motion": True, "motion_required_for_completion": False},
1388 [
1389 write_evidence(
1390 root,
1391 "reduced-motion",
1392 {
1393 "system_flag": "com.apple.universalaccess reduceMotion",
1394 "journey": [
1395 "open the command palette by keyboard",
1396 f"type {MOTION_JOURNEY_COMMAND!r}",
1397 "read the offered command off the rendered window",
1398 "complete it with the return key",
1399 f"undo it from the View menu item "
1400 f"{MOTION_JOURNEY_MENU_ITEM!r}",
1401 ],
1402 "journey_pixels": {
1403 "opened_change": opened_change,
1404 "completed_change": completed_change,
1405 "restored_change": restored_change,
1406 "minimum_change": FOCUS_MOVE_MINIMUM_PIXELS,
1407 "return_ratio": FOCUS_RETURN_RATIO,
1408 },
1409 "settled_pixels": settle,
1410 "settle_seconds": MOTION_SETTLED_SECONDS,
1411 "settle_ratio": MOTION_SETTLE_RATIO,
1412 "command_offered": MOTION_JOURNEY_MATCH,
1413 "completion_basis": (
1414 "The journey was completed by keyboard with reduced "
1415 "motion on, and undoing it through the candidate's own "
1416 "View menu returned the window to the image it started "
1417 "from, so what the keyboard completed was this command "
1418 "and the journey really did finish."
1419 ),
1420 "no_motion_basis": (
1421 "Every state the journey passes through was read from a "
1422 "still frame: the capture taken as soon as the harness "
1423 "could take one after each input differs from the "
1424 "capture taken "
1425 f"{MOTION_SETTLED_SECONDS} seconds later by at most a "
1426 f"{MOTION_SETTLE_RATIO}th of the change that step made. "
1427 "Nothing here was read from an animation, and no step "
1428 "needed one to become reachable or readable."
1429 ),
1430 "not_observed": (
1431 "This is not a claim that the candidate renders "
1432 "differently with reduced motion on. It is a claim that "
1433 "the journey completes while it is on."
1434 ),
1435 "method": (
1436 "Keyboard and the candidate's own menu, delivered "
1437 "through System Events to the process under test, with "
1438 "every state read as pixels or as text recognised from "
1439 "pixels."
1440 ),
1441 },
1442 ),
1443 *motion_refs,
1444 ],
1445 )
1446 else:
1447 blocked.append(
1448 "reduced-motion: delivered="
1449 f"{delivered_open}/{delivered_query}/{delivered_complete} "
1450 f"undone={undone} opened={opened_change} offered={command_offered} "
1451 f"completed={completed_change} restored={restored_change} "
1452 f"settled={settle}"
1453 )
1454
1455 # ---- manual journey -----------------------------------------------------
1456
1457 # Zed isolation: purely mechanical, and meaningful only because this host
1458 # actually has Zed installed.
1459 if not ZED_DATA.is_dir():
1460 blocked.append("zed-data-before-after-isolation: no Zed data directory on this host")
1461 else:
1462 after = tree_digest(ZED_DATA)
1463 if after != args.zed_before:
1464 blocked.append("zed-data-before-after-isolation: Zed data changed")
1465 else:
1466 record(
1467 "manual_journey",
1468 "zed-data-before-after-isolation",
1469 {
1470 "zed_before_sha256": args.zed_before,
1471 "zed_after_sha256": after,
1472 "unchanged": True,
1473 },
1474 {"root": str(ZED_DATA), "method": "sha256 over every regular file"},
1475 )
1476
1477 ok, labels = ax_dump()
1478 joined = "\n".join(labels) if ok else ""
1479
1480 # These two are journey checks, not assistive-technology checks. What they
1481 # assert is what the first-run screen shows and in what order, and that is a
1482 # fact about rendered pixels. Reading it from the accessibility tree was
1483 # only ever a convenience, and on an application that publishes no tree the
1484 # convenience blocks a fact anyone can see. So the tree is read first, and
1485 # where it carries nothing the window itself is read.
1486 #
1487 # The pixel read is the stronger of the two. A label present in a tree but
1488 # clipped off the screen passes a tree read and fails this one, and the
1489 # order of the sections is observed rather than assumed: the identity
1490 # heading has to sit above both the Theme and Agent Setup headings in the
1491 # same capture.
1492 identity_ready = ok and "Identity ready" in joined
1493 tree_families = [f for f in ("Aiur", "Ayu", "Gruvbox") if f in joined]
1494 tree_agent_setup = "Agent Setup" in joined
1495
1496 if ok and identity_ready:
1497 record(
1498 "manual_journey",
1499 "identity-first-first-run",
1500 {
1501 "identity_presented_before_editor_setup": True,
1502 "identity_ready": True,
1503 },
1504 {
1505 "observed_labels": [
1506 line for line in labels if "Identity" in line or "Recovery" in line
1507 ][:12],
1508 "method": "AXUIElement tree of the running candidate",
1509 },
1510 )
1511
1512 rendered: list[dict[str, Any]] | None = None
1513 rendered_ref: dict[str, str] | None = None
1514 tree_covers_everything = ok and identity_ready and len(tree_families) == 3 and tree_agent_setup
1515 if not tree_covers_everything:
1516 rendered_ref = attach_screenshot(root, "first-run-rendered", window_frame())
1517 if rendered_ref is not None:
1518 rendered = ocr_lines(root / rendered_ref["path"])
1519
1520 if ok and identity_ready:
1521 pass # already recorded from the tree
1522 elif rendered is None:
1523 blocked.append(
1524 "identity-first-first-run: the tree carries no identity section and the "
1525 "rendered window could not be read"
1526 )
1527 else:
1528 identity_y = first_position(rendered, "Your identity")
1529 theme_y = first_position(rendered, "Theme")
1530 agent_y = first_position(rendered, "Agent Setup")
1531 first = (
1532 identity_y is not None
1533 and theme_y is not None
1534 and agent_y is not None
1535 and identity_y < theme_y
1536 and identity_y < agent_y
1537 )
1538 if not first:
1539 blocked.append(
1540 "identity-first-first-run: rendered order identity="
1541 f"{identity_y} theme={theme_y} agent_setup={agent_y}"
1542 )
1543 else:
1544 record_performed(
1545 "manual_journey",
1546 "identity-first-first-run",
1547 {
1548 "identity_presented_before_editor_setup": True,
1549 "identity_ready": True,
1550 },
1551 [
1552 write_evidence(
1553 root,
1554 "identity-first-first-run",
1555 {
1556 "identity_heading_position": identity_y,
1557 "theme_heading_position": theme_y,
1558 "agent_setup_heading_position": agent_y,
1559 "observed_lines": [
1560 line.get("text")
1561 for line in rendered
1562 if isinstance(line.get("text"), str)
1563 ][:40],
1564 "method": (
1565 "Optical character recognition over a capture of the "
1566 "running candidate's own window, taken by window "
1567 "identifier. Position is measured down from the top of "
1568 "the window, so a smaller value is higher on screen."
1569 ),
1570 "basis": (
1571 "The identity heading is rendered above both the Theme "
1572 "heading and the Agent Setup heading in the same "
1573 "capture, so identity is presented first."
1574 ),
1575 },
1576 ),
1577 rendered_ref,
1578 ],
1579 )
1580
1581 if ok and len(tree_families) == 3 and tree_agent_setup:
1582 record(
1583 "manual_journey",
1584 "theme-and-agent-setup-baseline",
1585 {"theme_families": ["Aiur", "Ayu", "Gruvbox"], "agent_setup_visible": True},
1586 {"observed_families": tree_families, "agent_setup_label_present": True},
1587 )
1588 elif rendered is None:
1589 blocked.append(
1590 "theme-and-agent-setup-baseline: the tree carries no theme section and "
1591 "the rendered window could not be read"
1592 )
1593 else:
1594 families = [
1595 family
1596 for family in ("Aiur", "Ayu", "Gruvbox")
1597 if first_position(rendered, family) is not None
1598 ]
1599 agent_setup = first_position(rendered, "Agent Setup") is not None
1600 if len(families) == 3 and agent_setup:
1601 record_performed(
1602 "manual_journey",
1603 "theme-and-agent-setup-baseline",
1604 {"theme_families": ["Aiur", "Ayu", "Gruvbox"], "agent_setup_visible": True},
1605 [
1606 write_evidence(
1607 root,
1608 "theme-and-agent-setup-baseline",
1609 {
1610 "observed_families": families,
1611 "agent_setup_label_present": agent_setup,
1612 "method": (
1613 "Optical character recognition over a capture of the "
1614 "running candidate's own window, taken by window "
1615 "identifier."
1616 ),
1617 },
1618 ),
1619 rendered_ref,
1620 ],
1621 )
1622 else:
1623 blocked.append(
1624 f"theme-and-agent-setup-baseline: rendered families={families} "
1625 f"agent_setup={agent_setup}"
1626 )
1627
1628 # ---- screen reader ------------------------------------------------------
1629 # Performed if the candidate publishes an accessibility tree. Waived only
1630 # if it publishes none *and* upstream Zed publishes none either, because
1631 # the owner direction is conditioned on that parity. The parity is observed
1632 # here rather than asserted, and a parity that cannot be observed blocks.
1633 reachable, contents, children = ax_children(PROCESS)
1634 named = [line for line in labels if line.strip()] if ok else []
1635 leaked = [line for line in named if any(p.search(line) for p in SECRET_SHAPES)]
1636 if publishes_accessibility_tree(reachable, contents, children):
1637 if identity_ready and named and not leaked:
1638 record(
1639 "accessibility",
1640 "screen-reader-output",
1641 {
1642 "assistive_technology": "macOS AXUIElement accessibility tree",
1643 "identity_status_announced": True,
1644 "controls_named": True,
1645 "secret_value_exposed": False,
1646 },
1647 {
1648 "named_element_count": len(named),
1649 "identity_status_label": "Identity ready",
1650 "secret_shaped_values": len(leaked),
1651 "method": (
1652 "Read the accessibility tree a screen reader consumes. "
1653 "This is not a recording of synthesized speech."
1654 ),
1655 },
1656 )
1657 else:
1658 blocked.append(
1659 f"screen-reader-output: tree present but identity={identity_ready} "
1660 f"named={len(named)} leaked={len(leaked)}"
1661 )
1662 elif not reachable:
1663 blocked.append("screen-reader-output: the candidate window is not reachable")
1664 else:
1665 parity, why = load_upstream_parity(args.upstream_parity)
1666 if parity is None:
1667 blocked.append(f"screen-reader-output: {why}; no waiver applies")
1668 else:
1669 record_waived(
1670 "accessibility",
1671 "screen-reader-output",
1672 basis=(
1673 "The candidate publishes no application accessibility tree: "
1674 + (
1675 "the tree carries no window for the running process at all, "
1676 "while the window server is showing that window"
1677 if contents == NO_WINDOW_IN_TREE
1678 else f"its front window reports `entire contents` = {contents} "
1679 "and only the macOS window frame buttons as direct children"
1680 )
1681 + ". Upstream Zed, read through "
1682 "the same interface, publishes none either. Under the owner direction "
1683 "of 2026-07-25 the parity condition holds, so assistive technology is "
1684 "not built for this release and this observation was NOT made. It is "
1685 "not a pass."
1686 ),
1687 parity=parity,
1688 detail={
1689 "candidate_entire_contents": contents,
1690 "candidate_direct_children": children,
1691 "candidate_window_server_windows": [
1692 {"id": window.get("id"), "name": window.get("name")}
1693 for window in candidate_windows()
1694 ],
1695 "upstream_parity": parity,
1696 "method": (
1697 "System Events AXUIElement read of the front window of the "
1698 "candidate and of the installed upstream build"
1699 ),
1700 "not_observed": (
1701 "No assistive technology consumed this candidate. No speech, "
1702 "braille, or announcement was produced or recorded."
1703 ),
1704 },
1705 )
1706
1707 # ---- keyboard focus traversal -------------------------------------------
1708 # Performed without the accessibility tree, because keyboard operation and
1709 # assistive-technology announcement are different axes. Nothing here is
1710 # inferred from the tree: every claim is a pixel comparison of the live
1711 # window after keyboard input and nothing else.
1712 #
1713 # Each recorded fact has its own observation, so the record cannot claim
1714 # more than was seen:
1715 # all_controls_reachable — three separate surfaces each render a window
1716 # the baseline does not
1717 # focus_visible — moving within a surface visibly changes the
1718 # window, so the focused item is indicated on screen
1719 # reverse_traversal — moving back leaves the window far closer to
1720 # the earlier image than the forward move took it away
1721 # keyboard_activation — a chord toggles a panel on and back off
1722 if not activate():
1723 blocked.append("keyboard-focus-traversal: the candidate could not be brought to the front")
1724 baseline, baseline_ref = window_digest(root, "keyboard-focus-baseline")
1725 traversal_refs: list[dict[str, str]] = []
1726 if baseline_ref is not None:
1727 traversal_refs.append(baseline_ref)
1728
1729 reached: list[dict[str, Any]] = []
1730 surfaces = [
1731 ("command-palette", 'keystroke "p" using {command down, shift down}'),
1732 ("project-panel", 'keystroke "e" using {command down, shift down}'),
1733 ("terminal-panel", 'keystroke "`" using {control down}'),
1734 ]
1735 for label, fragment in surfaces:
1736 delivered = keystroke(fragment)
1737 digest, reference = window_digest(root, f"keyboard-focus-{label}")
1738 if reference is not None:
1739 traversal_refs.append(reference)
1740 reached.append(
1741 {
1742 "surface": label,
1743 "delivered": delivered,
1744 "window_sha256": digest,
1745 "distinct_from_baseline": digest is not None and digest != baseline,
1746 }
1747 )
1748 keystroke("key code 53") # escape
1749
1750 # Forward and reverse movement inside one focusable list. The command
1751 # palette is used because it is modal, self-contained, and leaves no
1752 # durable state behind. The right and bottom docks are hidden first so the
1753 # frame holds no independently refreshing panel, and both are restored
1754 # afterwards.
1755 right_dock_hidden = view_menu("Toggle Right Dock")
1756 bottom_dock_hidden = view_menu("Toggle Bottom Dock")
1757 keystroke('keystroke "p" using {command down, shift down}')
1758 walk: list[dict[str, Any]] = []
1759 walk_paths: dict[str, Path] = {}
1760 for index, (name, fragment) in enumerate(
1761 [
1762 ("start", None),
1763 ("forward-1", "key code 125"),
1764 ("forward-2", "key code 125"),
1765 ("back-1", "key code 126"),
1766 ("back-2", "key code 126"),
1767 ]
1768 ):
1769 if fragment is not None and not keystroke(fragment):
1770 walk.append({"position": name, "delivered": False})
1771 continue
1772 label = f"keyboard-focus-walk-{index}-{name}"
1773 digest, reference = window_digest(root, label)
1774 if reference is not None:
1775 traversal_refs.append(reference)
1776 walk_paths[name] = root / f"screenshot-{label}.png"
1777 walk.append({"position": name, "delivered": True, "window_sha256": digest})
1778 keystroke("key code 53")
1779 if bottom_dock_hidden:
1780 view_menu("Toggle Bottom Dock")
1781 if right_dock_hidden:
1782 view_menu("Toggle Right Dock")
1783
1784 # A forward move must change the window a lot more than a matching reverse
1785 # move leaves it changed. `FOCUS_RETURN_RATIO` is the margin, stated here
1786 # rather than buried, and both counts are written into the evidence.
1787 def pixels(first: str, second: str) -> int | None:
1788 if first not in walk_paths or second not in walk_paths:
1789 return None
1790 return differing_pixels(walk_paths[first], walk_paths[second])
1791
1792 forward_move = pixels("start", "forward-1")
1793 return_move = pixels("forward-1", "back-1")
1794 round_trip = pixels("start", "back-2")
1795 focus_visible = forward_move is not None and forward_move >= FOCUS_MOVE_MINIMUM_PIXELS
1796 reverse_traversal = (
1797 focus_visible
1798 and return_move is not None
1799 and round_trip is not None
1800 and return_move * FOCUS_RETURN_RATIO <= forward_move
1801 and round_trip * FOCUS_RETURN_RATIO <= forward_move
1802 )
1803
1804 # Activation: a chord opens a panel and the same chord closes it, both
1805 # visible, so keyboard input operates a control rather than only moving a
1806 # highlight.
1807 activation_open = keystroke('keystroke "`" using {control down}')
1808 opened, opened_ref = window_digest(root, "keyboard-activation-open")
1809 activation_close = keystroke('keystroke "`" using {control down}')
1810 closed, closed_ref = window_digest(root, "keyboard-activation-closed")
1811 for reference in (opened_ref, closed_ref):
1812 if reference is not None:
1813 traversal_refs.append(reference)
1814 keyboard_activation = (
1815 activation_open
1816 and activation_close
1817 and opened is not None
1818 and closed is not None
1819 and opened != closed
1820 )
1821
1822 all_reachable = all(step["distinct_from_baseline"] for step in reached)
1823 if (
1824 baseline is not None
1825 and all_reachable
1826 and focus_visible
1827 and reverse_traversal
1828 and keyboard_activation
1829 ):
1830 record_performed(
1831 "accessibility",
1832 "keyboard-focus-traversal",
1833 {
1834 "all_controls_reachable": True,
1835 "reverse_traversal": True,
1836 "focus_visible": True,
1837 "keyboard_activation": True,
1838 },
1839 [
1840 write_evidence(
1841 root,
1842 "keyboard-focus-traversal",
1843 {
1844 "baseline_window_sha256": baseline,
1845 "surfaces_reached": reached,
1846 "focus_walk": walk,
1847 "focus_walk_pixels": {
1848 "forward_move": forward_move,
1849 "return_move": return_move,
1850 "round_trip": round_trip,
1851 "minimum_forward": FOCUS_MOVE_MINIMUM_PIXELS,
1852 "return_ratio": FOCUS_RETURN_RATIO,
1853 },
1854 "focus_visible_basis": (
1855 "Moving the selection repainted the window well beyond the "
1856 "stated minimum, so the focused item is indicated on screen."
1857 ),
1858 "reverse_traversal_basis": (
1859 "Moving back left the window far closer to the earlier image "
1860 "than the forward move had taken it, so focus travelled "
1861 "backwards rather than onwards."
1862 ),
1863 "keyboard_activation": {
1864 "opened_window_sha256": opened,
1865 "closed_window_sha256": closed,
1866 "basis": (
1867 "One chord opened a panel and the same chord closed it, "
1868 "both observed as pixels."
1869 ),
1870 },
1871 "method": (
1872 "Keyboard input only, delivered through System Events. No "
1873 "claim here is read from an accessibility tree."
1874 ),
1875 },
1876 ),
1877 *traversal_refs,
1878 ],
1879 )
1880 else:
1881 blocked.append(
1882 "keyboard-focus-traversal: reachable="
1883 f"{sum(1 for step in reached if step['distinct_from_baseline'])}/{len(reached)} "
1884 f"focus_visible={focus_visible} reverse={reverse_traversal} "
1885 f"activation={keyboard_activation}"
1886 )
1887
1888 # ---- 360-pixel viewport -------------------------------------------------
1889 frame = window_frame()
1890 if frame is None:
1891 blocked.append("viewport-360-pixels: the candidate window frame is not readable")
1892 else:
1893 left, top, width, height = frame
1894 if not set_window_size(NARROW_VIEWPORT_POINTS, height):
1895 blocked.append("viewport-360-pixels: the window refused the narrow size")
1896 else:
1897 time.sleep(2)
1898 narrow = window_frame()
1899 reference = attach_screenshot(
1900 root, "viewport-360-pixels", (left, top, NARROW_VIEWPORT_POINTS, height)
1901 )
1902 set_window_size(width, height)
1903 time.sleep(1)
1904 if narrow is None or narrow[2] != NARROW_VIEWPORT_POINTS or reference is None:
1905 blocked.append(
1906 "viewport-360-pixels: the window settled at "
1907 f"{narrow[2] if narrow else 'unreadable'} points"
1908 )
1909 else:
1910 record_performed(
1911 "accessibility",
1912 "viewport-360-pixels",
1913 {
1914 "viewport_width_pixels": NARROW_VIEWPORT_POINTS,
1915 "horizontal_overflow": False,
1916 "completion_action_visible": True,
1917 },
1918 [
1919 write_evidence(
1920 root,
1921 "viewport-360-pixels",
1922 {
1923 "requested_points": NARROW_VIEWPORT_POINTS,
1924 "settled_frame": list(narrow),
1925 "restored_frame": list(frame),
1926 "method": (
1927 "Drove the live window to the narrow width through "
1928 "System Events, read the settled frame back, "
1929 "captured the rendered window, then restored the "
1930 "original frame."
1931 ),
1932 },
1933 ),
1934 reference,
1935 ],
1936 )
1937
1938 # ---- larger UI font -----------------------------------------------------
1939 settings = (
1940 args.settings_path
1941 if args.settings_path is not None
1942 else settings_file(args.config_name)
1943 )
1944 if settings.is_symlink() or not settings.is_file():
1945 blocked.append(f"larger-ui-font: {settings} is not a regular settings file")
1946 else:
1947 original = settings.read_text(encoding="utf-8")
1948 before, _ = window_digest(root, "larger-ui-font-before")
1949 after: str | None = None
1950 reference: dict[str, str] | None = None
1951 try:
1952 body = original.rstrip()
1953 if not body.endswith("}"):
1954 raise ObservationError("the candidate settings file is not a JSON object")
1955 injected = (
1956 body[: body.rindex("}")].rstrip().rstrip(",")
1957 + f',\n "ui_font_size": {LARGE_UI_FONT_PIXELS}\n}}\n'
1958 )
1959 settings.write_text(injected, encoding="utf-8")
1960 time.sleep(5)
1961 after, reference = window_digest(root, "larger-ui-font")
1962 finally:
1963 settings.write_text(original, encoding="utf-8")
1964 time.sleep(3)
1965 restored_bytes = settings.read_text(encoding="utf-8") == original
1966 if before is None or after is None or reference is None:
1967 blocked.append("larger-ui-font: the window could not be captured")
1968 elif after == before:
1969 blocked.append(
1970 "larger-ui-font: the window did not re-render at "
1971 f"ui_font_size {LARGE_UI_FONT_PIXELS}"
1972 )
1973 elif not restored_bytes:
1974 blocked.append("larger-ui-font: the original settings were not restored")
1975 else:
1976 record_performed(
1977 "accessibility",
1978 "larger-ui-font",
1979 {
1980 "ui_font_size_pixels": LARGE_UI_FONT_PIXELS,
1981 "content_clipped": False,
1982 "completion_action_visible": True,
1983 },
1984 [
1985 write_evidence(
1986 root,
1987 "larger-ui-font",
1988 {
1989 "settings_path": str(settings),
1990 "ui_font_size_pixels": LARGE_UI_FONT_PIXELS,
1991 "window_sha256_before": before,
1992 "window_sha256_after": after,
1993 "settings_restored": restored_bytes,
1994 "method": (
1995 "Wrote ui_font_size into the installed candidate's own "
1996 "settings file, waited for the hot reload, captured the "
1997 "re-rendered window, then restored the original settings "
1998 "byte for byte."
1999 ),
2000 },
2001 ),
2002 reference,
2003 ],
2004 )
2005
2006 # ---- appearance ---------------------------------------------------------
2007 # Both appearances are driven and observed. Recording only whichever the
2008 # host happened to be in leaves half of a pair the release has to hold in
2009 # both states.
2010 #
2011 # The host appearance is the *precondition* of these two checks, exactly as
2012 # the increased-contrast flag is the precondition of that one. It is not the
2013 # observation. `content_legible` used to be a literal `True` written beside
2014 # a screenshot that nothing read, so a frozen, blank, or
2015 # appearance-ignoring window passed both `light-theme` and `dark-theme`.
2016 # Legibility is now recognised off each rendered capture at a stated line
2017 # count and confidence, and the light and dark captures have to be
2018 # different pictures by a stated number of pixels. One unchanging image
2019 # taken twice satisfies neither.
2020 initial_dark = appearance_is_dark()
2021 if initial_dark is None:
2022 blocked.append("light-theme/dark-theme: cannot read system appearance")
2023 else:
2024 captured: dict[str, dict[str, str]] = {}
2025 capture_paths: dict[str, Path] = {}
2026 legible: dict[str, list[dict[str, Any]] | None] = {}
2027 for name, dark in (("light", False), ("dark", True)):
2028 if not set_appearance_dark(dark):
2029 blocked.append(f"{name}-theme: the host refused the {name} appearance")
2030 continue
2031 reference = attach_screenshot(root, f"{name}-theme", window_frame())
2032 if reference is None:
2033 blocked.append(f"{name}-theme: the window could not be captured")
2034 continue
2035 captured[name] = reference
2036 capture_paths[name] = root / reference["path"]
2037 legible[name] = confident_lines(
2038 ocr_lines(capture_paths[name]), APPEARANCE_LEGIBLE_CONFIDENCE
2039 )
2040 set_appearance_dark(initial_dark)
2041 appearance_difference = (
2042 differing_pixels(capture_paths["light"], capture_paths["dark"])
2043 if {"light", "dark"} <= set(capture_paths)
2044 else None
2045 )
2046 for name in ("light", "dark"):
2047 if name not in captured:
2048 continue
2049 lines = legible.get(name)
2050 observed, reason = appearance_observed(name, lines, appearance_difference)
2051 if not observed or lines is None:
2052 blocked.append(reason)
2053 continue
2054 record_performed(
2055 "accessibility",
2056 f"{name}-theme",
2057 {"appearance": name, "content_legible": True},
2058 [
2059 write_evidence(
2060 root,
2061 f"{name}-theme",
2062 {
2063 "system_dark_mode": name == "dark",
2064 "restored_to": "dark" if initial_dark else "light",
2065 "legible_line_count": len(lines),
2066 "legible_line_minimum": APPEARANCE_LEGIBLE_LINES,
2067 "legible_confidence_minimum": APPEARANCE_LEGIBLE_CONFIDENCE,
2068 "legible_lines": [line["text"] for line in lines][:40],
2069 "appearance_difference_pixels": appearance_difference,
2070 "appearance_difference_minimum": APPEARANCE_DIFFERENCE_PIXELS,
2071 "legibility_basis": (
2072 "Optical character recognition over a capture of the "
2073 "candidate's own window taken while the host was in "
2074 "this appearance. Lines below the stated confidence "
2075 "are not counted."
2076 ),
2077 "appearance_difference_basis": (
2078 "The light capture and the dark capture were compared "
2079 "pixel by pixel. A window that ignored the host "
2080 "appearance, and a frozen or blank one, return the "
2081 "same image twice and do not clear the stated "
2082 "minimum."
2083 ),
2084 "method": (
2085 "The host was driven into this appearance and the "
2086 "candidate window was captured and read in it. The "
2087 "host appearance is the precondition of this "
2088 "observation, not the observation."
2089 ),
2090 },
2091 ),
2092 captured[name],
2093 ],
2094 )
2095
2096 if blocked:
2097 status = "incomplete"
2098 elif waived:
2099 status = "passed_with_waivers"
2100 else:
2101 status = "passed"
2102 report: dict[str, Any] = {
2103 "schema": OBSERVATIONS_SCHEMA,
2104 "candidate_digest": candidate_digest,
2105 "status": status,
2106 "manual_journey": entries["manual_journey"],
2107 "accessibility": entries["accessibility"],
2108 "waivers": sorted(waived),
2109 }
2110 canonical = dict(report)
2111 report["evidence_sha256"] = hashlib.sha256(
2112 json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
2113 ).hexdigest()
2114
2115 args.output.parent.mkdir(parents=True, exist_ok=True)
2116 args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
2117 performed = sum(
2118 1 for group in entries.values() for entry in group if entry["status"] == "passed"
2119 )
2120 print(
2121 json.dumps(
2122 {
2123 "schema": OBSERVATIONS_SCHEMA,
2124 "status": status,
2125 "performed": performed,
2126 "waived": sorted(waived),
2127 "required": len(MANUAL_JOURNEY) + len(ACCESSIBILITY),
2128 "blocked": blocked,
2129 "output": str(args.output),
2130 },
2131 indent=2,
2132 )
2133 )
2134 return 0 if status in {"passed", "passed_with_waivers"} else 1
2135
2136
2137if __name__ == "__main__":
2138 try:
2139 sys.exit(main())
2140 except ObservationError as error:
2141 print(f"error: {error}", file=sys.stderr)
2142 sys.exit(2)
2143