/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; /* import-globals-from ../../mochitest/role.js */ /* import-globals-from ../../mochitest/states.js */ loadScripts( { name: "role.js", dir: MOCHITESTS_DIR }, { name: "states.js", dir: MOCHITESTS_DIR } ); function testValueChangedEventData( macIface, data, expectedId, expectedChangeValue, expectedEditType, expectedWordAtLeft ) { is( data.AXTextChangeElement.getAttributeValue("AXDOMIdentifier"), expectedId, "Correct AXTextChangeElement" ); is( data.AXTextStateChangeType, AXTextStateChangeTypeEdit, "Correct AXTextStateChangeType" ); let changeValues = data.AXTextChangeValues; is(changeValues.length, 1, "One element in AXTextChangeValues"); is( changeValues[0].AXTextChangeValue, expectedChangeValue, "Correct AXTextChangeValue" ); is( changeValues[0].AXTextEditType, expectedEditType, "Correct AXTextEditType" ); let textMarker = changeValues[0].AXTextChangeValueStartMarker; ok(textMarker, "There is a AXTextChangeValueStartMarker"); let range = macIface.getParameterizedAttributeValue( "AXLeftWordTextMarkerRangeForTextMarker", textMarker ); let str = macIface.getParameterizedAttributeValue( "AXStringForTextMarkerRange", range, "correct word before caret" ); is(str, expectedWordAtLeft); } // Return true if the first given object a subset of the second function isSubset(subset, superset) { if (typeof subset != "object" || typeof superset != "object") { return superset == subset; } for (let [prop, val] of Object.entries(subset)) { if (!isSubset(val, superset[prop])) { return false; } } return true; } function matchWebArea(expectedId, expectedInfo) { return (iface, data) => { if (!data) { return false; } let textChangeElemID = data.AXTextChangeElement.getAttributeValue("AXDOMIdentifier"); return ( iface.getAttributeValue("AXRole") == "AXWebArea" && textChangeElemID == expectedId && isSubset(expectedInfo, data) ); }; } function matchInput(expectedId, expectedInfo) { return (iface, data) => { if (!data) { return false; } return ( iface.getAttributeValue("AXDOMIdentifier") == expectedId && isSubset(expectedInfo, data) ); }; } async function synthKeyAndTestSelectionChanged( synthKey, synthEvent, expectedId, expectedSelectionString, expectedSelectionInfo ) { let selectionChangedEvents = Promise.all([ waitForMacEventWithInfo( "AXSelectedTextChanged", matchWebArea(expectedId, expectedSelectionInfo) ), waitForMacEventWithInfo( "AXSelectedTextChanged", matchInput(expectedId, expectedSelectionInfo) ), ]); EventUtils.synthesizeKey(synthKey, synthEvent); let [webareaEvent, inputEvent] = await selectionChangedEvents; is( inputEvent.data.AXTextChangeElement.getAttributeValue("AXDOMIdentifier"), expectedId, "Correct AXTextChangeElement" ); let rangeString = inputEvent.macIface.getParameterizedAttributeValue( "AXStringForTextMarkerRange", inputEvent.data.AXSelectedTextMarkerRange ); is( rangeString, expectedSelectionString, `selection has correct value (${expectedSelectionString})` ); is( webareaEvent.macIface.getAttributeValue("AXDOMIdentifier"), "body", "Input event target is top-level WebArea" ); rangeString = webareaEvent.macIface.getParameterizedAttributeValue( "AXStringForTextMarkerRange", inputEvent.data.AXSelectedTextMarkerRange ); is( rangeString, expectedSelectionString, `selection has correct value (${expectedSelectionString}) via top document` ); return inputEvent; } function testSelectionEventLeftChar(event, expectedChar) { const selStart = event.macIface.getParameterizedAttributeValue( "AXStartTextMarkerForTextMarkerRange", event.data.AXSelectedTextMarkerRange ); const selLeft = event.macIface.getParameterizedAttributeValue( "AXPreviousTextMarkerForTextMarker", selStart ); const leftCharRange = event.macIface.getParameterizedAttributeValue( "AXTextMarkerRangeForUnorderedTextMarkers", [selLeft, selStart] ); const leftCharString = event.macIface.getParameterizedAttributeValue( "AXStringForTextMarkerRange", leftCharRange ); is(leftCharString, expectedChar, "Left character is correct"); } function testSelectionEventLine(event, expectedLine) { const selStart = event.macIface.getParameterizedAttributeValue( "AXStartTextMarkerForTextMarkerRange", event.data.AXSelectedTextMarkerRange ); const lineRange = event.macIface.getParameterizedAttributeValue( "AXLineTextMarkerRangeForTextMarker", selStart ); const lineString = event.macIface.getParameterizedAttributeValue( "AXStringForTextMarkerRange", lineRange ); is(lineString, expectedLine, "Line is correct"); } async function synthKeyAndTestValueChanged( synthKey, synthEvent, expectedId, expectedTextSelectionId, expectedChangeValue, expectedEditType, expectedWordAtLeft ) { let valueChangedEvents = Promise.all([ waitForMacEvent( "AXSelectedTextChanged", matchWebArea(expectedTextSelectionId, { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, }) ), waitForMacEvent( "AXSelectedTextChanged", matchInput(expectedTextSelectionId, { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, }) ), waitForMacEventWithInfo( "AXValueChanged", matchWebArea(expectedId, { AXTextStateChangeType: AXTextStateChangeTypeEdit, AXTextChangeValues: [ { AXTextChangeValue: expectedChangeValue, AXTextEditType: expectedEditType, }, ], }) ), waitForMacEventWithInfo( "AXValueChanged", matchInput(expectedId, { AXTextStateChangeType: AXTextStateChangeTypeEdit, AXTextChangeValues: [ { AXTextChangeValue: expectedChangeValue, AXTextEditType: expectedEditType, }, ], }) ), ]); EventUtils.synthesizeKey(synthKey, synthEvent); let [, , webareaEvent, inputEvent] = await valueChangedEvents; testValueChangedEventData( webareaEvent.macIface, webareaEvent.data, expectedId, expectedChangeValue, expectedEditType, expectedWordAtLeft ); testValueChangedEventData( inputEvent.macIface, inputEvent.data, expectedId, expectedChangeValue, expectedEditType, expectedWordAtLeft ); } async function focusIntoInput(accDoc, inputId, innerContainerId) { let selectionId = innerContainerId ? innerContainerId : inputId; let input = getNativeInterface(accDoc, inputId); ok(!input.getAttributeValue("AXFocused"), "input is not focused"); ok(input.isAttributeSettable("AXFocused"), "input is focusable"); let events = Promise.all([ waitForMacEvent( "AXFocusedUIElementChanged", iface => iface.getAttributeValue("AXDOMIdentifier") == inputId ), waitForMacEventWithInfo( "AXSelectedTextChanged", matchWebArea(selectionId, { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, }) ), waitForMacEventWithInfo( "AXSelectedTextChanged", matchInput(selectionId, { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, }) ), ]); input.setAttributeValue("AXFocused", true); await events; } async function focusIntoInputAndType(accDoc, inputId, innerContainerId) { let selectionId = innerContainerId ? innerContainerId : inputId; await focusIntoInput(accDoc, inputId, innerContainerId); async function testTextInput( synthKey, expectedChangeValue, expectedWordAtLeft ) { await synthKeyAndTestValueChanged( synthKey, null, inputId, selectionId, expectedChangeValue, AXTextEditTypeTyping, expectedWordAtLeft ); } await testTextInput("h", "h", "h"); await testTextInput("e", "e", "he"); await testTextInput("l", "l", "hel"); await testTextInput("l", "l", "hell"); await testTextInput("o", "o", "hello"); await testTextInput(" ", " ", "hello"); // You would expect this to be useless but this is what VO // consumes. I guess it concats the inserted text data to the // word to the left of the marker. await testTextInput("w", "w", " "); await testTextInput("o", "o", "wo"); await testTextInput("r", "r", "wor"); await testTextInput("l", "l", "worl"); await testTextInput("d", "d", "world"); async function testTextDelete(expectedChangeValue, expectedWordAtLeft) { await synthKeyAndTestValueChanged( "KEY_Backspace", null, inputId, selectionId, expectedChangeValue, AXTextEditTypeDelete, expectedWordAtLeft ); } await testTextDelete("d", "worl"); await testTextDelete("l", "wor"); await synthKeyAndTestSelectionChanged( "KEY_ArrowLeft", null, selectionId, "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionPrevious, AXTextSelectionGranularity: AXTextSelectionGranularityCharacter, } ); await synthKeyAndTestSelectionChanged( "KEY_ArrowLeft", { shiftKey: true }, selectionId, "o", { AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend, AXTextSelectionDirection: AXTextSelectionDirectionPrevious, AXTextSelectionGranularity: AXTextSelectionGranularityCharacter, } ); await synthKeyAndTestSelectionChanged( "KEY_ArrowLeft", { shiftKey: true }, selectionId, "wo", { AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend, AXTextSelectionDirection: AXTextSelectionDirectionPrevious, AXTextSelectionGranularity: AXTextSelectionGranularityCharacter, } ); await synthKeyAndTestSelectionChanged( "KEY_ArrowLeft", null, selectionId, "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove } ); await synthKeyAndTestSelectionChanged( "KEY_ArrowLeft", { shiftKey: true, metaKey: true }, selectionId, "hello ", { AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend, AXTextSelectionDirection: AXTextSelectionDirectionBeginning, AXTextSelectionGranularity: AXTextSelectionGranularityLine, } ); await synthKeyAndTestSelectionChanged( "KEY_ArrowLeft", null, selectionId, "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove } ); await synthKeyAndTestSelectionChanged( "KEY_ArrowRight", { shiftKey: true, altKey: true }, selectionId, "hello", { AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend, AXTextSelectionDirection: AXTextSelectionDirectionNext, AXTextSelectionGranularity: AXTextSelectionGranularityWord, } ); } // Test text input addAccessibleTask( `link `, async (browser, accDoc) => { await focusIntoInputAndType(accDoc, "input"); }, { topLevel: true, iframe: true, remoteIframe: true } ); // Test content editable addAccessibleTask( `

