@a11y-oracle/core-engine 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @module a11y-orchestrator
3
+ *
4
+ * Coordinates the three sub-engines (Speech, Keyboard, Focus) into a
5
+ * single `pressKey()` → unified-state workflow.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { A11yOrchestrator } from '@a11y-oracle/core-engine';
10
+ *
11
+ * const oracle = new A11yOrchestrator(cdpSession);
12
+ * await oracle.enable();
13
+ *
14
+ * const state = await oracle.pressKey('Tab');
15
+ * console.log(state.speech); // "Products, button, collapsed"
16
+ * console.log(state.focusedElement?.tag); // "BUTTON"
17
+ * console.log(state.focusIndicator.meetsWCAG_AA); // true
18
+ * ```
19
+ */
20
+ import { SpeechEngine } from './speech-engine.js';
21
+ import { KeyboardEngine } from '@a11y-oracle/keyboard-engine';
22
+ import { FocusAnalyzer } from '@a11y-oracle/focus-analyzer';
23
+ /**
24
+ * Orchestrates speech, keyboard dispatch, and focus analysis into a
25
+ * unified accessibility testing API.
26
+ *
27
+ * A single `pressKey()` call dispatches a keystroke, waits for focus
28
+ * to settle, then collects speech output, focused element info, and
29
+ * focus indicator analysis in parallel.
30
+ *
31
+ * ## Sub-engines
32
+ *
33
+ * | Engine | Responsibility |
34
+ * |--------|---------------|
35
+ * | {@link SpeechEngine} | AXTree → speech string |
36
+ * | {@link KeyboardEngine} | CDP key dispatch + `document.activeElement` |
37
+ * | {@link FocusAnalyzer} | CSS focus indicator + tab order + trap detection |
38
+ */
39
+ export class A11yOrchestrator {
40
+ speech;
41
+ keyboard;
42
+ focusAnalyzer;
43
+ options;
44
+ /**
45
+ * @param cdp - CDP session for sending protocol commands.
46
+ * @param options - Optional configuration for speech output and focus settling.
47
+ */
48
+ constructor(cdp, options = {}) {
49
+ this.speech = new SpeechEngine(cdp, options);
50
+ this.keyboard = new KeyboardEngine(cdp);
51
+ this.focusAnalyzer = new FocusAnalyzer(cdp);
52
+ this.options = {
53
+ includeLandmarks: options.includeLandmarks ?? true,
54
+ includeDescription: options.includeDescription ?? false,
55
+ focusSettleMs: options.focusSettleMs ?? 50,
56
+ };
57
+ }
58
+ /**
59
+ * Enable the CDP Accessibility domain.
60
+ *
61
+ * Must be called before any other method.
62
+ */
63
+ async enable() {
64
+ await this.speech.enable();
65
+ }
66
+ /**
67
+ * Disable the CDP Accessibility domain.
68
+ *
69
+ * Call this when done to free browser resources.
70
+ */
71
+ async disable() {
72
+ await this.speech.disable();
73
+ }
74
+ /**
75
+ * Dispatch a key press and return the unified accessibility state.
76
+ *
77
+ * 1. Dispatches `keyDown` + `keyUp` via CDP `Input.dispatchKeyEvent`.
78
+ * 2. Waits {@link A11yOrchestratorOptions.focusSettleMs} for transitions.
79
+ * 3. Collects speech, focused element, and focus indicator **in parallel**.
80
+ *
81
+ * @param key - Key name (e.g. `'Tab'`, `'Enter'`, `'ArrowDown'`).
82
+ * @param modifiers - Optional modifier keys.
83
+ * @returns Unified accessibility state snapshot.
84
+ *
85
+ * @example
86
+ * ```typescript
87
+ * const state = await orchestrator.pressKey('Tab');
88
+ * expect(state.speech).toBe('Products, button, collapsed');
89
+ * expect(state.focusIndicator.meetsWCAG_AA).toBe(true);
90
+ * ```
91
+ */
92
+ async pressKey(key, modifiers) {
93
+ await this.keyboard.press(key, modifiers);
94
+ // Wait for focus transitions and CSS animations to settle
95
+ if (this.options.focusSettleMs > 0) {
96
+ await new Promise((resolve) => setTimeout(resolve, this.options.focusSettleMs));
97
+ }
98
+ return this.getState();
99
+ }
100
+ /**
101
+ * Get the current unified accessibility state without pressing a key.
102
+ *
103
+ * Collects speech, focused element, and focus indicator in parallel.
104
+ *
105
+ * @returns Unified accessibility state snapshot.
106
+ */
107
+ async getState() {
108
+ const [speechResult, focusedElementInfo, focusIndicator] = await Promise.all([
109
+ this.speech.getSpeech(),
110
+ this.keyboard.getFocusedElement(),
111
+ this.focusAnalyzer.getFocusIndicator(),
112
+ ]);
113
+ const focusedElement = focusedElementInfo
114
+ ? this.mapFocusedElement(focusedElementInfo)
115
+ : null;
116
+ const a11yFocusIndicator = {
117
+ isVisible: focusIndicator.isVisible,
118
+ contrastRatio: focusIndicator.contrastRatio,
119
+ meetsWCAG_AA: focusIndicator.meetsWCAG_AA,
120
+ };
121
+ return {
122
+ speech: speechResult?.speech ?? '',
123
+ speechResult,
124
+ focusedElement,
125
+ focusIndicator: a11yFocusIndicator,
126
+ };
127
+ }
128
+ /**
129
+ * Extract all tabbable elements in DOM tab order.
130
+ *
131
+ * @returns Report with sorted tab order entries and total count.
132
+ */
133
+ async traverseTabOrder() {
134
+ const entries = await this.focusAnalyzer.getTabOrder();
135
+ return {
136
+ entries,
137
+ totalCount: entries.length,
138
+ };
139
+ }
140
+ /**
141
+ * Detect whether a container traps keyboard focus.
142
+ *
143
+ * Focuses the first tabbable element in the container, then
144
+ * presses Tab repeatedly. If focus never escapes after `maxTabs`
145
+ * presses, the container is a keyboard trap (WCAG 2.1.2 failure).
146
+ *
147
+ * @param selector - CSS selector for the container to test.
148
+ * @param maxTabs - Maximum Tab presses before declaring a trap. Default 50.
149
+ * @returns Traversal result indicating whether focus is trapped.
150
+ */
151
+ async traverseSubTree(selector, maxTabs) {
152
+ return this.focusAnalyzer.detectKeyboardTrap(selector, maxTabs);
153
+ }
154
+ /**
155
+ * Map a raw `FocusedElementInfo` from keyboard-engine to the
156
+ * orchestrator's `A11yFocusedElement` shape.
157
+ */
158
+ mapFocusedElement(info) {
159
+ return {
160
+ tag: info.tag,
161
+ id: info.id,
162
+ className: info.className,
163
+ textContent: info.textContent,
164
+ role: info.role,
165
+ ariaLabel: info.ariaLabel,
166
+ tabIndex: info.tabIndex,
167
+ rect: info.rect,
168
+ };
169
+ }
170
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @module role-map
3
+ *
4
+ * Maps Chrome DevTools Protocol AXTree role values to human-readable
5
+ * speech role strings.
6
+ *
7
+ * CDP already maps HTML elements to their implicit ARIA roles (e.g.,
8
+ * `<button>` → `"button"`, `<nav>` → `"navigation"`). This module
9
+ * translates those internal role names to the strings that appear in
10
+ * the final speech output.
11
+ *
12
+ * Unknown roles pass through as-is, ensuring forward compatibility
13
+ * with new ARIA roles.
14
+ */
15
+ /**
16
+ * Maps CDP AXTree `role.value` strings to speech output role strings.
17
+ *
18
+ * Keys are the role values returned by `Accessibility.getFullAXTree()`.
19
+ * Values are the corresponding human-readable strings used in speech
20
+ * output.
21
+ *
22
+ * Roles that map to an empty string (`''`) are silent — they produce
23
+ * no role announcement in the speech output.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * ROLE_TO_SPEECH['button'] // → 'button'
28
+ * ROLE_TO_SPEECH['navigation'] // → 'navigation'
29
+ * ROLE_TO_SPEECH['generic'] // → '' (silent)
30
+ * ```
31
+ */
32
+ export declare const ROLE_TO_SPEECH: Record<string, string>;
33
+ /**
34
+ * Set of roles classified as "landmark" roles.
35
+ *
36
+ * When a node has a landmark role, the word `"landmark"` is appended
37
+ * to its speech output. For example, a `<nav>` element produces
38
+ * `"Main, navigation landmark"` rather than just `"Main, navigation"`.
39
+ */
40
+ export declare const LANDMARK_ROLES: Set<string>;
41
+ //# sourceMappingURL=role-map.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"role-map.d.ts","sourceRoot":"","sources":["../../src/lib/role-map.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAkFjD,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,aASzB,CAAC"}
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @module role-map
3
+ *
4
+ * Maps Chrome DevTools Protocol AXTree role values to human-readable
5
+ * speech role strings.
6
+ *
7
+ * CDP already maps HTML elements to their implicit ARIA roles (e.g.,
8
+ * `<button>` → `"button"`, `<nav>` → `"navigation"`). This module
9
+ * translates those internal role names to the strings that appear in
10
+ * the final speech output.
11
+ *
12
+ * Unknown roles pass through as-is, ensuring forward compatibility
13
+ * with new ARIA roles.
14
+ */
15
+ /**
16
+ * Maps CDP AXTree `role.value` strings to speech output role strings.
17
+ *
18
+ * Keys are the role values returned by `Accessibility.getFullAXTree()`.
19
+ * Values are the corresponding human-readable strings used in speech
20
+ * output.
21
+ *
22
+ * Roles that map to an empty string (`''`) are silent — they produce
23
+ * no role announcement in the speech output.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * ROLE_TO_SPEECH['button'] // → 'button'
28
+ * ROLE_TO_SPEECH['navigation'] // → 'navigation'
29
+ * ROLE_TO_SPEECH['generic'] // → '' (silent)
30
+ * ```
31
+ */
32
+ export const ROLE_TO_SPEECH = {
33
+ // ─── Interactive widget roles ───
34
+ button: 'button',
35
+ link: 'link',
36
+ checkbox: 'checkbox',
37
+ radio: 'radio button',
38
+ textbox: 'edit text',
39
+ combobox: 'combo box',
40
+ slider: 'slider',
41
+ switch: 'switch',
42
+ tab: 'tab',
43
+ menuitem: 'menu item',
44
+ menuitemcheckbox: 'menu item checkbox',
45
+ menuitemradio: 'menu item radio',
46
+ option: 'option',
47
+ searchbox: 'search text',
48
+ spinbutton: 'spin button',
49
+ // ─── Landmark roles ───
50
+ navigation: 'navigation',
51
+ main: 'main',
52
+ banner: 'banner',
53
+ contentinfo: 'content info',
54
+ complementary: 'complementary',
55
+ search: 'search',
56
+ region: 'region',
57
+ form: 'form',
58
+ // ─── Document structure roles ───
59
+ heading: 'heading',
60
+ list: 'list',
61
+ listitem: 'list item',
62
+ img: 'image',
63
+ figure: 'figure',
64
+ table: 'table',
65
+ row: 'row',
66
+ cell: 'cell',
67
+ columnheader: 'column header',
68
+ rowheader: 'row header',
69
+ grid: 'grid',
70
+ gridcell: 'grid cell',
71
+ tree: 'tree',
72
+ treeitem: 'tree item',
73
+ tablist: 'tab list',
74
+ tabpanel: 'tab panel',
75
+ menu: 'menu',
76
+ menubar: 'menu bar',
77
+ toolbar: 'toolbar',
78
+ dialog: 'dialog',
79
+ alertdialog: 'alert dialog',
80
+ alert: 'alert',
81
+ status: 'status',
82
+ progressbar: 'progress bar',
83
+ separator: 'separator',
84
+ group: 'group',
85
+ article: 'article',
86
+ definition: 'definition',
87
+ term: 'term',
88
+ note: 'note',
89
+ log: 'log',
90
+ marquee: 'marquee',
91
+ timer: 'timer',
92
+ tooltip: 'tooltip',
93
+ feed: 'feed',
94
+ math: 'math',
95
+ directory: 'directory',
96
+ document: 'document',
97
+ application: 'application',
98
+ // ─── Silent roles (no spoken role) ───
99
+ generic: '',
100
+ none: '',
101
+ presentation: '',
102
+ StaticText: '',
103
+ InlineTextBox: '',
104
+ LineBreak: '',
105
+ RootWebArea: '',
106
+ WebArea: '',
107
+ paragraph: '',
108
+ DescriptionListDetail: '',
109
+ DescriptionListTerm: '',
110
+ DescriptionList: '',
111
+ };
112
+ /**
113
+ * Set of roles classified as "landmark" roles.
114
+ *
115
+ * When a node has a landmark role, the word `"landmark"` is appended
116
+ * to its speech output. For example, a `<nav>` element produces
117
+ * `"Main, navigation landmark"` rather than just `"Main, navigation"`.
118
+ */
119
+ export const LANDMARK_ROLES = new Set([
120
+ 'navigation',
121
+ 'main',
122
+ 'banner',
123
+ 'contentinfo',
124
+ 'complementary',
125
+ 'search',
126
+ 'region',
127
+ 'form',
128
+ ]);
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @module speech-engine
3
+ *
4
+ * The central class of A11y-Oracle. {@link SpeechEngine} connects to the
5
+ * browser's Accessibility Tree via the Chrome DevTools Protocol and generates
6
+ * standardized speech output following the format:
7
+ *
8
+ * ```
9
+ * [Computed Name], [Role], [State/Properties]
10
+ * ```
11
+ *
12
+ * Chrome's CDP already computes accessible names per the W3C AccName spec,
13
+ * so the engine's job is to:
14
+ * 1. Fetch the AXTree via `Accessibility.getFullAXTree()`
15
+ * 2. Find the currently focused node
16
+ * 3. Map the node's role and properties to human-readable strings
17
+ * 4. Assemble the final speech string
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * import { SpeechEngine } from '@a11y-oracle/core-engine';
22
+ *
23
+ * const engine = new SpeechEngine(cdpSession);
24
+ * await engine.enable();
25
+ *
26
+ * const result = await engine.getSpeech();
27
+ * console.log(result?.speech); // "Products, button, collapsed"
28
+ * ```
29
+ */
30
+ import type { Protocol } from 'devtools-protocol';
31
+ import type { CDPSessionLike, SpeechEngineOptions, SpeechResult } from './types.js';
32
+ /**
33
+ * Generates standardized speech output from the browser's Accessibility Tree.
34
+ *
35
+ * The engine operates through a {@link CDPSessionLike} interface, making it
36
+ * framework-agnostic. It works with Playwright's CDP sessions, raw WebSocket
37
+ * connections, or any other CDP-compatible client.
38
+ *
39
+ * ## Speech Output Format
40
+ *
41
+ * Every element produces a string in this format:
42
+ * ```
43
+ * [Computed Name], [Role], [State/Properties]
44
+ * ```
45
+ *
46
+ * Parts are omitted if they are empty. For example:
47
+ * - `"Products, button, collapsed"` — name + role + state
48
+ * - `"Main, navigation landmark"` — name + role (landmark)
49
+ * - `"Home, link"` — name + role (no states)
50
+ *
51
+ * ## Landmark Roles
52
+ *
53
+ * Landmark roles (navigation, main, banner, etc.) automatically append
54
+ * the word "landmark" to their role string unless
55
+ * {@link SpeechEngineOptions.includeLandmarks} is set to `false`.
56
+ */
57
+ export declare class SpeechEngine {
58
+ private cdp;
59
+ private options;
60
+ /**
61
+ * Create a new SpeechEngine instance.
62
+ *
63
+ * @param cdp - A CDP session-like object for sending protocol commands.
64
+ * @param options - Optional configuration for speech output behavior.
65
+ */
66
+ constructor(cdp: CDPSessionLike, options?: SpeechEngineOptions);
67
+ /**
68
+ * Enable the CDP Accessibility domain.
69
+ *
70
+ * Must be called before any other method. Enables the browser to
71
+ * start tracking and reporting accessibility tree data.
72
+ */
73
+ enable(): Promise<void>;
74
+ /**
75
+ * Disable the CDP Accessibility domain.
76
+ *
77
+ * Call this when you're done using the engine to free browser resources.
78
+ */
79
+ disable(): Promise<void>;
80
+ /**
81
+ * Get the speech output for the currently focused element.
82
+ *
83
+ * Fetches the full AXTree, locates the node with `focused: true`,
84
+ * and computes its speech string.
85
+ *
86
+ * @returns The {@link SpeechResult} for the focused element, or `null`
87
+ * if no element has focus or the focused element is ignored.
88
+ *
89
+ * @example
90
+ * ```typescript
91
+ * // After pressing Tab to focus a button
92
+ * const result = await engine.getSpeech();
93
+ * console.log(result?.speech); // "Products, button, collapsed"
94
+ * console.log(result?.name); // "Products"
95
+ * console.log(result?.role); // "button"
96
+ * console.log(result?.states); // ["collapsed"]
97
+ * ```
98
+ */
99
+ getSpeech(): Promise<SpeechResult | null>;
100
+ /**
101
+ * Get speech output for ALL non-ignored, non-silent nodes in the tree.
102
+ *
103
+ * Useful for asserting on landmarks, headings, or other structural
104
+ * elements that may not have focus.
105
+ *
106
+ * @returns An array of {@link SpeechResult} objects for every visible
107
+ * node that produces speech output.
108
+ *
109
+ * @example
110
+ * ```typescript
111
+ * const all = await engine.getFullTreeSpeech();
112
+ * const nav = all.find(r => r.speech === 'Main, navigation landmark');
113
+ * expect(nav).toBeDefined();
114
+ * ```
115
+ */
116
+ getFullTreeSpeech(): Promise<SpeechResult[]>;
117
+ /**
118
+ * Find the most specific focused node in the flat AXTree node list.
119
+ *
120
+ * CDP returns the AXTree as a flat array with the document node first
121
+ * and more specific nodes later. Multiple nodes can report `focused: true`
122
+ * (e.g., both the RootWebArea and the actual focused element). This method
123
+ * returns the **last** focused node, which is the most specific (deepest)
124
+ * element that actually has user focus.
125
+ *
126
+ * @param nodes - The flat array of AXNodes from `getFullAXTree()`.
127
+ * @returns The most specific focused node, or `null` if no node is focused.
128
+ */
129
+ findFocusedNode(nodes: Protocol.Accessibility.AXNode[]): Protocol.Accessibility.AXNode | null;
130
+ /**
131
+ * Compute the speech string for a single AXNode.
132
+ *
133
+ * Follows the output format: `[Computed Name], [Role], [State/Properties]`.
134
+ * Parts are omitted when empty. Ignored nodes and nodes with no
135
+ * meaningful content return `null`.
136
+ *
137
+ * @param node - A CDP AXNode from the accessibility tree.
138
+ * @returns A {@link SpeechResult} with the computed speech, or `null`
139
+ * if the node should be silent (ignored, no role, no name).
140
+ *
141
+ * @example
142
+ * ```typescript
143
+ * const result = engine.computeSpeech(axNode);
144
+ * // { speech: "Products, button, collapsed", name: "Products", ... }
145
+ * ```
146
+ */
147
+ computeSpeech(node: Protocol.Accessibility.AXNode): SpeechResult | null;
148
+ }
149
+ //# sourceMappingURL=speech-engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"speech-engine.d.ts","sourceRoot":"","sources":["../../src/lib/speech-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,KAAK,EAAE,cAAc,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAKpF;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,GAAG,CAAiB;IAC5B,OAAO,CAAC,OAAO,CAAgC;IAE/C;;;;;OAKG;gBACS,GAAG,EAAE,cAAc,EAAE,OAAO,GAAE,mBAAwB;IAQlE;;;;;OAKG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAI7B;;;;OAIG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAI9B;;;;;;;;;;;;;;;;;;OAkBG;IACG,SAAS,IAAI,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IAO/C;;;;;;;;;;;;;;;OAeG;IACG,iBAAiB,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC;IAOlD;;;;;;;;;;;OAWG;IACH,eAAe,CACb,KAAK,EAAE,QAAQ,CAAC,aAAa,CAAC,MAAM,EAAE,GACrC,QAAQ,CAAC,aAAa,CAAC,MAAM,GAAG,IAAI;IAWvC;;;;;;;;;;;;;;;;OAgBG;IACH,aAAa,CAAC,IAAI,EAAE,QAAQ,CAAC,aAAa,CAAC,MAAM,GAAG,YAAY,GAAG,IAAI;CA6CxE"}