Skip to content
Merged
130 changes: 112 additions & 18 deletions .github/workflows/nix-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,24 +82,26 @@ jobs:
visual: true
result_link: result-linux-background-gui-gtk3-mousepad
artifact_name: cua-driver-linux-background-gui-gtk3-mousepad
- name: Linux background GUI test (gtk3-geany)
check_attr: cua-driver-linux-background-gui-gtk3-geany
timeout_minutes: 15
visual: true
result_link: result-linux-background-gui-gtk3-geany
artifact_name: cua-driver-linux-background-gui-gtk3-geany
# gtk3-geany temporarily disabled (huge AT-SPI tree grinds the emulated VM; job times out)
# - name: Linux background GUI test (gtk3-geany)
# check_attr: cua-driver-linux-background-gui-gtk3-geany
# timeout_minutes: 15
# visual: true
# result_link: result-linux-background-gui-gtk3-geany
# artifact_name: cua-driver-linux-background-gui-gtk3-geany
- name: Linux background GUI test (gtk3-scite)
check_attr: cua-driver-linux-background-gui-gtk3-scite
timeout_minutes: 15
visual: true
result_link: result-linux-background-gui-gtk3-scite
artifact_name: cua-driver-linux-background-gui-gtk3-scite
- name: Linux background GUI test (gtk3-abiword)
check_attr: cua-driver-linux-background-gui-gtk3-abiword
timeout_minutes: 15
visual: true
result_link: result-linux-background-gui-gtk3-abiword
artifact_name: cua-driver-linux-background-gui-gtk3-abiword
# gtk3-abiword temporarily disabled (huge AT-SPI tree grinds the emulated VM; job times out)
# - name: Linux background GUI test (gtk3-abiword)
# check_attr: cua-driver-linux-background-gui-gtk3-abiword
# timeout_minutes: 15
# visual: true
# result_link: result-linux-background-gui-gtk3-abiword
# artifact_name: cua-driver-linux-background-gui-gtk3-abiword
# GTK4
- name: Linux background GUI test (gtk4-characters)
check_attr: cua-driver-linux-background-gui-gtk4-characters
Expand Down Expand Up @@ -239,15 +241,15 @@ jobs:
if: always() && matrix.visual
run: |
mkdir -p artifacts
find -L "${{ matrix.result_link }}/" -name '*.gif' -type f -exec cp {} artifacts/ \; 2>/dev/null || true
ls -la artifacts/ 2>/dev/null || echo "No GIF artifacts found"
find -L "${{ matrix.result_link }}/" \( -name '*.gif' -o -name '*.png' -o -name '*.json' \) -type f -exec cp {} artifacts/ \; 2>/dev/null || true
ls -la artifacts/ 2>/dev/null || echo "No visual artifacts found"

- name: Upload GIF artifacts
if: always() && matrix.visual
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ${{ matrix.artifact_name }}
path: artifacts/*.gif
path: artifacts/*
if-no-files-found: warn

- name: Sign and upload to Nix cache
Expand Down Expand Up @@ -280,9 +282,7 @@ jobs:
'cua-driver-linux-background-gui-tk',
'cua-driver-linux-background-gui-gtk3-gedit',
'cua-driver-linux-background-gui-gtk3-mousepad',
'cua-driver-linux-background-gui-gtk3-geany',
'cua-driver-linux-background-gui-gtk3-scite',
'cua-driver-linux-background-gui-gtk3-abiword',
'cua-driver-linux-background-gui-gtk4-characters',
'cua-driver-linux-background-gui-qt5-manuskript',
'cua-driver-linux-background-gui-qt5-klog',
Expand All @@ -294,10 +294,14 @@ jobs:
'cua-driver-linux-background-gui-electron-zettlr',
'cua-driver-linux-background-gui-electron-joplin',
'cua-driver-linux-background-gui-electron-logseq',
'cua-driver-linux-som-overlays',
];

let body = `${marker}\n## Linux visual regression artifacts\n\n`;
body += 'Matrix jobs now run independently. Download GIF artifacts from this workflow run:\n';
body += 'Matrix jobs now run independently. Download visual artifacts from this workflow run.\n';
body += 'Each background-GUI job uploads a `.gif` of the interaction plus two annotated PNGs ';
body += '(`<app>.png` raw, `<app>-atspi.png` with AT-SPI element boxes); ';
body += 'the `cua-driver-linux-som-overlays` artifact adds `<app>-som.png` cua Set-of-Marks overlays:\n';
for (const artifactName of artifactNames) {
body += `- \`${artifactName}\`\n`;
}
Expand Down Expand Up @@ -328,3 +332,93 @@ jobs:
body,
});
}

# ── Set-of-Marks overlays ───────────────────────────────────────────────────
# Downstream aggregate job: consumes the raw `<app>.png` screenshots uploaded
# by the background-GUI matrix and emits `<app>-som.png` cua Set-of-Marks
# overlays. Deliberately OFF the hot path — `needs: [nix-checks]` + `if:
# always()` so it runs after the matrix regardless of pass/fail and never
# blocks it. NOTE: `pip install -e libs/python/som` pulls torch + ultralytics +
# easyocr (~GBs) and downloads model weights at first parse, so this job is
# the slow/expensive one; it is intentionally isolated from the build matrix.
som-annotate:
name: Annotate screenshots with cua Set-of-Marks
if: always()
needs: [nix-checks]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Download all run artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: downloaded-artifacts

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"

- name: Install cua-som
run: |
python -m pip install --upgrade pip
pip install -e libs/python/som

- name: Generate Set-of-Marks overlays
run: |
mkdir -p som-overlays
python - <<'PY'
import base64, glob, os, sys, traceback

# Collect raw background-GUI screenshots: <app>.png produced by the
# skeleton matrix. Exclude the AT-SPI overlays (-atspi) and any prior
# SoM outputs (-som). Artifacts land under downloaded-artifacts/<name>/.
candidates = []
for p in glob.glob("downloaded-artifacts/**/*-background-gui-*.png", recursive=True):
stem = os.path.basename(p)
if stem.endswith("-atspi.png") or stem.endswith("-som.png"):
continue
candidates.append(p)
candidates = sorted(set(candidates))
print(f"Found {len(candidates)} raw screenshot(s) to annotate:", flush=True)
for p in candidates:
print(f" {p}", flush=True)

