Skip to content

Commit 2a7ee8d

Browse files
committed
test(contracts): enumerate the macOS emitter's real vocabulary
Review found the table claimed to pin "the vocabulary each backend actually emits" while omitting most of it. `normalizedSnapshotType` has three output classes and only two were represented: 1. thirteen roles mapped to fixed short names — six were missing (StaticText, TextField, TextArea, MenuBarItem, Menu, MenuItem); 2. AXWindow, whose output is the SUBROLE unless it is AXStandardWindow; 3. the `default:` arm, `subrole ?? role`, emitting the raw AX-prefixed value for every unmapped role. All three are now enumerated, and the table asserts its own completeness against the emitter's fixed-output set — a role added to that switch without being added here fails, which is the emitter-drift protection the docblock was promising but not delivering. Re-measuring over the complete tables also corrected the header's own numbers. The claim was "71 of 73 agree, 2 disagree"; over 75 names it is 71 agree and FOUR disagree, because AXSystemDialog and AXUnknown were absent from the old table. Those two are the behavioral delta of this PR — an AXWindow whose subrole is emitted as the type, invisible to the six type-only spellings and named exactly by `role` — so the incomplete table had been hiding the very rows that justify reading role/subrole. The other two (AXFloatingWindow, AXSystemFloatingWindow) remain inert: only the `===` spelling misses them and its platform union is `android | ios`.
1 parent d3ec1f3 commit 2a7ee8d

1 file changed

Lines changed: 209 additions & 0 deletions

