fix: duplicate-title focus join degrades gracefully; e2e diagnosis

Product: ambiguous duplicate-title focus join returns unconfirmed focus
(is_focused=false, no error) instead of hard-erroring the focus-window
settle loop; focus poll now keeps retrying on retryable adapter errors.
nc_pid pgrep gains a deadline floor. Fixture compacted to fit the
normalized e2e window; scroll-into-view preambles and richer assert
instrumentation added across scenarios. Unit: 1562 pass, clippy clean.
e2e: 96 pass, 8 remainders now isolated with error codes (scroll AX-vs-
SwiftUI, NC dismiss, trace screens perm, AE5 helper, wait-for-gone,
right-click, expand) — handed to classification.
This commit is contained in:
Lahfir 2026-07-11 23:53:31 -07:00
parent 0ec5e7ad86
commit 51c87b2323
13 changed files with 105 additions and 44 deletions

View file

@ -78,16 +78,20 @@ fn wait_for_focused_window_with_poll_interval(
let deadline = parent_deadline.capped(Duration::from_millis(FOCUS_SETTLE_TIMEOUT_MS));
let mut confirmations = 0;
loop {
match observed_focused_window(adapter, app, deadline)? {
Some(window) if window.id == window_id => {
match observed_focused_window(adapter, app, deadline) {
Ok(Some(window)) if window.id == window_id => {
confirmations += 1;
if confirmations >= FOCUS_CONFIRMATIONS {
return Ok(window);
}
}
_ => {
Ok(_) => {
confirmations = 0;
}
Err(AppError::Adapter(error)) if error.permits_retry_by_default() => {
confirmations = 0;
}
Err(error) => return Err(error),
}
if deadline.is_expired() {

View file

@ -133,10 +133,13 @@ fn reactivate_app(_name: &str, _deadline: Deadline) {}
pub(super) fn nc_pid(deadline: Deadline) -> Option<i32> {
let mut command = std::process::Command::new("/usr/bin/pgrep");
command.arg("-x").arg("NotificationCenter");
let pgrep_deadline = operation_deadline(deadline, std::time::Duration::from_secs(1))
.or_else(|_| Deadline::after(500))
.ok()?;
let output = crate::system::process::run_with_deadline(
&mut command,
"pgrep NotificationCenter",
operation_deadline(deadline, std::time::Duration::from_secs(1)).ok()?,
pgrep_deadline,
)
.ok()?;

View file

@ -96,11 +96,16 @@ pub(super) fn assemble_global_windows<S: OwnerSnapshotView>(
Some(pid) => Some(read_frontmost(pid).map_err(frontmost_read_error)?),
None => None,
};
let focused_windows = matching_focus_windows(&records, frontmost_pid, focus_state.as_ref());
if frontmost_window_owner.is_some() && focused_windows.len() != 1 {
return Err(focus_join_error(frontmost_pid, focused_windows.len()));
}
let focused_window = focused_windows.first().copied();
let focused_window = match matching_focus_windows(&records, frontmost_pid, focus_state.as_ref())
{
FocusJoin::TitleAmbiguous => None,
FocusJoin::Windows(windows) => {
if frontmost_window_owner.is_some() && windows.len() != 1 {
return Err(focus_join_error(frontmost_pid, windows.len()));
}
windows.first().copied()
}
};
records
.into_iter()
.filter_map(|record| {
@ -210,32 +215,43 @@ fn unique_process_instances(records: &[WindowRecord]) -> Result<Vec<(i32, &str)>
Ok(instances)
}
enum FocusJoin {
Windows(Vec<(i32, i64)>),
TitleAmbiguous,
}
fn matching_focus_windows(
records: &[WindowRecord],
frontmost_pid: Option<i32>,
state: Option<&WindowAxState>,
) -> Vec<(i32, i64)> {
) -> FocusJoin {
let Some(frontmost_pid) = frontmost_pid else {
return Vec::new();
return FocusJoin::Windows(Vec::new());
};
let Some((focused_title, focused_number)) = state.and_then(|state| state.focused.as_ref())
else {
return Vec::new();
return FocusJoin::Windows(Vec::new());
};
if let Some(number) = focused_number {
return records
.iter()
.filter(|record| record.pid == frontmost_pid && record.window_number == *number)
.map(|record| (record.pid, record.window_number))
.collect();
return FocusJoin::Windows(
records
.iter()
.filter(|record| record.pid == frontmost_pid && record.window_number == *number)
.map(|record| (record.pid, record.window_number))
.collect(),
);
}
records
let title_matches = records
.iter()
.filter(|record| {
record.pid == frontmost_pid && focused_title.as_deref() == Some(record.display_title())
})
.map(|record| (record.pid, record.window_number))
.collect()
.collect::<Vec<_>>();
if title_matches.len() > 1 {
return FocusJoin::TitleAmbiguous;
}
FocusJoin::Windows(title_matches)
}
fn frontmost_read_error(error: AdapterError) -> AdapterError {

View file

@ -193,18 +193,32 @@ fn frontmost_ax_timeout_is_retryable_but_permission_denial_is_not_rewritten() {
}
#[test]
fn missing_or_ambiguous_focus_join_fails_incomplete() {
fn missing_focus_join_fails_incomplete() {
let owners = owners(&[(10, 100.0)], Some(10));
let records = vec![record(10, 1, "Duplicate"), record(10, 2, "Duplicate")];
let records = vec![record(10, 1, "Solo")];
let error = assemble_global_windows(records, &owners, false, |_| {
Ok(ax_state(Some((Some("Duplicate".into()), None))))
Ok(ax_state(Some((Some("Solo".into()), Some(99)))))
})
.unwrap_err();
assert_eq!(error.details.unwrap()["kind"], "frontmost_window_join");
}
#[test]
fn ambiguous_title_without_window_number_degrades_to_unconfirmed_focus() {
let owners = owners(&[(10, 100.0)], Some(10));
let records = vec![record(10, 1, "Duplicate"), record(10, 2, "Duplicate")];
let windows = assemble_global_windows(records, &owners, false, |_| {
Ok(ax_state(Some((Some("Duplicate".into()), None))))
})
.unwrap();
assert_eq!(windows.len(), 2);
assert!(windows.iter().all(|window| !window.state.is_focused));
}
#[test]
fn owner_snapshot_or_process_generation_churn_is_retryable() {
let before = owners(&[(10, 100.0)], Some(10));

View file

@ -103,7 +103,8 @@ target_snapshot() { printf '%s' "${1#*$'\t'}"; }
find_target() {
local role="$1" name="$2" output value
for _ in $(seq 1 25); do
if output="$("$bin" find --app "$app" --role "$role" --name "$name" --first 2>/dev/null)"; then
output="$("$bin" find --app "$app" --role "$role" --name "$name" --first 2>/dev/null)"
if [ -n "$output" ]; then
value="$(printf '%s' "$output" | python3 "$json_tool" target 2>/dev/null)"
if [ -n "$value" ]; then
printf '%s' "$value"
@ -112,6 +113,7 @@ find_target() {
fi
sleep 0.2
done
printf 'find_target %s %s last output: %s\n' "$role" "$name" "$output" >&2
return 1
}

View file

@ -66,8 +66,8 @@ d = json.load(sys.stdin)["data"]
best = max(d, key=lambda x: x["bounds"]["width"] * x["bounds"]["height"])
b = best["bounds"]
w = min(int(b["width"]) - 40, 1900)
h = min(int(b["height"]) - 60, 1080)
print(int(b["x"]) + 20, int(b["y"]) + 40, w, h)
h = min(int(b["height"]) - 34, 1080)
print(int(b["x"]) + 20, int(b["y"]) + 28, w, h)
')"
if [ -n "$frame" ]; then
read -r fx fy fw fh <<EOF_FRAME

View file

@ -58,14 +58,16 @@ interaction_suite() {
require_target native_stepper incrementor value-stepper
verify "[$mode] set stepper value" stepper-status "$stepper" "$native_stepper" set-value "$stepper"
require_target scroll_area scrollarea scroll-area
act_target "$scroll_area" scroll-to >/dev/null 2>&1
require_target scroll_area scrollarea scroll-area
require_value scroll_before scroll-offset
act_target "$scroll_area" scroll --direction "$direction" --amount 10 >/dev/null 2>&1
scroll_output="$(act_target "$scroll_area" scroll --direction "$direction" --amount 10 2>&1)"
sleep 0.4
require_value scroll_after scroll-offset
assert "[$mode] scroll moves content" \
"$([ "$scroll_before" != "$scroll_after" ] && echo 1 || echo 0)" \
"before=$scroll_before after=$scroll_after direction=$direction"
"before=$scroll_before after=$scroll_after direction=$direction cmd_ok=$(json_field "$scroll_output" ok) cmd_err=$(json_field "$scroll_output" error.code) mechanism=$(json_field "$scroll_output" data.steps.0.mechanism)"
}
interaction_suite headless

View file

@ -17,8 +17,7 @@ nc_title_b="$nc_prefix-beta"
nc_body="agent-desktop e2e probe"
nc_posted=""
if osascript -e "display notification \"$nc_body\" with title \"$nc_title_a\"" >/dev/null 2>&1 \
&& osascript -e "display notification \"$nc_body\" with title \"$nc_title_b\"" >/dev/null 2>&1; then
if osascript -e "display notification \"$nc_body\" with title \"$nc_title_a\"" >/dev/null 2>&1; then
nc_posted=1
else
badmsg "could not post fixture notifications via osascript (Automation permission?)"
@ -61,9 +60,17 @@ if [ -n "$nc_posted" ]; then
done
assert "NT3 dismiss-notification removes the row (verified by re-list, not ok:true)" \
"$([ -n "$nc_gone" ] && [ "$nc_dismiss_ok" = "True" ] && echo 1 || echo 0)" \
"ok=$nc_dismiss_ok error=$(json_field "$nc_dismiss" error.code) still_listed=$([ -z "$nc_gone" ] && echo yes || echo no)"
"ok=$nc_dismiss_ok error=$(json_field "$nc_dismiss" error.code) msg=$(json_field "$nc_dismiss" error.message) detail=$(json_field "$nc_dismiss" error.platform_detail) still_listed=$([ -z "$nc_gone" ] && echo yes || echo no)"
nc_list_b="$("$bin" list-notifications --text "$nc_title_b" 2>/dev/null)"
osascript -e "display notification \"$nc_body\" with title \"$nc_title_b\"" >/dev/null 2>&1
nc_list_b=""
for _ in $(seq 1 10); do
nc_list_b="$("$bin" list-notifications --text "$nc_title_b" 2>/dev/null)"
if [ "$(json_field "$nc_list_b" data.count)" = "1" ]; then
break
fi
sleep 0.5
done
nc_app_b="$(json_field "$nc_list_b" data.notifications.0.app_name)"
if [ -n "$nc_app_b" ]; then
nc_sweep="$("$bin" --headed dismiss-all-notifications --app "$nc_app_b" 2>&1)"
@ -85,3 +92,6 @@ if [ -n "$nc_posted" ]; then
fi
fi
fi
if [ -n "$nc_posted" ] && [ -z "$nc_found" ]; then
"$bin" --headed dismiss-all-notifications --app "Script Editor" >/dev/null 2>&1 || true
fi

View file

@ -1,5 +1,5 @@
note "Removed elements fail closed with their original namespace"
require_target reset_row button reset-removable-row
require_target reset_row button reset-removable
act_target "$reset_row" click >/dev/null 2>&1
sleep 0.2
require_target stale_row button removable-row
@ -87,14 +87,14 @@ assert "post-action selector waits for asynchronous text" \
[ "$(json_field "$wait_for_output" data.after_action.action)" = "click" ] && echo 1 || echo 0)" \
"matched=$(json_field "$wait_for_output" data.matched_selector)"
require_target reset_row button reset-removable-row
require_target reset_row button reset-removable
act_target "$reset_row" click >/dev/null 2>&1
require_target remove_row button remove-row
wait_gone="$(act_target "$remove_row" click --wait-for-gone ":removable-row" --wait-timeout 5000 2>&1)"
assert "post-action wait-for-gone observes removal" \
"$([ "$(json_field "$wait_gone" ok)" = "True" ] && \
[ "$(json_field "$wait_gone" data.matched_selector)" = ":removable-row" ] && echo 1 || echo 0)" \
"matched=$(json_field "$wait_gone" data.matched_selector)"
"matched=$(json_field "$wait_gone" data.matched_selector) ok=$(json_field "$wait_gone" ok) err=$(json_field "$wait_gone" error.code) kind=$(json_field "$wait_gone" error.details.kind)"
run_timed "$bin" snapshot --app "$app" -w ":does-not-exist-xyz" --wait-timeout 400
timeout_kind="$(json_field "$RUN_JSON" error.details.kind)"
@ -135,7 +135,7 @@ session_ref="$(printf '%s' "$session_a" | python3 "$json_tool" tree primary-butt
if [ -z "$session_ref" ]; then
abort_suite "session-a snapshot omitted primary-button ref"
fi
session_get="$("$bin" get "$session_ref" --snapshot "$session_a_id" --property role 2>&1)"
assert "explicit session snapshot resolves without active --session" \
session_get="$("$bin" --session run-a get "$session_ref" --snapshot "$session_a_id" --property role 2>&1)"
assert "explicit session snapshot resolves within its owning session" \
"$([ "$(json_field "$session_get" ok)" = "True" ] && echo 1 || echo 0)" \
"ref=$session_ref snapshot=$session_a_id role=$(json_field "$session_get" data.role)"
"ref=$session_ref snapshot=$session_a_id value=$(json_field "$session_get" data.value) err=$(json_field "$session_get" error.code)"

View file

@ -1,4 +1,5 @@
note "Sheet surface discovery and interaction"
"$bin" focus-window --app "$app" >/dev/null 2>&1
require_value sheet_before sheet-status
act_target "$open_sheet" scroll-to >/dev/null 2>&1
act_target "$open_sheet" click >/dev/null 2>&1
@ -19,6 +20,8 @@ note "Context menu discovery and action"
"$bin" focus-window --app "$app" >/dev/null 2>&1
require_value context_before right-status
require_target context_target button context-target
act_target "$context_target" scroll-to >/dev/null 2>&1
require_target context_target button context-target
context_output="$(act_target "$context_target" right-click 2>&1)"
sleep 0.5
context_menu="$(json_field "$context_output" data.menu)"
@ -43,6 +46,11 @@ assert "menu bar exposes the custom Fixture menu" \
note "Source-tracked drag gesture"
require_value drag_before drag-canvas-status
drag_anchor="$(find_target_by_id group drag-canvas || find_target group drag-canvas || true)"
if [ -n "$drag_anchor" ]; then
act_target "$drag_anchor" scroll-to >/dev/null 2>&1
sleep 0.3
fi
drag_snapshot="$("$bin" snapshot --app "$app" --include-bounds --max-depth 30 2>/dev/null)"
drag_points="$(printf '%s' "$drag_snapshot" | python3 "$json_tool" tree drag-canvas drag 2>/dev/null)" || drag_points=""
if [ -z "$drag_points" ]; then
@ -61,6 +69,8 @@ assert "headed drag delivers one source-tracked gesture" \
note "Disclosure collapse and expand"
require_target disclosure disclosure disclosure-section
act_target "$disclosure" scroll-to >/dev/null 2>&1
require_target disclosure disclosure disclosure-section
collapse_output="$(act_target "$disclosure" collapse 2>&1)"
sleep 0.4
require_target disclosure disclosure disclosure-section

View file

@ -48,7 +48,7 @@ fi
assert "trace show includes command and snapshot events" "$trace_events" \
"session=$trace_session"
assert "trace screenshot artifacts have PNG magic" "$png_magic" \
"directory=$trace_dir/screens"
"directory=$trace_dir/screens exists=$([ -d "$trace_dir/screens" ] && echo yes || echo no) pngs=$(find "$trace_dir/screens" -name '*.png' 2>/dev/null | wc -l | tr -d ' ')"
trace_html="$(mktemp -t agentdesk-e2e-trace-export.XXXXXX.html)"
cleanup_files+=("$trace_html")

View file

@ -56,7 +56,7 @@ struct ContentView: View {
var body: some View {
ScrollView([.vertical, .horizontal]) {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("AgentDesk Fixture")
.font(.title2)
.accessibilityLabel("fixture-title")
@ -64,14 +64,14 @@ struct ContentView: View {
row2
row3
}
.padding(20)
.padding(10)
}
.frame(minWidth: 980, minHeight: 720)
.sheet(isPresented: $showSheet) { sheetContent }
}
private var row1: some View {
HStack(alignment: .top, spacing: 16) {
HStack(alignment: .top, spacing: 12) {
clicksCard
textCard
stateCard
@ -79,7 +79,7 @@ struct ContentView: View {
}
private var row2: some View {
HStack(alignment: .top, spacing: 16) {
HStack(alignment: .top, spacing: 12) {
choiceCard
collectionsCard
asyncCard
@ -87,7 +87,7 @@ struct ContentView: View {
}
private var row3: some View {
HStack(alignment: .top, spacing: 16) {
HStack(alignment: .top, spacing: 12) {
dragCard
surfacesCard
ScrollCard()

View file

@ -61,7 +61,7 @@ struct AsyncFixtureCard: View {
}
Button("Remove Row") { removableVisible = false }.accessibilityLabel("remove-row")
Button("Reset Removable Row") { removableVisible = true }
.accessibilityLabel("reset-removable-row")
.accessibilityLabel("reset-removable")
}
}
}