if not candidates:
print("No raw screenshots found; nothing to annotate.", flush=True)
sys.exit(0)

from som import OmniParser
parser = OmniParser()

ok = 0
for p in candidates:
try:
with open(p, "rb") as f:
data = f.read()
result = parser.parse(data)
b64 = result.annotated_image_base64
# Strip a possible data URL prefix before decoding.
if "," in b64 and b64.strip().startswith("data:"):
b64 = b64.split(",", 1)[1]
out_name = os.path.splitext(os.path.basename(p))[0] + "-som.png"
out_path = os.path.join("som-overlays", out_name)
with open(out_path, "wb") as f:
f.write(base64.b64decode(b64))
print(f"OK {p} -> {out_path}", flush=True)
ok += 1
except Exception:
print(f"FAIL {p}", flush=True)
traceback.print_exc()
print(f"Annotated {ok}/{len(candidates)} screenshot(s).", flush=True)
PY
ls -la som-overlays/ 2>/dev/null || echo "No SoM overlays produced"

- name: Upload Set-of-Marks overlays
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: cua-driver-linux-som-overlays
path: som-overlays/*-som.png
if-no-files-found: warn
8 changes: 6 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,13 @@
# GTK3
"gtk3-gedit"
"gtk3-mousepad"
"gtk3-geany"
# gtk3-geany / gtk3-abiword temporarily disabled: their huge
# AT-SPI trees make the bounds walk + recorder grind in the
# emulated CI VM and the jobs time out. Re-enable once the
# walk is fast enough for 700+-node trees.
# "gtk3-geany"
"gtk3-scite"
"gtk3-abiword"
# "gtk3-abiword"
# GTK4
"gtk4-characters"
# Qt5
Expand Down
8 changes: 8 additions & 0 deletions libs/cua-driver/rust/crates/platform-linux/src/atspi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ pub fn insert_text(pid: u32, text: &str) -> Result<bool> {
}

/// Get the screen-coordinate bounding box (x, y, width, height) of element `idx`.
/// Screen-coordinate bounds for every action node in pid's AT-SPI tree, keyed
/// by `element_index`. Best-effort: nodes whose bounds can't be read are
/// omitted rather than erroring the whole call. Returns `(element_index, x, y,
/// width, height)` tuples in screen coordinates.
pub fn get_all_element_bounds(pid: u32) -> Result<Vec<(usize, i32, i32, u32, u32)>> {
native::get_all_element_bounds(pid)
}

pub fn get_element_bounds(pid: u32, idx: usize) -> Result<(i32, i32, u32, u32)> {
native::get_element_bounds(pid, idx)
}
Expand Down
63 changes: 63 additions & 0 deletions libs/cua-driver/rust/crates/platform-linux/src/atspi/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -670,3 +670,66 @@ pub fn get_element_bounds(pid: u32, idx: usize) -> Result<(i32, i32, u32, u32)>
Ok((x, y, w.max(0) as u32, h.max(0) as u32))
})
}

/// Screen-coordinate bounds for every action node in the tree, keyed by the
/// same `element_index` used by [`walk_tree`]/`get_element_bounds`.
///
/// Walks the application once (unlike calling `get_element_bounds` per node,
/// which would reconnect and re-walk every time) and queries each node's
/// `Component.GetExtents(Screen)`. Nodes without a usable Component interface,
/// or whose extents query fails/times out, are silently skipped — the result is
/// best-effort and never errors on a per-node hiccup.
///
/// Returns `(element_index, x, y, width, height)` tuples.
pub fn get_all_element_bounds(pid: u32) -> Result<Vec<(usize, i32, i32, u32, u32)>> {
runtime().block_on(async {
let conn = AccessibilityConnection::new()
.await
.map_err(|e| anyhow!("AT-SPI connect failed: {e}"))?;
let visited = collect_visited(&conn, pid)
.await?
.ok_or_else(|| anyhow!("no AT-SPI application for pid {pid}"))?;
let action_nodes: Vec<&Visited> = visited.iter().filter(|v| !v.actions.is_empty()).collect();
// Each element costs ~3 D-Bus round-trips (proxies + component +
// GetExtents). Big trees (geany exposes ~787 nodes) would grind for
// minutes and time out callers, so cap the walk; pre-order means the
// first nodes are the window chrome / toolbars that are actually
// visible, which is what bounds consumers (overlays, targeting) need.
const MAX_BOUNDS_NODES: usize = 150;
// Hard wall-clock budget for the whole collection: on pathological
// trees individual D-Bus calls each burn up to CALL_TIMEOUT (geany's
// unrealized nodes did exactly that), so a per-node cap alone can
// still add up to minutes. Return whatever was collected in time.
let deadline = std::time::Instant::now() + Duration::from_secs(20);
let mut out = Vec::with_capacity(action_nodes.len().min(MAX_BOUNDS_NODES));
for (idx, node) in action_nodes.iter().enumerate().take(MAX_BOUNDS_NODES) {
if std::time::Instant::now() >= deadline {
dlog!("get_all_element_bounds: 20s budget exhausted at node {idx}; returning {} bound(s)", out.len());
break;
}
if !node.has_component {
continue;
}
let proxies = match call(node.acc.proxies()).await {
Some(Ok(p)) => p,
_ => continue,
};
let comp = match call(proxies.component()).await {
Some(Ok(c)) => c,
_ => continue,
};
if let Some(Ok((x, y, w, h))) = call(comp.get_extents(CoordType::Screen)).await {
// Unrealized widgets (e.g. items inside closed menus/popovers)
// report GetExtents as the i32::MIN sentinel and/or a degenerate
// 0x0 / 1x1 size. Emitting those poisons downstream consumers
// (overlay renderers, click targeting), so keep only elements
// with plausible on-screen geometry.
if x == i32::MIN || y == i32::MIN || x < -16384 || y < -16384 || w <= 1 || h <= 1 {
continue;
}
out.push((idx, x, y, w as u32, h as u32));
}
}
Ok(out)
})
}
36 changes: 34 additions & 2 deletions libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,13 @@ impl Tool for GetWindowStateTool {
} else {
None
};
// Best-effort per-element screen bounds (AT-SPI Component.GetExtents).
// Tolerant: an empty/missing map never fails the call.
let bounds = if do_tree {
crate::atspi::get_all_element_bounds(pid).unwrap_or_default()
} else {
Vec::new()
};
let screenshot = if do_shot {
match crate::capture::screenshot_window_bytes(xid) {
Ok(raw) => {
Expand All @@ -377,11 +384,11 @@ impl Tool for GetWindowStateTool {
} else {
None
};
Ok((tree_result, screenshot))
Ok((tree_result, screenshot, bounds))
}).await;

match result {
Ok(Ok((tree_opt, shot_opt))) => {
Ok(Ok((tree_opt, shot_opt, bounds))) => {
let mut content = Vec::new();
let mut structured = json!({ "window_id": xid, "pid": pid });

Expand All @@ -392,6 +399,31 @@ impl Tool for GetWindowStateTool {
state.element_cache.update(pid, xid, &tr.nodes);
structured["element_count"] = json!(count);
structured["tree_markdown"] = json!(tr.tree_markdown);

// Additive `elements` array: one entry per tree node that
// has an element_index AND a successfully-resolved screen
// bounds (AT-SPI Component.GetExtents(Screen)).
use std::collections::HashMap;
let bounds_by_idx: HashMap<usize, (i32, i32, u32, u32)> = bounds
.into_iter()
.map(|(i, x, y, w, h)| (i, (x, y, w, h)))
.collect();
let elements: Vec<serde_json::Value> = tr
.nodes
.iter()
.filter_map(|n| {
let idx = n.element_index?;
let (x, y, w, h) = bounds_by_idx.get(&idx).copied()?;
Some(json!({
"element_index": idx,
"role": n.role,
"name": n.name,
"x": x, "y": y,
"width": w, "height": h,
}))
})
.collect();
structured["elements"] = json!(elements);
}

if let Some((b64, w, h, orig_w)) = shot_opt {
Expand Down
Loading
Loading