diff --git a/.changeset/window-not-found.md b/.changeset/window-not-found.md new file mode 100644 index 0000000..28bac55 --- /dev/null +++ b/.changeset/window-not-found.md @@ -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". diff --git a/package-lock.json b/package-lock.json index f5ec4a9..c02a6f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "macos-vision", - "version": "1.4.0", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "macos-vision", - "version": "1.4.0", + "version": "1.8.0", "hasInstallScript": true, "license": "MIT", "os": [ diff --git a/src/ax.ts b/src/ax.ts index 4a29be0..012d8f1 100644 --- a/src/ax.ts +++ b/src/ax.ts @@ -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; diff --git a/src/native/ax-helper.swift b/src/native/ax-helper.swift index 54bfff3..788258d 100644 --- a/src/native/ax-helper.swift +++ b/src/native/ax-helper.swift @@ -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 @@ -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]) { @@ -377,6 +391,8 @@ let keepRoles: Set = [ "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. @@ -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, diff --git a/test/ax.test.ts b/test/ax.test.ts index 92af1a8..a7be46f 100644 --- a/test/ax.test.ts +++ b/test/ax.test.ts @@ -40,7 +40,10 @@ describe('axTree()', () => { 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); + expect(tree.budget.elements).toBeLessThanOrEqual(tree.budget.walked); expect(tree.budget.capped).toBe(true); expect(tree.budget.elapsedMs).toBeGreaterThanOrEqual(0); }, @@ -103,3 +106,15 @@ describe('axTree()', () => { 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/); + }, + T + ); +});