Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/window-not-found.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"macos-vision": patch
---

fix: `axTree()` window and budget reporting say what actually happened

Asking for `window: 1` on an app with one window silently fell through to the
application element. That walks a larger, different tree and reports no window
frame — a different answer to the question that was asked, easily mistaken for a
real result. It now fails with how many windows the app actually exposes, and
says so separately when an app exposes none at all (minimised or hidden).

`budget` now also reports `walked` — the number of nodes visited before pruning,
which is what `maxElements` caps. Without it, a result showing `elements: 315`,
`maxElements: 400` and `capped: true` reads as a contradiction instead of
"the walk hit the cap and pruning then removed 85".
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions src/ax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@ export interface AxNode {
}

export interface AxBudget {
/** Nodes returned, after pruning. */
elements: number;
/**
* Nodes the walk visited before pruning — what `maxElements` actually caps.
* Without it, `elements` below `maxElements` next to `capped: true` reads as a
* contradiction rather than as "pruning removed some of what we walked".
*/
walked: number;
/** True when the walk stopped at `maxElements`/`maxDepth` — the tree is incomplete. */
capped: boolean;
maxElements: number;
Expand Down
17 changes: 17 additions & 0 deletions src/native/ax-helper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ struct Node: Codable {
}

struct Budget: Codable {
/// Nodes returned, after pruning.
let elements: Int
/// Nodes the walk visited before pruning — this is what `maxElements` caps,
/// so without it `elements < maxElements` alongside `capped: true` reads as a
/// contradiction.
let walked: Int
let capped: Bool
let maxElements: Int
let maxDepth: Int
Expand Down Expand Up @@ -303,6 +308,15 @@ let windows = children(axApp).filter { el in
return (a[0] as? String) == "AXWindow"
}
let windowIndex = intOpt("--window", 0)
// Asking for a window that is not there must say so. Falling through to the
// application element walks a different, larger tree and reports no window
// frame — a silently different answer to the question that was asked.
if windows.isEmpty {
fail("\(app.localizedName ?? "app") has no accessibility windows (is it minimised or hidden?)")
}
if windows[safe: windowIndex] == nil {
fail("window \(windowIndex) not found: \(app.localizedName ?? "app") exposes \(windows.count)")
}
if let win = windows[safe: windowIndex] {
let a = readAttributes(win)
if let p = point(a[5]), let s = size(a[6]) {
Expand Down Expand Up @@ -377,6 +391,8 @@ let keepRoles: Set<String> = [
"Sheet", "Toolbar", "Image", "Table", "Outline",
]

let walkedCount = nodes.count

if !detailFull {
// Keep anything that carries meaning; re-parent survivors onto their nearest
// surviving ancestor so the hierarchy stays walkable.
Expand Down Expand Up @@ -408,6 +424,7 @@ let result = TreeResult(
source: pixels == nil ? "ax" : "ax+px",
budget: Budget(
elements: nodes.count,
walked: walkedCount,
capped: capped,
maxElements: maxElements,
maxDepth: maxDepth,
Expand Down
17 changes: 16 additions & 1 deletion test/ax.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@
async () => {
const tree = await axTree({ app: APP, maxElements: 10 });
expect(tree.budget.elements).toBe(tree.nodes.length);
expect(tree.budget.elements).toBeLessThanOrEqual(10);
// maxElements caps the walk, and pruning runs after it — so `walked` is
// what hit the cap while `elements` can legitimately be smaller.
expect(tree.budget.walked).toBeLessThanOrEqual(10);

Check failure on line 45 in test/ax.test.ts

View workflow job for this annotation

GitHub Actions / test

test/ax.test.ts > axTree() > reports the budget honestly instead of truncating silently

TypeError: actual value must be number or bigint, received "undefined" ❯ test/ax.test.ts:45:34
expect(tree.budget.elements).toBeLessThanOrEqual(tree.budget.walked);
expect(tree.budget.capped).toBe(true);
expect(tree.budget.elapsedMs).toBeGreaterThanOrEqual(0);
},
Expand Down Expand Up @@ -103,3 +106,15 @@
T
);
});

describe('axTree() window targeting', () => {
it(
'fails loudly when the window index is out of range',
async () => {
// Falling back to the application element would walk a larger tree and
// report no window frame — a different answer to the question asked.
await expect(axTree({ app: APP, window: 99 })).rejects.toThrow(/window 99 not found/);

Check failure on line 116 in test/ax.test.ts

View workflow job for this annotation

GitHub Actions / test

test/ax.test.ts > axTree() window targeting > fails loudly when the window index is out of range

AssertionError: promise resolved "{ app: 'Finder', source: 'ax', …(3) }" instead of rejecting - Expected + Received - [Error: rejected promise] + Object { + "app": "Finder", + "budget": Object { + "capped": false, + "culled": 0, + "elapsedMs": 49, + "elements": 20, + "maxDepth": 40, + "maxElements": 1500, + }, + "nodes": Array [ + Object { + "box": Array [ + 0, + 0, + 1024, + 30, + ], + "depth": 1, + "id": 0, + "role": "MenuBar", + }, + Object { + "box": Array [ + 10, + 0, + 34, + 30, + ], + "depth": 2, + "id": 1, + "label": "Apple", + "parent": 0, + "role": "MenuBarItem", + }, + Object { + "box": Array [ + 44, + 0, + 62, + 30, + ], + "depth": 2, + "id": 2, + "label": "Finder", + "parent": 0, + "role": "MenuBarItem", + }, + Object { + "box": Array [ + 106, + 0, + 42, + 30, + ], + "depth": 2, + "id": 3, + "label": "File", + "parent": 0, + "role": "MenuBarItem", + }, + Object { + "box": Array [ + 0, + 746, + 24, + 22, + ], + "depth": 6, + "enabled": false, + "id": 5, + "label": "Add “Red” label", + "parent": 3, + "role": "RadioButton", + }, + Object { + "box": Array [ + 0, + 746, + 24, + 22, + ], + "depth": 6, + "enabled": false, + "id": 6, + "label": "Add “Orange” label", + "parent": 3, + "role": "RadioButton", + }, + Object { + "box": Array [ + 0, + 746, + 24, + 22, + ], + "depth": 6, + "enabled": false, + "id": 7, + "label": "Add “Yellow” label", + "parent": 3, + "role": "RadioButton", + }, + Object { + "box": Array [ + 0, + 746, + 24, + 22, + ], + "depth": 6, + "enabled": false, + "id": 8, + "label": "Add “Green” label", + "parent": 3, + "role": "RadioButton", + }, + Object { + "box": Array [ + 0, + 746, + 24, + 22, + ], + "depth": 6, + "enabled": false, + "id": 9, + "label": "Add “Blue” label", + "parent": 3, + "role": "RadioButton", + }, + Object { + "box": Array [ + 0, + 746, + 24, + 22, + ], + "depth": 6, + "enabled": false, + "id": 10, + "label": "Add “Purple” label", + "parent": 3, + "role": "RadioButton", + }, + Object { + "box": Array [ + 0, + 746, + 24, + 22, + ], + "depth": 6, + "enabled": false, + "id": 11, + "label": "Add “Gray” label", + "parent": 3, + "role": "RadioButton", + }, + Object { + "box": Array [ + 1, + 751, + 41, + 18, + ], + "depth": 5, + "id": 12, + "label": "Tags…", + "parent": 3, + "role": "Button", + }, + Object { + "box": Array [ + -1, + 755, + 18, + 10, + ], + "depth": 5, + "id": 13, + "label": "tag", + "parent": 3, + "role": "Image", + }, + Object { + "box": Array [ + 148, + 0, + 44, + 30, + ], + "depth": 2, + "id": 14, + "label": "Edit", + "parent": 0, + "role": "MenuBarItem", + }, + Object { + "box": Array [ + 192, + 0, + 50, + 30, + ], + "depth": 2, + "id": 15, + "label": "View", + "parent": 0, + "role": "MenuBarItem", + }, + Object { + "box": Array [ + 242, +
},
T
);
});
Loading