File tree

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { isViewportRootNode } from './snapshot-visibility.ts';
3+
4+
/**
5+
* `isViewportRootNode` pinned over the vocabulary each backend ACTUALLY emits
6+
* into `node.type`/`role`/`subrole` — not over fixture strings.
7+
*
8+
* Nine hand-rolled spellings of this predicate existed before #1592's method was
9+
* applied here: three normalizing `type|role|subrole`, five lowercasing or
10+
* normalizing `type` alone, and one comparing the normalized type for EQUALITY.
11+
* Measured over the 75 names in the three tables below they agree on 71. All
12+
* four disagreements are macOS windows, and they split two ways:
13+
*
14+
* - `AXFloatingWindow` / `AXSystemFloatingWindow`: only the `===` spelling
15+
* (maestro) misses them, and its platform union is `android | ios`, so it
16+
* can never see a macOS node. Inert — the same shape #1592 found for its
17+
* own role/subrole arm.
18+
* - `AXSystemDialog` / `AXUnknown`: an `AXWindow` whose subrole is emitted AS
19+
* the type, so the six type-only spellings cannot see they are windows while
20+
* `role` names it exactly. These two are the entire behavioral delta of the
21+
* consolidation, and they are windows by role.
22+
*
23+
* Grounding this in emitted vocabulary is what made the collapse provable; a
24+
* fixture-string comparison would have reported divergences no backend can
25+
* produce (#1592, second commit).
26+
*
27+
* Keep these tables honest against their emitters — a predicate that silently
28+
* stops matching the root is invisible in every other test, because the root
29+
* node is scenery in all of them. The macOS table asserts its own completeness
30+
* against the emitter's fixed-output set below; iOS and Android do not, so a
31+
* new `elementTypeName` case or a new Android container class still has to be
32+
* added here by hand.
33+
*/
34+
35+
// apple/runner/.../RunnerTests+Snapshot.swift `elementTypeName` — a closed set.
36+
// `Element(N)` is the escape hatch for element types the switch does not name.
37+
const IOS_EMITTED_TYPES = [
38+
'Application',
39+
'Window',
40+
'Button',
41+
'Cell',
42+
'StaticText',
43+
'TextField',
44+
'TextView',
45+
'SecureTextField',
46+
'Switch',
47+
'Slider',
48+
'Link',
49+
'Image',
50+
'NavigationBar',
51+
'TabBar',
52+
'CollectionView',
53+
'Table',
54+
'ScrollView',
55+
'Toolbar',
56+
'SearchField',
57+
'SegmentedControl',
58+
'Stepper',
59+
'Picker',
60+
'ActivityIndicator',
61+
'ProgressIndicator',
62+
'CheckBox',
63+
'MenuItem',
64+
'WebView',
65+
'Other',
66+
'Keyboard',
67+
'Key',
68+
'Element(72)',
69+
] as const;
70+
71+
// src/platforms/android/ui-hierarchy.ts — `type` is the uiautomator `class`
72+
// attribute verbatim, a fully-qualified Java class name.
73+
const ANDROID_EMITTED_TYPES = [
74+
'android.view.View',
75+
'android.view.ViewGroup',
76+
'android.widget.Button',
77+
'android.widget.EditText',
78+
'android.widget.FrameLayout',
79+
'android.widget.HorizontalScrollView',
80+
'android.widget.ImageButton',
81+
'android.widget.ImageView',
82+
'android.widget.LinearLayout',
83+
'android.widget.ScrollView',
84+
'android.widget.SeekBar',
85+
'android.widget.Switch',
86+
'android.widget.TextView',
87+
'androidx.compose.ui.platform.ComposeView',
88+
'androidx.recyclerview.widget.RecyclerView',
89+
'android.widget.ListView',
90+
'android.widget.GridView',
91+
'androidx.core.widget.NestedScrollView',
92+
] as const;
93+
94+
// apple/macos-helper/.../SnapshotTraversal.swift `normalizedSnapshotType`. Three
95+
// output classes, all represented below:
96+
// 1. every one of the 13 roles the switch maps to a fixed short name;
97+
// 2. `AXWindow`, whose output is the SUBROLE unless that subrole is
98+
// `AXStandardWindow` — so a window's `type` can be any subrole string;
99+
// 3. the `default:` arm, `subrole ?? role`, which emits the raw `AX`-prefixed
100+
// value for every unmapped role.
101+
// This backend is the only one that populates `role`/`subrole` at all.
102+
const MACOS_EMITTED_NODES = [
103+
// 1. the complete fixed-output set.
104+
{ type: 'Application', role: 'AXApplication' },
105+
{ type: 'Sheet', role: 'AXSheet' },
106+
{ type: 'Dialog', role: 'AXDialog' },
107+
{ type: 'Button', role: 'AXButton' },
108+
{ type: 'StaticText', role: 'AXStaticText' },
109+
{ type: 'TextField', role: 'AXTextField' },
110+
{ type: 'TextArea', role: 'AXTextArea' },
111+
{ type: 'ScrollArea', role: 'AXScrollArea' },
112+
{ type: 'Group', role: 'AXGroup' },
113+
{ type: 'MenuBar', role: 'AXMenuBar' },
114+
{ type: 'MenuBarItem', role: 'AXMenuBarItem' },
115+
{ type: 'Menu', role: 'AXMenu' },
116+
{ type: 'MenuItem', role: 'AXMenuItem' },
117+
// 2. AXWindow: standard subrole collapses to "Window", every other subrole
118+
// is emitted verbatim as the type.
119+
{ type: 'Window', role: 'AXWindow', subrole: 'AXStandardWindow' },
120+
{ type: 'AXFloatingWindow', role: 'AXWindow', subrole: 'AXFloatingWindow' },
121+
{ type: 'AXSystemFloatingWindow', role: 'AXWindow', subrole: 'AXSystemFloatingWindow' },
122+
{ type: 'AXSystemDialog', role: 'AXWindow', subrole: 'AXSystemDialog' },
123+
{ type: 'AXUnknown', role: 'AXWindow', subrole: 'AXUnknown' },
124+
// 3. the raw `subrole ?? role` fallback for unmapped roles.
125+
{ type: 'AXWebArea', role: 'AXWebArea' },
126+
{ type: 'AXList', role: 'AXList' },
127+
{ type: 'AXTable', role: 'AXTable' },
128+
{ type: 'AXOutline', role: 'AXOutline' },
129+
{ type: 'AXScrollBar', role: 'AXScrollBar' },
130+
{ type: 'AXSplitGroup', role: 'AXSplitGroup' },
131+
{ type: 'AXToolbar', role: 'AXToolbar' },
132+
{ type: 'AXSearchField', role: 'AXTextField', subrole: 'AXSearchField' },
133+
] as const;
134+
135+
/**
136+
* The 13 fixed outputs above are the complete set `normalizedSnapshotType`'s
137+
* switch can return, so a role added to that switch without being added here
138+
* is the drift this table exists to catch. Asserted rather than trusted.
139+
*/
140+
const MACOS_FIXED_OUTPUTS = [
141+
'Application',
142+
'Sheet',
143+
'Dialog',
144+
'Button',
145+
'StaticText',
146+
'TextField',
147+
'TextArea',
148+
'ScrollArea',
149+
'Group',
150+
'MenuBar',
151+
'MenuBarItem',
152+
'Menu',
153+
'MenuItem',
154+
] as const;
155+
156+
describe('isViewportRootNode over emitted backend vocabulary', () => {
157+
test('iOS: exactly Application and Window, out of 31 emitted names', () => {
158+
const roots = IOS_EMITTED_TYPES.filter((type) => isViewportRootNode({ type }));
159+
expect(roots).toEqual(['Application', 'Window']);
160+
});
161+
162+
test('iOS: substring and equality agree, so the collapse of the `===` spelling was a no-op', () => {
163+
for (const type of IOS_EMITTED_TYPES) {
164+
const equality = type === 'Application' || type === 'Window';
165+
expect({ type, root: isViewportRootNode({ type }) }).toEqual({ type, root: equality });
166+
}
167+
});
168+
169+
// The load-bearing one. Android has no root node, so `resolveViewportRect`'s
170+
// third fallback (largest containing rect of any node) is the only arm that
171+
// ever returns on Android — and the resolvers that lack it return null there.
172+
test('Android: no emitted class name is a viewport root', () => {
173+
const roots = ANDROID_EMITTED_TYPES.filter((type) => isViewportRootNode({ type }));
174+
expect(roots).toEqual([]);
175+
});
176+
177+
test('macOS: every AXWindow subrole is a root, whatever `type` says', () => {
178+
const roots = MACOS_EMITTED_NODES.filter(isViewportRootNode).map((node) => node.type);
179+
expect(roots).toEqual([
180+
'Application',
181+
'Window',
182+
'AXFloatingWindow',
183+
'AXSystemFloatingWindow',
184+
'AXSystemDialog',
185+
'AXUnknown',
186+
]);
187+
});
188+
189+
// The table's own completeness claim, asserted rather than trusted: every
190+
// fixed output the emitter's switch can return appears above, so adding a
191+
// role to that switch without adding it here fails.
192+
test('macOS: the table covers every fixed output normalizedSnapshotType returns', () => {
193+
const covered = new Set(MACOS_EMITTED_NODES.map((node) => node.type));
194+
expect(MACOS_FIXED_OUTPUTS.filter((name) => !covered.has(name))).toEqual([]);
195+
});
196+
197+
// Why the canonical predicate reads role/subrole and not `type` alone: these
198+
// two are windows that no type-only spelling could see.
199+
test('macOS: role rescues the two window subroles `type` alone cannot name', () => {
200+
const typeOnly = (type: string) => {
201+
const value = type.toLowerCase();
202+
return value.includes('application') || value.includes('window');
203+
};
204+
const rescued = MACOS_EMITTED_NODES.filter(
205+
(node) => isViewportRootNode(node) && !typeOnly(node.type),
206+
).map((node) => node.type);
207+
expect(rescued).toEqual(['AXSystemDialog', 'AXUnknown']);
208+
});
209+
});

0 commit comments

Comments
 (0)