mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-07-27 01:22:16 +00:00
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:
parent
0ec5e7ad86
commit
51c87b2323
13 changed files with 105 additions and 44 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue