From c82831581b08b0fc0f762b33e1775caf5fa9799a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Wo=C5=82czuk?= Date: Mon, 24 Aug 2026 19:16:06 +0200 Subject: [PATCH 1/2] fix: axTree() rejects an out-of-range window index Asking for window 1 on an app with one window fell through to walking the application element: a larger, different tree with no window frame, returned as though it answered the question. It now fails with the count the app actually exposes, and distinguishes the case where there are no accessibility windows at all (minimised or hidden). Co-Authored-By: Claude Opus 5 --- .changeset/window-not-found.md | 11 +++++++++++ package-lock.json | 4 ++-- src/native/ax-helper.swift | 9 +++++++++ test/ax.test.ts | 12 ++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 .changeset/window-not-found.md diff --git a/.changeset/window-not-found.md b/.changeset/window-not-found.md new file mode 100644 index 0000000..5aaf779 --- /dev/null +++ b/.changeset/window-not-found.md @@ -0,0 +1,11 @@ +--- +"macos-vision": patch +--- + +fix: `axTree()` fails on an out-of-range window index instead of walking the whole app + +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). 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/native/ax-helper.swift b/src/native/ax-helper.swift index 54bfff3..b621572 100644 --- a/src/native/ax-helper.swift +++ b/src/native/ax-helper.swift @@ -303,6 +303,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]) { diff --git a/test/ax.test.ts b/test/ax.test.ts index 92af1a8..c4f48d4 100644 --- a/test/ax.test.ts +++ b/test/ax.test.ts @@ -103,3 +103,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 + ); +}); From 1d89e759725be3539129553f603fd03c2ff9faa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Wo=C5=82czuk?= Date: Mon, 24 Aug 2026 19:18:12 +0200 Subject: [PATCH 2/2] fix: report walked node count alongside the returned one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maxElements caps the walk, and pruning runs afterwards, so a result could show elements: 315 next to maxElements: 400 and capped: true — three numbers that read as a contradiction. budget.walked is what the cap applies to; elements is what came back. Co-Authored-By: Claude Opus 5 --- .changeset/window-not-found.md | 7 ++++++- src/ax.ts | 7 +++++++ src/native/ax-helper.swift | 8 ++++++++ test/ax.test.ts | 5 ++++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.changeset/window-not-found.md b/.changeset/window-not-found.md index 5aaf779..28bac55 100644 --- a/.changeset/window-not-found.md +++ b/.changeset/window-not-found.md @@ -2,10 +2,15 @@ "macos-vision": patch --- -fix: `axTree()` fails on an out-of-range window index instead of walking the whole app +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/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 b621572..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 @@ -386,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. @@ -417,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 c4f48d4..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); },