`, async (browser, accDoc) => { const inner = getNativeInterface(accDoc, "inner"); const editableAncestor = inner.getAttributeValue("AXEditableAncestor"); is( editableAncestor.getAttributeValue("AXDOMIdentifier"), "input", "Editable ancestor is input" ); await focusIntoInputAndType(accDoc, "input"); } ); // Test input that gets role::EDITCOMBOBOX addAccessibleTask(``, async (browser, accDoc) => { const box = getNativeInterface(accDoc, "box"); const editableAncestor = box.getAttributeValue("AXEditableAncestor"); is( editableAncestor.getAttributeValue("AXDOMIdentifier"), "box", "Editable ancestor is box itself" ); await focusIntoInputAndType(accDoc, "box"); }); // Test multiline caret control in a text area addAccessibleTask( ``, async (browser, accDoc) => { await focusIntoInput(accDoc, "input"); await synthKeyAndTestSelectionChanged("KEY_ArrowRight", null, "input", "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionNext, AXTextSelectionGranularity: AXTextSelectionGranularityCharacter, }); await synthKeyAndTestSelectionChanged("KEY_ArrowDown", null, "input", "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionNext, AXTextSelectionGranularity: AXTextSelectionGranularityLine, }); await synthKeyAndTestSelectionChanged( "KEY_ArrowLeft", { metaKey: true }, "input", "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionBeginning, AXTextSelectionGranularity: AXTextSelectionGranularityLine, } ); await synthKeyAndTestSelectionChanged( "KEY_ArrowRight", { metaKey: true }, "input", "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionEnd, AXTextSelectionGranularity: AXTextSelectionGranularityLine, } ); }, { topLevel: true, iframe: true, remoteIframe: true } ); /** * Test that the caret returns the correct marker when it is positioned after * the last character (to facilitate appending text). */ addAccessibleTask( ``, async function (browser, docAcc) { await focusIntoInput(docAcc, "input"); let event = await synthKeyAndTestSelectionChanged( "KEY_ArrowRight", null, "input", "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionNext, AXTextSelectionGranularity: AXTextSelectionGranularityCharacter, } ); testSelectionEventLeftChar(event, "a"); event = await synthKeyAndTestSelectionChanged( "KEY_ArrowRight", null, "input", "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionNext, AXTextSelectionGranularity: AXTextSelectionGranularityCharacter, } ); testSelectionEventLeftChar(event, "b"); event = await synthKeyAndTestSelectionChanged( "KEY_ArrowRight", null, "input", "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionNext, AXTextSelectionGranularity: AXTextSelectionGranularityCharacter, } ); testSelectionEventLeftChar(event, "c"); }, { chrome: true, topLevel: true } ); /** * Test that the caret returns the correct line when the caret is at the start * of the line. */ addAccessibleTask( `
a b c
`, async function (browser, docAcc) { let hard = getNativeInterface(docAcc, "hard"); await focusIntoInput(docAcc, "hard"); is(hard.getAttributeValue("AXInsertionPointLineNumber"), 0); let event = await synthKeyAndTestSelectionChanged( "KEY_ArrowDown", null, "hard", "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionNext, AXTextSelectionGranularity: AXTextSelectionGranularityLine, } ); testSelectionEventLine(event, "cd"); is(hard.getAttributeValue("AXInsertionPointLineNumber"), 1); event = await synthKeyAndTestSelectionChanged( "KEY_ArrowDown", null, "hard", "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionNext, AXTextSelectionGranularity: AXTextSelectionGranularityLine, } ); testSelectionEventLine(event, "ef"); is(hard.getAttributeValue("AXInsertionPointLineNumber"), 2); event = await synthKeyAndTestSelectionChanged( "KEY_ArrowDown", null, "hard", "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionNext, AXTextSelectionGranularity: AXTextSelectionGranularityLine, } ); testSelectionEventLine(event, ""); is(hard.getAttributeValue("AXInsertionPointLineNumber"), 3); event = await synthKeyAndTestSelectionChanged( "KEY_ArrowDown", null, "hard", "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionNext, AXTextSelectionGranularity: AXTextSelectionGranularityLine, } ); testSelectionEventLine(event, "gh"); is(hard.getAttributeValue("AXInsertionPointLineNumber"), 4); event = await synthKeyAndTestSelectionChanged( "KEY_ArrowDown", null, "hard", "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionNext, AXTextSelectionGranularity: AXTextSelectionGranularityLine, } ); testSelectionEventLine(event, ""); is(hard.getAttributeValue("AXInsertionPointLineNumber"), 5); let wrapped = getNativeInterface(docAcc, "wrapped"); await focusIntoInput(docAcc, "wrapped"); is(wrapped.getAttributeValue("AXInsertionPointLineNumber"), 0); event = await synthKeyAndTestSelectionChanged( "KEY_ArrowDown", null, "wrapped", "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionNext, AXTextSelectionGranularity: AXTextSelectionGranularityLine, } ); testSelectionEventLine(event, "b "); is(wrapped.getAttributeValue("AXInsertionPointLineNumber"), 1); event = await synthKeyAndTestSelectionChanged( "KEY_ArrowDown", null, "wrapped", "", { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove, AXTextSelectionDirection: AXTextSelectionDirectionNext, AXTextSelectionGranularity: AXTextSelectionGranularityLine, } ); testSelectionEventLine(event, "c"); is(wrapped.getAttributeValue("AXInsertionPointLineNumber"), 2); }, { chrome: true, topLevel: true } );