diff --git a/.gitignore b/.gitignore index 50eb2d5dc..50447732f 100644 --- a/.gitignore +++ b/.gitignore @@ -125,5 +125,3 @@ server/migrations/test_plan_target_id.csv # Private Key files (installed by deploy) jwt-signing-key.pem - -client/resources diff --git a/client/resources/aria-at-harness.mjs b/client/resources/aria-at-harness.mjs new file mode 100644 index 000000000..bc52077be --- /dev/null +++ b/client/resources/aria-at-harness.mjs @@ -0,0 +1,675 @@ +import { + element, + fragment, + property, + attribute, + className, + style, + focus, + render, +} from './vrender.mjs'; +import { + AssertionResultMap, + userCloseWindow, + userOpenWindow, + WhitespaceStyleMap, + UnexpectedBehaviorImpactMap, +} from './aria-at-test-run.mjs'; +import { TestRunExport, TestRunInputOutput } from './aria-at-test-io-format.mjs'; +import { TestWindow } from './aria-at-test-window.mjs'; + +const PAGE_STYLES = ` + table { + border-collapse: collapse; + margin-bottom: 1em; + } + + table, td, th { + border: 1px solid black; + } + + td { + padding: .5em; + } + + table.record-results tr:first-child { + font-weight: bold; + } + + textarea { + width: 100% + } + + fieldset.problem-select { + margin-top: 1em; + margin-left: 1em; + } + + div.problem-option-container.enabled { + margin-bottom: 0.5em; + } + + div.problem-option-container:last-child { + margin-bottom: 0; + } + + fieldset.assertions { + margin-bottom: 1em; + } + + label.assertion { + display: block; + } + + .required:not(.highlight-required) { + display: none; + } + + .required-other:not(.highlight-required) { + display: none; + } + + .required.highlight-required { + color: red; + } + + fieldset.highlight-required { + border-color: red; + } + + fieldset .highlight-required { + color: red; + } + + .off-screen { + position: absolute !important; + height: 1px; + width: 1px; + overflow: hidden; + clip: rect(1px, 1px, 1px, 1px); + white-space: nowrap; + } +`; + +let testRunIO = new TestRunInputOutput(); +testRunIO.setTitleInputFromTitle(document.title); +testRunIO.setUnexpectedInputFromBuiltin(); +testRunIO.setScriptsInputFromMap(typeof scripts === 'object' ? scripts : {}); + +/** + * @param {SupportJSON} newSupport + * @param {CommandsJSON} newCommandsData + * @param {AllCommandsJSON} allCommands + */ +export function initialize(newSupport, newCommandsData, allCommands) { + testRunIO.setSupportInputFromJSON(newSupport); + testRunIO.setAllCommandsInputFromJSON(allCommands); + testRunIO.setConfigInputFromQueryParamsAndSupport( + Array.from(new URL(document.location).searchParams) + ); + testRunIO.setKeysInputFromBuiltinAndConfig(); + testRunIO.setCommandsInputFromJSONAndConfigKeys(newCommandsData); +} + +/** + * @param {BehaviorJSON} atBehavior + */ +export function verifyATBehavior(atBehavior) { + if (testRunIO.behaviorInput !== null) { + throw new Error('Test files should only contain one verifyATBehavior call.'); + } + + testRunIO.setBehaviorInputFromJSONAndCommandsConfigKeysTitleUnexpected(atBehavior); +} + +export async function loadCollectedTestAsync(testRoot, testFileName) { + const collectedTestResponse = await fetch(`${testRoot}/${testFileName}`); + const collectedTestJson = await collectedTestResponse.json(); + + // v2 commands.json + const commandsJsonResponse = await fetch('../commands.json'); + if (commandsJsonResponse.ok) { + const commandsJson = await commandsJsonResponse.json(); + testRunIO.setAllCommandsInputFromJSON(commandsJson); + } + + await testRunIO.setInputsFromCollectedTestAsync(collectedTestJson, testRoot); + testRunIO.setConfigInputFromQueryParamsAndSupport([ + ['at', collectedTestJson.target.at.key], + ...Array.from(new URL(document.location).searchParams), + ]); + + displayInstructionsForBehaviorTest(); +} + +export function displayTestPageAndInstructions(testPage) { + if (document.readyState !== 'complete') { + window.setTimeout(() => { + displayTestPageAndInstructions(testPage); + }, 100); + return; + } + + testRunIO.setPageUriInputFromPageUri(testPage); + + document.querySelector('html').setAttribute('lang', 'en'); + var style = document.createElement('style'); + style.innerHTML = PAGE_STYLES; + document.head.appendChild(style); + + displayInstructionsForBehaviorTest(); +} + +function displayInstructionsForBehaviorTest() { + const windowManager = new TestWindow({ + ...testRunIO.testWindowOptions(), + hooks: { + windowOpened() { + app.dispatch(userOpenWindow()); + }, + windowClosed() { + app.dispatch(userCloseWindow()); + }, + }, + }); + + // First, execute necesary set up script in test page if the test page is open from a previous behavior test + windowManager.prepare(); + + const app = new TestRunExport({ + hooks: { + openTestPage() { + windowManager.open(); + }, + closeTestPage() { + windowManager.close(); + }, + postResults: () => postResults(testRunIO.resultJSON(app.state)), + }, + state: testRunIO.testRunState(), + resultsJSON: state => testRunIO.resultJSON(state), + }); + app.observe(() => { + render(document.body, renderVirtualTestPage(app.testPageAndResults())); + }); + render(document.body, renderVirtualTestPage(app.testPageAndResults())); + + // if test is loaded in iFrame + if (window.parent && window.parent.postMessage) { + // results can be submitted by parent posting a message to the + // iFrame with a data.type property of 'submit' + window.addEventListener('message', function (message) { + if (!validateMessage(message, 'submit')) return; + app.hooks.submit(); + }); + + // send message to parent that test has loaded + window.parent.postMessage( + { + type: 'loaded', + data: { + testPageUri: windowManager.pageUri, + }, + }, + '*' + ); + } +} + +function validateMessage(message, type) { + if (window.location.origin !== message.origin) { + return false; + } + if (!message.data || typeof message.data !== 'object') { + return false; + } + if (message.data.type !== type) { + return false; + } + return true; +} + +/** + * @param {resultsJSON} resultsJSON + */ +function postResults(resultsJSON) { + // send message to parent if test is loaded in iFrame + if (window.parent && window.parent.postMessage) { + window.parent.postMessage( + { + type: 'results', + data: resultsJSON, + }, + '*' + ); + } +} + +function bind(fn, ...args) { + return (...moreArgs) => fn(...args, ...moreArgs); +} + +const a = bind(element, 'a'); +const br = bind(element, 'br'); +const button = bind(element, 'button'); +const div = bind(element, 'div'); +const em = bind(element, 'em'); +const kbd = bind(element, 'kbd'); +const fieldset = bind(element, 'fieldset'); +const h1 = bind(element, 'h1'); +const h2 = bind(element, 'h2'); +const h3 = bind(element, 'h3'); +const hr = bind(element, 'hr'); +const input = bind(element, 'input'); +const label = bind(element, 'label'); +const select = bind(element, 'select'); +const option = bind(element, 'option'); +const legend = bind(element, 'legend'); +const li = bind(element, 'li'); +const ol = bind(element, 'ol'); +const p = bind(element, 'p'); +const script = bind(element, 'script'); +const section = bind(element, 'section'); +const span = bind(element, 'span'); +const table = bind(element, 'table'); +const td = bind(element, 'td'); +const textarea = bind(element, 'textarea'); +const th = bind(element, 'th'); +const tr = bind(element, 'tr'); +const ul = bind(element, 'ul'); + +const forInput = bind(attribute, 'for'); +const href = bind(attribute, 'href'); +const id = bind(attribute, 'id'); +const name = bind(attribute, 'name'); +const tabIndex = bind(attribute, 'tabindex'); +const textContent = bind(attribute, 'textContent'); +const type = bind(attribute, 'type'); +const ariaLabel = bind(attribute, 'aria-label'); +const ariaHidden = bind(attribute, 'aria-hidden'); + +const value = bind(property, 'value'); +const checked = bind(property, 'checked'); +const disabled = bind(property, 'disabled'); + +/** @type {(cb: (ev: MouseEvent) => void) => any} */ +const onclick = bind(property, 'onclick'); +/** @type {(cb: (ev: InputEvent) => void) => any} */ +const onchange = bind(property, 'onchange'); +/** @type {(cb: (ev: KeyboardEvent) => void) => any} */ +const onkeydown = bind(property, 'onkeydown'); + +/** + * @param {Description} value + */ +function rich(value) { + if (typeof value === 'string') { + return value; + } else if (Array.isArray(value)) { + return fragment(...value.map(rich)); + } else if (value.kbd) { + return kbd.bind(value.kbd)(rich(value.kbd)); + } else { + if ('whitespace' in value) { + if (value.whitespace === WhitespaceStyleMap.LINE_BREAK) { + return br(); + } + return null; + } + return (value.href ? a.bind(null, href(value.href)) : span)( + className([ + value.offScreen ? 'off-screen' : '', + value.required ? 'required' : '', + value.highlightRequired ? 'highlight-required' : '', + ]), + rich(value.description) + ); + } +} + +/** + * @param {TestPageAndResultsDocument} doc + */ +function renderVirtualTestPage(doc) { + return fragment( + 'instructions' in doc + ? div( + section( + id('errors'), + style({ display: doc.errors && doc.errors.visible ? 'block' : 'none' }), + h2(doc.errors ? doc.errors.header : ''), + ul( + ...(doc.errors && doc.errors.errors ? doc.errors.errors.map(error => li(error)) : []) + ), + hr() + ), + section(id('instructions'), renderVirtualInstructionDocument(doc.instructions)), + section(id('record-results')) + ) + : null, + 'results' in doc ? renderVirtualResultsTable(doc.results) : null, + doc.resultsJSON + ? script( + type('text/json'), + id('__ariaatharness__results__'), + textContent(JSON.stringify(doc.resultsJSON)) + ) + : null + ); +} + +/** + * @param doc {InstructionDocument} + */ +function renderVirtualInstructionDocument(doc) { + function compose(...fns) { + return around => fns.reduceRight((carry, fn) => fn(carry), around); + } + + const map = (ary, el) => ary.map(item => el(item)); + + return div( + instructionHeader(doc.instructions), + + instructCommands(doc.instructions.instructions), + + instructAssertions(doc.instructions.assertions), + + button( + disabled(!doc.instructions.openTestPage.enabled), + onclick(doc.instructions.openTestPage.click), + rich(doc.instructions.openTestPage.button) + ), + + resultHeader(doc.results.header), + + section(...doc.results.commands.map(commandResult)), + + doc.submit ? button(onclick(doc.submit.click), rich(doc.submit.button)) : null + ); + + /** + * @param {InstructionDocumentResultsHeader} param0 + */ + function resultHeader({ header, description }) { + return fragment(h2(rich(header)), p(rich(description))); + } + + /** + * @param {InstructionDocumentResultsCommand} command + * @param {number} commandIndex + */ + function commandResult(command, commandIndex) { + return fragment( + h3(rich(command.header)), + p( + label(rich(command.atOutput.description)), + textarea( + value(command.atOutput.value), + focus(command.atOutput.focus), + onchange(ev => + command.atOutput.change(/** @type {HTMLInputElement} */ (ev.currentTarget).value) + ) + ) + ), + fieldset( + className(['assertions']), + legend(rich(command.assertionsHeader.descriptionHeader)), + ...command.assertions.map(bind(commandResultAssertion, commandIndex)) + ), + ...[command.unexpectedBehaviors].map(bind(commandResultUnexpectedBehavior, commandIndex)) + ); + } + + /** + * @param {number} commandIndex + * @param {InstructionDocumentResultsCommandsUnexpected} unexpected + */ + function commandResultUnexpectedBehavior(commandIndex, unexpected) { + return fieldset( + id(`cmd-${commandIndex}-problem`), + rich(unexpected.description), + div( + radioChoice( + `problem-${commandIndex}-true`, + `problem-${commandIndex}`, + unexpected.passChoice + ) + ), + div( + radioChoice( + `problem-${commandIndex}-false`, + `problem-${commandIndex}`, + unexpected.failChoice + ) + ), + fieldset( + className(['problem-select']), + id(`cmd-${commandIndex}-problem-checkboxes`), + legend(rich(unexpected.failChoice.options.header)), + ...unexpected.failChoice.options.options.map(failOption => { + const failOptionId = failOption.description + .toLowerCase() + .replace(/[.,]/g, '') + .replace(/\s+/g, '-'); + + const undesirableBehaviorCheckbox = div( + input( + type('checkbox'), + value(failOption.description), + id(`${failOptionId}-${commandIndex}-checkbox`), + className([`undesirable-${commandIndex}`]), + disabled(!failOption.enabled), + checked(failOption.checked), + focus(failOption.focus), + onchange(ev => + failOption.change(/** @type {HTMLInputElement} */ (ev.currentTarget).checked) + ), + onkeydown(ev => { + if (failOption.keydown(ev.key)) { + ev.stopPropagation(); + ev.preventDefault(); + } + }) + ), + label( + id(`${failOptionId}-${commandIndex}-label`), + forInput(`${failOptionId}-${commandIndex}-checkbox`), + rich(`${failOption.description} behavior occurred`) + ) + ); + + const impactSelect = div( + className([!failOption.checked && 'off-screen']), + ariaHidden(!failOption.checked), + label(forInput(`${failOptionId}-${commandIndex}-impact`), rich('Impact:')), + select( + id(`${failOptionId}-${commandIndex}-impact`), + ariaLabel(`Impact for ${failOption.description}`), + option(UnexpectedBehaviorImpactMap.MODERATE), + option(UnexpectedBehaviorImpactMap.SEVERE), + disabled(!failOption.checked), + onchange(ev => + failOption.impactchange(/** @type {HTMLInputElement} */ (ev.currentTarget).value) + ) + ) + ); + + const detailsTextInput = div( + className([!failOption.checked && 'off-screen']), + ariaHidden(!failOption.checked), + label( + forInput(`${failOptionId}-${commandIndex}-details`), + rich(failOption.more.description) + ), + input( + type('text'), + id(`${failOptionId}-${commandIndex}-details`), + ariaLabel(`Details for ${failOption.description}`), + className(['undesirable-other-input']), + disabled(!failOption.more.enabled), + value(failOption.more.value), + onchange(ev => + failOption.more.change(/** @type {HTMLInputElement} */ (ev.currentTarget).value) + ) + ) + ); + + return div( + className(['problem-option-container', failOption.checked && 'enabled']), + undesirableBehaviorCheckbox, + impactSelect, + detailsTextInput + ); + }) + ) + ); + } + + /** + * @param {number} commandIndex + * @param {InstructionDocumentResultsCommandsAssertion} assertion + * @param {number} assertionIndex + */ + function commandResultAssertion(commandIndex, assertion, assertionIndex) { + return label( + className(['assertion']), + input( + type('checkbox'), + id(`cmd-${commandIndex}-${assertionIndex}`), + checked(assertion.passed === AssertionResultMap.PASS), + onclick(assertion.click) + ), + rich(assertion.description) + ); + } + + /** + * @param {string} idKey + * @param {string} nameKey + * @param {InstructionDocumentAssertionChoice} choice + */ + function radioChoice(idKey, nameKey, choice) { + return fragment( + input( + type('radio'), + id(idKey), + name(nameKey), + checked(choice.checked), + focus(choice.focus), + onclick(choice.click) + ), + label(id(`${idKey}-label`), forInput(`${idKey}`), rich(choice.label)) + ); + } + + /** + * @param {InstructionDocumentInstructionsInstructions} param0 + * @returns + */ + function instructCommands({ + header, + instructions, + strongInstructions: boldInstructions, + commands, + }) { + return fragment( + h2(rich(header)), + ol( + ...map(instructions, compose(li, rich)), + ...map(boldInstructions, compose(li, em, rich)), + li(rich(commands.description), ul(...map(commands.commands, compose(li, em, rich)))) + ) + ); + } + + /** + * @param {InstructionDocumentInstructions} param0 + */ + function instructionHeader({ header, description }) { + return fragment( + h1(id('behavior-header'), tabIndex('0'), focus(header.focus), rich(header.header)), + p(rich(description)) + ); + } + + /** + * @param {InstructionDocumentInstructionsAssertions} param0 + */ + function instructAssertions({ header, description, assertions }) { + return fragment( + h2(rich(header)), + p(rich(description)), + ol(...map(assertions, compose(li, em, rich))) + ); + } +} + +/** + * @param {ResultsTableDocument} results + */ +function renderVirtualResultsTable(results) { + return fragment( + h1(rich(results.header)), + h2(id('overallstatus'), rich(results.status.header)), + + table( + (({ description, support, details }) => tr(th(description), th(support), th(details)))( + results.table.headers + ), + results.table.commands.map( + ({ + description, + support, + details: { output, passingAssertions, failingAssertions, unexpectedBehaviors }, + }) => + fragment( + tr( + td(rich(description)), + td(rich(support)), + td( + p(rich(output)), + commandDetailsList(passingAssertions), + commandDetailsList(failingAssertions), + commandDetailsList(unexpectedBehaviors) + ) + ) + ) + ) + ) + ); + + /** + * @param {object} list + * @param {Description} list.description + * @param {Description[]} list.items + */ + function commandDetailsList({ description, items }) { + return div(description, ul(...items.map(description => li(rich(description))))); + } +} + +/** @typedef {import('./aria-at-test-io-format.mjs').SupportJSON} SupportJSON */ +/** @typedef {import('./aria-at-test-io-format.mjs').AllCommandsJSON} AllCommandsJSON */ +/** @typedef {import('./aria-at-test-io-format.mjs').CommandsJSON} CommandsJSON */ +/** @typedef {import('./aria-at-test-io-format.mjs').BehaviorJSON} BehaviorJSON */ + +/** @typedef {import('./aria-at-test-run.mjs').TestRunState} TestRunState */ + +/** @typedef {import('./aria-at-test-run.mjs').Description} Description */ + +/** @typedef {import('./aria-at-test-run.mjs').InstructionDocument} InstructionDocument */ +/** @typedef {import('./aria-at-test-run.mjs').InstructionDocumentInstructions} InstructionDocumentInstructions */ +/** @typedef {import('./aria-at-test-run.mjs').InstructionDocumentInstructionsAssertions} InstructionDocumentInstructionsAssertions */ +/** @typedef {import('./aria-at-test-run.mjs').InstructionDocumentResultsHeader} InstructionDocumentResultsHeader */ +/** @typedef {import('./aria-at-test-run.mjs').InstructionDocumentResultsCommand} InstructionDocumentResultsCommand */ +/** @typedef {import('./aria-at-test-run.mjs').InstructionDocumentResultsCommandsUnexpected} InstructionDocumentResultsCommandsUnexpected */ +/** @typedef {import("./aria-at-test-run.mjs").InstructionDocumentResultsCommandsAssertion} InstructionDocumentResultsCommandsAssertion */ +/** @typedef {import("./aria-at-test-run.mjs").InstructionDocumentAssertionChoice} InstructionDocumentAssertionChoice */ +/** @typedef {import("./aria-at-test-run.mjs").InstructionDocumentInstructionsInstructions} InstructionDocumentInstructionsInstructions */ + +/** @typedef {import('./aria-at-test-run.mjs').ResultsTableDocument} ResultsTableDocument */ + +/** + * @typedef {import('./aria-at-test-io-format.mjs').TestPageAndResultsDocument} TestPageAndResultsDocument + */ diff --git a/client/resources/aria-at-test-io-format.mjs b/client/resources/aria-at-test-io-format.mjs new file mode 100644 index 000000000..8121c4133 --- /dev/null +++ b/client/resources/aria-at-test-io-format.mjs @@ -0,0 +1,1878 @@ +/// +/// +/// + +import { + AssertionResultMap, + CommonResultMap, + createEnumMap, + HasUnexpectedBehaviorMap, + TestRun, + UserActionMap, +} from './aria-at-test-run.mjs'; +import * as keysModule from './keys.mjs'; + +const UNEXPECTED_BEHAVIORS = [ + 'Output is excessively verbose, e.g., includes redundant and/or irrelevant speech', + 'Reading cursor position changed in an unexpected manner', + 'Screen reader became extremely sluggish', + 'Screen reader crashed', + 'Browser crashed', +]; + +/** Depends on ConfigInput. */ +class KeysInput { + /** + * @param {object} value + * @param {string} value.origin + * @param {{[KEY_ID: string]: string}} value.keys + * @param {ATJSON} value.at + * @param {{[atMode in ATMode]: string}} value.modeInstructions + * @private + */ + constructor(value) { + this.errors = []; + + /** @private */ + this._value = value; + } + + origin() { + return this._value.origin; + } + + /** + * @param {string} keyId + * @returns {string} + */ + keysForCommand(keyId) { + return this._value.keys[keyId]; + } + + /** + * @param {ATMode} atMode + */ + modeInstructions(atMode) { + if (this._value.modeInstructions[atMode]) { + return this._value.modeInstructions[atMode]; + } + return ''; + } + + /** + * @param {object} data + * @param {ConfigInput} data.configInput + */ + static fromBuiltinAndConfig({ configInput }) { + const keys = keysModule; + const atKey = configInput.at().key; + + invariant( + ['jaws', 'nvda', 'voiceover_macos'].includes(atKey), + '%s is one of "jaws", "nvda", or "voiceover_macos"', + atKey + ); + + return new KeysInput({ + origin: 'resources/keys.mjs', + keys, + at: atKey, + modeInstructions: { + reading: { + jaws: `Verify the Virtual Cursor is active by pressing ${keys.ALT_DELETE}. If it is not, exit Forms Mode to activate the Virtual Cursor by pressing ${keys.ESC}.`, + nvda: `Ensure NVDA is in browse mode by pressing ${keys.ESC}. Note: This command has no effect if NVDA is already in browse mode.`, + voiceover_macos: `Toggle Quick Nav ON by pressing the ${keys.LEFT} and ${keys.RIGHT} keys at the same time.`, + }[atKey], + interaction: { + jaws: `Verify the PC Cursor is active by pressing ${keys.ALT_DELETE}. If it is not, turn off the Virtual Cursor by pressing ${keys.INS_Z}.`, + nvda: `If NVDA did not make the focus mode sound when the test page loaded, press ${keys.INS_SPACE} to turn focus mode on.`, + voiceover_macos: `Toggle Quick Nav OFF by pressing the ${keys.LEFT} and ${keys.RIGHT} keys at the same time.`, + }[atKey], + }, + }); + } + + /** @param {AriaATFile.CollectedTest} collectedTest */ + static fromCollectedTest(collectedTest) { + return new KeysInput({ + origin: 'test.collected.json', + keys: collectedTest.commands.reduce((carry, { keypresses }) => { + return keypresses.reduce((carry, { id, keystroke }) => { + carry[id] = keystroke; + return carry; + }, carry); + }, {}), + at: collectedTest.target.at.key, + modeInstructions: collectedTest.instructions.mode, + }); + } +} + +class SupportInput { + /** + * @param {SupportJSON} value + * @private + */ + constructor(value) { + this.errors = []; + + /** @private */ + this._value = value; + } + + defaultAT() { + return this._value.ats[0]; + } + + /** + * @param {string} atKey + * @returns {ATJSON | undefined} + */ + findAT(atKey) { + const lowercaseATKey = atKey.toLowerCase(); + return this._value.ats.find(({ key }) => key === lowercaseATKey); + } + + /** + * @param {SupportJSON} json + */ + static fromJSON(json) { + return new SupportInput(json); + } + + /** + * @param {AriaATFile.CollectedTest} collectedTest + */ + static fromCollectedTest(collectedTest) { + return new SupportInput({ + ats: [ + typeof collectedTest.target.at.raw === 'object' + ? collectedTest.target.at.raw + : { key: collectedTest.target.at.key, name: collectedTest.target.at.name }, + ], + applies_to: {}, + examples: [], + }); + } +} + +class AllCommandsInput { + /** + * @param {AllCommandsJSON} value + * @private + */ + constructor(value) { + this.errors = []; + + /** @private */ + this._value = value; + + /** @private */ + this._flattened = this.flattenObject(this._value); + } + + flattenObject(obj, parentKey) { + const flattened = {}; + + for (const key in obj) { + if (typeof obj[key] === 'object') { + const subObject = this.flattenObject(obj[key], parentKey + key + '.'); + Object.assign(flattened, subObject); + } else { + flattened[parentKey + key] = obj[key]; + } + } + + return flattened; + } + + findValueByKey(keyToFind) { + const keys = Object.keys(this._flattened); + + // Need to specially handle VO modifier key combination + if (keyToFind === 'vo') + return this.findValuesByKeys([this._flattened['modifierAliases.vo']])[0]; + + if (keyToFind.includes('modifiers.') || keyToFind.includes('keys.')) { + const parts = keyToFind.split('.'); + const keyToCheck = parts[parts.length - 1]; // value after the '.' + + if (this._flattened[keyToFind]) + return { + value: this._flattened[keyToFind], + key: keyToCheck, + }; + + return null; + } + + for (const key of keys) { + const parts = key.split('.'); + const parentKey = parts[0]; + const keyToCheck = parts[parts.length - 1]; // value after the '.' + + if (keyToCheck === keyToFind) { + if (parentKey === 'modifierAliases') { + return this.findValueByKey(`modifiers.${this._flattened[key]}`); + } else if (parentKey === 'keyAliases') { + return this.findValueByKey(`keys.${this._flattened[key]}`); + } + + return { + value: this._flattened[key], + key: keyToCheck, + }; + } + } + + // Return null if the key is not found + return null; + } + + findValuesByKeys(keysToFind = []) { + const result = []; + + const patternSepWithReplacement = (keyToFind, pattern, replacement) => { + if (keyToFind.includes(pattern)) { + let value = ''; + let validKeys = true; + const keys = keyToFind.split(pattern); + + for (const key of keys) { + const keyResult = this.findValueByKey(key); + if (keyResult) + value = value ? `${value}${replacement}${keyResult.value}` : keyResult.value; + else validKeys = false; + } + if (validKeys) return { value, key: keyToFind }; + } + + return null; + }; + + const patternSepHandler = keyToFind => { + let value = ''; + + if (keyToFind.includes(' ') && keyToFind.includes('+')) { + const keys = keyToFind.split(' '); + for (let [index, key] of keys.entries()) { + const keyToFindResult = this.findValueByKey(key); + if (keyToFindResult) keys[index] = keyToFindResult.value; + if (key.includes('+')) keys[index] = patternSepWithReplacement(key, '+', '+').value; + } + value = keys.join(' then '); + + return { value, key: keyToFind }; + } else if (keyToFind.includes(' ')) + return patternSepWithReplacement(keyToFind, ' ', ' then '); + else if (keyToFind.includes('+')) return patternSepWithReplacement(keyToFind, '+', '+'); + }; + + for (const keyToFind of keysToFind) { + if (keyToFind.includes(' ') || keyToFind.includes('+')) { + result.push(patternSepHandler(keyToFind)); + } else { + const keyToFindResult = this.findValueByKey(keyToFind); + if (keyToFindResult) result.push(keyToFindResult); + } + } + + return result; + } + + static fromJSON(json) { + return new AllCommandsInput(json); + } +} + +/** Depends on ConfigInput and KeysInput. */ +class CommandsInput { + /** + * @param {object} value + * @param {CommandsJSON} value.commands + * @param {ATJSON} value.at + * @param {KeysInput} keysInput + * @param {AllCommandsInput} allCommandsInput + * @private + */ + constructor(value, keysInput, allCommandsInput) { + this.errors = []; + + /** @private */ + this._value = value; + + /** @private */ + this._keysInput = keysInput; + + this._allCommandsInput = allCommandsInput; + } + + /** + * @param {object} config + * @param {string} config.task + * @param {ATMode} mode + * @returns {string[]} + */ + getCommands({ task }, mode) { + if (mode === 'reading' || mode === 'interaction') { + const v1Commands = this.getCommandsV1(task, mode); + return { + commands: v1Commands, + commandsAndSettings: v1Commands.map(command => ({ command })), + }; + } else { + return this.getCommandsV2({ task }, mode); + } + } + + getCommandsV1(task, mode) { + const assistiveTech = this._value.at; + + if (!this._value.commands[task]) { + throw new Error( + `Task "${task}" does not exist, please add to at-commands or correct your spelling.` + ); + } else if (!this._value.commands[task][mode]) { + throw new Error( + `Mode "${mode}" instructions for task "${task}" does not exist, please add to at-commands or correct your spelling.` + ); + } + + let commandsData = this._value.commands[task][mode][assistiveTech.key] || []; + let commands = []; + + for (let c of commandsData) { + let innerCommands = []; + let commandSequence = c[0].split(','); + for (let command of commandSequence) { + command = this._keysInput.keysForCommand(command); + if (typeof command === 'undefined') { + throw new Error( + `Key instruction identifier "${c}" for AT "${assistiveTech.name}", mode "${mode}", task "${task}" is not an available identified. Update you commands.json file to the correct identifier or add your identifier to resources/keys.mjs.` + ); + } + + let furtherInstruction = c[1]; + command = furtherInstruction ? `${command} ${furtherInstruction}` : command; + innerCommands.push(command); + } + commands.push(innerCommands.join(', then ')); + } + + return commands; + } + + getCommandsV2({ task }, mode) { + const assistiveTech = this._value.at; + let commandsAndSettings = []; + let commands = []; + + // Mode could be in the format of mode1_mode2 + // If they are from the same AT, this needs to return the function in the format of [ [[commands], settings], [[commands], settings], ... ] + for (const _atMode of mode.split('_')) { + if (assistiveTech.settings[_atMode] || _atMode === 'defaultMode') { + const [atMode] = deriveModeWithTextAndInstructions(_atMode, assistiveTech); + + if (!this._value.commands[task]) { + throw new Error( + `Task "${task}" does not exist, please add to at-commands or correct your spelling.` + ); + } else if (!this._value.commands[task][atMode]) { + throw new Error( + `Mode "${atMode}" instructions for task "${task}" does not exist, please add to at-commands or correct your spelling.` + ); + } + + let commandsData = this._value.commands[task][atMode][assistiveTech.key] || []; + for (let commandSequence of commandsData) { + for (const commandWithPresentationNumber of commandSequence) { + const [commandId, presentationNumber] = commandWithPresentationNumber.split('|'); + + let command; + const foundCommandKV = this._allCommandsInput.findValuesByKeys([commandId]); + if (!foundCommandKV.length) command = undefined; + else { + const { value } = this._allCommandsInput.findValuesByKeys([commandId])[0]; + command = value; + } + + if (typeof command === 'undefined') { + throw new Error( + `Key instruction identifier "${commandSequence}" for AT "${assistiveTech.name}", mode "${atMode}", task "${task}" is not an available identified. Update your commands.json file to the correct identifier or add your identifier to resources/keys.mjs.` + ); + } + + commands.push(command); + commandsAndSettings.push({ + command, + commandId, + presentationNumber: Number(presentationNumber), + settings: _atMode, + settingsText: assistiveTech.settings?.[_atMode]?.screenText || 'default mode active', + settingsInstructions: assistiveTech.settings?.[_atMode]?.instructions || [ + assistiveTech.defaultConfigurationInstructionsHTML, + ], + }); + } + } + } + } + + return { commands, commandsAndSettings }; + } + + /** + * @param {CommandsJSON} json + * @param {object} data + * @param {ConfigInput} data.configInput + * @param {KeysInput} data.keysInput + */ + static fromJSONAndConfigKeys(json, { configInput, keysInput, allCommandsInput }) { + return new CommandsInput({ commands: json, at: configInput.at() }, keysInput, allCommandsInput); + } + + /** + * @param {AriaATFile.CollectedTest} collectedTest + * @param {object} data + * @param {KeysInput} data.keysInput + */ + static fromCollectedTestKeys(collectedTest, { keysInput, allCommandsInput }) { + let settingsForTest = {}; + + // For v2 test format + const settings = collectedTest.target.at.settings; + if (settings) { + for (const _atMode of settings.split('_')) { + settingsForTest[_atMode] = { + // Use settings attribute to verify in filter if available + [collectedTest.target.at.key]: collectedTest.commands + .filter(({ settings }) => (settings ? settings === _atMode : true)) + .map(({ id, extraInstruction }) => (extraInstruction ? [id, extraInstruction] : [id])), + }; + } + } else { + settingsForTest = { + [collectedTest.target.mode]: { + [collectedTest.target.at.key]: collectedTest.commands.map(({ id, extraInstruction }) => + extraInstruction ? [id, extraInstruction] : [id] + ), + }, + }; + } + + return new CommandsInput( + { + commands: { + [collectedTest.info.task || collectedTest.info.testId]: settingsForTest, + }, + at: + typeof collectedTest.target.at.raw === 'object' + ? collectedTest.target.at.raw + : collectedTest.target.at, + }, + keysInput, + allCommandsInput + ); + } +} + +/** + * Depends on SupportInput. + */ +class ConfigInput { + /** + * @param {string[]} errors + * @param {object} value + * @param {ATJSON} value.at + * @param {boolean} value.displaySubmitButton + * @param {boolean} value.renderResultsAfterSubmit + * @param {"SubmitResultsJSON" | "TestResultJSON"} value.resultFormat + * @param {AriaATTestResult.JSON | null} value.resultJSON + * @private + */ + constructor(errors, value) { + this.errors = errors; + + /** @private */ + this._value = value; + } + + at() { + return this._value.at; + } + + displaySubmitButton() { + return this._value.displaySubmitButton; + } + + renderResultsAfterSubmit() { + return this._value.renderResultsAfterSubmit; + } + + resultFormat() { + return this._value.resultFormat; + } + + resultJSON() { + return this._value.resultJSON; + } + + /** + * @param {ConfigQueryParams} queryParams + * @param {object} data + * @param {SupportInput} data.supportInput + */ + static fromQueryParamsAndSupport(queryParams, { supportInput }) { + const errors = []; + + let at = supportInput.defaultAT(); + let displaySubmitButton = true; + let renderResultsAfterSubmit = true; + let resultFormat = 'SubmitResultsJSON'; + let resultJSON = null; + + for (const [key, value] of queryParams) { + if (key === 'at') { + const requestedAT = value; + const knownAt = supportInput.findAT(requestedAT); + if (knownAt) { + at = knownAt; + } else { + errors.push( + `Harness does not have commands for the requested assistive technology ('${requestedAT}'), showing commands for assistive technology '${at.name}' instead. To test '${requestedAT}', please contribute command mappings to this project.` + ); + } + } else if (key === 'showResults') { + displaySubmitButton = decodeBooleanParam(value, displaySubmitButton); + } else if (key === 'showSubmitButton') { + renderResultsAfterSubmit = decodeBooleanParam(value, renderResultsAfterSubmit); + } else if (key === 'resultFormat') { + if (value !== 'SubmitResultsJSON' && value !== 'TestResultJSON') { + errors.push( + `resultFormat can be 'SubmitResultsJSON' or 'TestResultJSON'. '${value}' is not supported.` + ); + continue; + } + resultFormat = value; + } else if (key === 'resultJSON') { + try { + resultJSON = JSON.parse(value); + } catch (error) { + errors.push(`Failed to parse resultJSON: ${error.message}`); + } + } + } + + if (resultJSON && resultFormat !== 'TestResultJSON') { + errors.push(`resultJSON requires resultFormat to be set to 'TestResultJSON'.`); + resultJSON = null; + } + + return new ConfigInput(errors, { + at, + displaySubmitButton, + renderResultsAfterSubmit, + resultFormat, + resultJSON, + }); + + /** + * @param {string} param + * @param {boolean} defaultValue + * @returns {boolean} + */ + function decodeBooleanParam(param, defaultValue) { + if (param === 'true') { + return true; + } else if (param === 'false') { + return false; + } + return defaultValue; + } + } +} + +class ScriptsInput { + /** + * @param {object} value + * @param {SetupScripts} value.scripts + * @private + */ + constructor(value) { + this.errors = []; + + /** @private */ + this._value = value; + } + + scripts() { + return this._value.scripts; + } + + /** + * @param {SetupScripts} scripts + */ + static fromScriptsMap(scripts) { + return new ScriptsInput({ scripts }); + } + + /** + * @param {{source: string}} script + * @private + */ + static scriptsFromSource(script) { + return { [script.name]: new Function('testPageDocument', script.source) }; + } + + /** + * @param {{modulePath: string}} script + * @param {string} dataUrl + * @private + */ + static async scriptsFromModuleAsync(script, dataUrl) { + return await import(`${dataUrl}/${script.modulePath}`); + } + + /** + * @param {{jsonpPath: string}} script + * @param {string} dataUrl + * @private + */ + static async scriptsFromJsonpAsync(script, dataUrl) { + return await Promise.race([ + new Promise(resolve => { + window.scriptsJsonpLoaded = resolve; + const scriptTag = document.createElement('script'); + scriptTag.src = script.jsonpPath; + document.body.appendChild(scriptTag); + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Loading scripts timeout error')), 10000) + ), + ]); + } + + /** + * @param {AriaATFile.CollectedTest} collectedAsync + * @param {string} dataUrl url to directory where CollectedTest was loaded from + */ + static async fromCollectedTestAsync({ target: { setupScript } }, dataUrl) { + if (!setupScript) { + return new ScriptsInput({ scripts: {} }); + } + try { + return new ScriptsInput({ scripts: ScriptsInput.scriptsFromSource(setupScript) }); + } catch (error) { + try { + return new ScriptsInput({ + scripts: await ScriptsInput.scriptsFromModuleAsync(setupScript, dataUrl), + }); + } catch (error2) { + try { + return new ScriptsInput({ + scripts: await ScriptsInput.scriptsFromJsonpAsync(setupScript, dataUrl), + }); + } catch (error3) { + throw new Error( + [error, error2, error3].map(error => error.stack || error.message).join('\n\n') + ); + } + } + } + } +} + +class UnexpectedInput { + /** + * @param {object} value + * @param {BehaviorUnexpectedItem[]} value.behaviors + * @private + */ + constructor(value) { + this.errors = []; + + this._value = value; + } + + behaviors() { + return this._value.behaviors; + } + + static fromBuiltin() { + return new UnexpectedInput({ + behaviors: [ + ...UNEXPECTED_BEHAVIORS.map(description => ({ description })), + { description: 'Other' }, + ], + }); + } +} + +class TitleInput { + /** + * @param {object} value + * @param {string} value.title + * @private + */ + constructor(value) { + this.errors = []; + + /** @private */ + this._value = value; + } + + title() { + return this._value.title; + } + + /** @param {string} title */ + static fromTitle(title) { + return new TitleInput({ + title, + }); + } +} + +/** Depends on CommandsInput, ConfigInput, KeysInput, TitleInput, and UnexpectedInput. */ +class BehaviorInput { + /** + * @param {object} value + * @param {Behavior} value.behavior + * @private + */ + constructor(value) { + this.errors = []; + + /** @private */ + this._value = value; + } + + behavior() { + return this._value.behavior; + } + + /** + * @param {BehaviorJSON} json + * @param {object} data + * @param {KeysInput} data.keysInput + * @param {CommandsInput} data.commandsInput + * @param {ConfigInput} data.configInput + * @param {UnexpectedInput} data.unexpectedInput + * @param {TitleInput} data.titleInput + */ + static fromJSONCommandsConfigKeysTitleUnexpected( + json, + { commandsInput, configInput, keysInput, titleInput, unexpectedInput } + ) { + const mode = Array.isArray(json.mode) ? json.mode[0] : json.mode; + const at = configInput.at(); + + const { commandsAndSettings } = commandsInput.getCommands({ task: json.task }, mode); + + // Use to determine assertionExceptions + const commandsInfo = json.commandsInfo?.[at.key]; + + return new BehaviorInput({ + behavior: { + description: titleInput.title(), + task: json.task, + mode, + modeInstructions: keysInput.modeInstructions(mode), + appliesTo: json.applies_to, + specificUserInstruction: json.specific_user_instruction, + setupScriptDescription: json.setup_script_description, + setupTestPage: json.setupTestPage, + assertionResponseQuestion: json.assertionResponseQuestion, + commands: commandsAndSettings.map(cs => { + const foundCommandInfo = commandsInfo?.find( + c => + cs.commandId === c.command && + cs.presentationNumber === c.presentationNumber && + cs.settings === c.settings + ); + if (!foundCommandInfo || !foundCommandInfo.assertionExceptions) return cs; + + // Only works for v2 + let assertionExceptions = json.output_assertions.map(each => each.assertionId); + foundCommandInfo.assertionExceptions.split(' ').forEach(each => { + let [priority, assertionId] = each.split(':'); + const index = assertionExceptions.findIndex(each => each === assertionId); + + priority = Number(priority); + assertionExceptions[index] = priority; + }); + // Preserve default priority or update with exception + assertionExceptions = assertionExceptions.map((each, index) => + isNaN(each) ? json.output_assertions[index].priority : each + ); + + return { ...cs, assertionExceptions }; + }), + assertions: (json.output_assertions ? json.output_assertions : []).map(assertion => { + // Tuple array [ priorityNumber, assertionText ] + if (Array.isArray(assertion)) { + return { + priority: Number(assertion[0]), + assertion: assertion[1], + }; + } + + // { assertionId, priority, assertionStatement, assertionPhrase, refIds, tokenizedAssertionStatements, tokenizedAssertionPhrases } + return { + priority: assertion.priority, + assertion: + assertion.tokenizedAssertionStatements?.[at.key] || assertion.assertionStatement, + }; + }), + additionalAssertions: (json.additional_assertions + ? json.additional_assertions[at.key] || [] + : [] + ).map(assertionTuple => ({ + priority: Number(assertionTuple[0]), + assertion: assertionTuple[1], + })), + unexpectedBehaviors: unexpectedInput.behaviors(), + }, + }); + } + + /** + * @param {AriaATFile.CollectedTest} collectedTest + * @param {object} data + * @param {CommandsInput} data.commandsInput + * @param {KeysInput} data.keysInput + * @param {UnexpectedInput} data.unexpectedInput + */ + static fromCollectedTestCommandsKeysUnexpected( + { info, target, instructions, assertions, commands }, + { commandsInput, keysInput, unexpectedInput } + ) { + // v1:info.task, v2: info.testId | v1:target.mode, v2:target.at.settings + const { commandsAndSettings } = commandsInput.getCommands( + { task: info.task || info.testId }, + target.mode || target.at.settings + ); + + return new BehaviorInput({ + behavior: { + description: info.title, + task: info.task || info.testId, + mode: target.mode || target.at.settings, + modeInstructions: instructions.mode, + appliesTo: [target.at.name], + specificUserInstruction: instructions.raw || instructions.instructions, + setupScriptDescription: target.setupScript ? target.setupScript.description : '', + setupTestPage: target.setupScript ? target.setupScript.name : undefined, + commands: commandsAndSettings.map(cs => { + const foundCommandInfo = commands.find( + c => cs.commandId === c.id && cs.settings === c.settings + ); + if (!foundCommandInfo || !foundCommandInfo.assertionExceptions) return cs; + + // Only works for v2 + let assertionExceptions = assertions.map(each => each.assertionId); + foundCommandInfo.assertionExceptions.forEach(each => { + let { priority, assertionId } = each; + const index = assertionExceptions.findIndex(each => each === assertionId); + + priority = Number(priority); + assertionExceptions[index] = priority; + }); + // Preserve default priority or update with exception + assertionExceptions = assertionExceptions.map((each, index) => + isNaN(each) ? assertions[index].priority : each + ); + + return { ...cs, assertionExceptions }; + }), + assertions: assertions.map( + ({ priority, expectation, assertionStatement, tokenizedAssertionStatements }) => { + let assertion = tokenizedAssertionStatements + ? tokenizedAssertionStatements[target.at.key] + : null; + assertion = assertion || expectation || assertionStatement; + + return { + priority, + assertion, + }; + } + ), + additionalAssertions: [], + unexpectedBehaviors: unexpectedInput.behaviors(), + }, + }); + } +} + +class PageUriInput { + /** + * @param {object} value + * @param {string} value.pageUri + * @private + */ + constructor(value) { + this._errors = []; + this._value = value; + } + + pageUri() { + return this._value.pageUri; + } + + /** + * @param {string} pageUri + */ + static fromPageUri(pageUri) { + return new PageUriInput({ pageUri }); + } +} + +export class TestRunInputOutput { + constructor() { + /** @type {BehaviorInput} */ + this.behaviorInput = null; + /** @type {CommandsInput} */ + this.commandsInput = null; + /** @type {ConfigInput} */ + this.configInput = null; + /** @type {KeysInput} */ + this.keysInput = null; + /** @type {PageUriInput} */ + this.pageUriInput = null; + /** @type {ScriptsInput} */ + this.scriptsInput = null; + /** @type {SupportInput} */ + this.supportInput = null; + /** @type {AllCommandsInput} */ + this.allCommandsInput = null; + /** @type {TitleInput} */ + this.titleInput = null; + /** @type {UnexpectedInput} */ + this.unexpectedInput = null; + } + + /** @param {BehaviorInput} behaviorInput */ + setBehaviorInput(behaviorInput) { + this.behaviorInput = behaviorInput; + } + + /** @param {BehaviorJSON} behaviorJSON */ + setBehaviorInputFromJSONAndCommandsConfigKeysTitleUnexpected(behaviorJSON) { + invariant( + this.commandsInput !== null, + 'Call %s or %s before calling %s.', + this.setCommandsInput.name, + this.setCommandsInputFromJSONAndConfigKeys.name, + this.setBehaviorInputFromJSONAndCommandsConfigKeysTitleUnexpected.name + ); + invariant( + this.configInput !== null, + 'Call %s or %s before calling %s.', + this.setConfigInput.name, + this.setConfigInputFromQueryParamsAndSupport.name, + this.setBehaviorInputFromJSONAndCommandsConfigKeysTitleUnexpected.name + ); + invariant( + this.keysInput !== null, + 'Call %s or %s before calling %s.', + this.setKeysInput.name, + this.setKeysInputFromBuiltinAndConfig.name, + this.setBehaviorInputFromJSONAndCommandsConfigKeysTitleUnexpected.name + ); + invariant( + this.titleInput !== null, + 'Call %s or %s before calling %s.', + this.setTitleInput.name, + this.setTitleInputFromTitle.name, + this.setBehaviorInputFromJSONAndCommandsConfigKeysTitleUnexpected.name + ); + invariant( + this.unexpectedInput !== null, + 'Call %s or %s before calling %s.', + this.setUnexpectedInput.name, + this.setUnexpectedInputFromBuiltin.name, + this.setBehaviorInputFromJSONAndCommandsConfigKeysTitleUnexpected.name + ); + + this.setBehaviorInput( + BehaviorInput.fromJSONCommandsConfigKeysTitleUnexpected(behaviorJSON, { + commandsInput: this.commandsInput, + configInput: this.configInput, + keysInput: this.keysInput, + titleInput: this.titleInput, + unexpectedInput: this.unexpectedInput, + }) + ); + } + + /** + * Set all inputs but ConfigInput. + * @param {AriaATFile.CollectedTest} collectedTest + * @param {string} dataUrl url to directory where CollectedTest was loaded from + */ + async setInputsFromCollectedTestAsync(collectedTest, dataUrl) { + const pageUriInput = PageUriInput.fromPageUri(collectedTest.target.referencePage); + const titleInput = TitleInput.fromTitle(collectedTest.info.title); + const supportInput = SupportInput.fromCollectedTest(collectedTest); + const scriptsInput = await ScriptsInput.fromCollectedTestAsync(collectedTest, dataUrl); + + const unexpectedInput = UnexpectedInput.fromBuiltin(); + const keysInput = KeysInput.fromCollectedTest(collectedTest); + const allCommandsInput = this.allCommandsInput; + const commandsInput = CommandsInput.fromCollectedTestKeys(collectedTest, { + keysInput, + allCommandsInput, + }); + const behaviorInput = BehaviorInput.fromCollectedTestCommandsKeysUnexpected(collectedTest, { + commandsInput, + keysInput, + unexpectedInput, + }); + + this.setTitleInput(titleInput); + this.setPageUriInput(pageUriInput); + this.setSupportInput(supportInput); + this.setScriptsInput(scriptsInput); + + this.setUnexpectedInput(unexpectedInput); + this.setKeysInput(keysInput); + this.setCommandsInput(commandsInput); + this.setBehaviorInput(behaviorInput); + } + + /** @param {CommandsInput} commandsInput */ + setCommandsInput(commandsInput) { + this.commandsInput = commandsInput; + } + + /** @param {CommandsJSON} commandsJSON */ + setCommandsInputFromJSONAndConfigKeys(commandsJSON) { + invariant( + this.configInput !== null, + 'Call %s or %s before calling %s.', + this.setConfigInput.name, + this.setConfigInputFromQueryParamsAndSupport.name, + this.setCommandsInputFromJSONAndConfigKeys.name + ); + invariant( + this.keysInput !== null, + 'Call %s or %s before calling %s.', + this.setKeysInput.name, + this.setKeysInputFromBuiltinAndConfig.name, + this.setCommandsInputFromJSONAndConfigKeys.name + ); + + this.setCommandsInput( + CommandsInput.fromJSONAndConfigKeys(commandsJSON, { + configInput: this.configInput, + keysInput: this.keysInput, + allCommandsInput: this.allCommandsInput, + }) + ); + } + + /** @param {ConfigInput} configInput */ + setConfigInput(configInput) { + this.configInput = configInput; + } + + /** @param {ConfigQueryParams} queryParams */ + setConfigInputFromQueryParamsAndSupport(queryParams) { + invariant( + this.supportInput !== null, + 'Call %s or %s before calling %s.', + this.setSupportInput.name, + this.setSupportInputFromJSON.name, + this.setConfigInputFromQueryParamsAndSupport.name + ); + + this.setConfigInput( + ConfigInput.fromQueryParamsAndSupport(queryParams, { + supportInput: this.supportInput, + }) + ); + } + + /** @param {KeysInput} keysInput */ + setKeysInput(keysInput) { + this.keysInput = keysInput; + } + + setKeysInputFromBuiltinAndConfig() { + invariant( + this.configInput !== null, + 'Call %s or %s before calling %s.', + this.setConfigInput.name, + this.setConfigInputFromQueryParamsAndSupport.name, + this.setCommandsInputFromJSONAndConfigKeys.name + ); + + this.setKeysInput(KeysInput.fromBuiltinAndConfig({ configInput: this.configInput })); + } + + /** @param {PageUriInput} pageUriInput */ + setPageUriInput(pageUriInput) { + this.pageUriInput = pageUriInput; + } + + /** @param {string} pageUri */ + setPageUriInputFromPageUri(pageUri) { + this.setPageUriInput(PageUriInput.fromPageUri(pageUri)); + } + + /** @param {ScriptsInput} scriptsInput */ + setScriptsInput(scriptsInput) { + this.scriptsInput = scriptsInput; + } + + /** @param {SetupScripts} scriptsMap */ + setScriptsInputFromMap(scriptsMap) { + this.setScriptsInput(ScriptsInput.fromScriptsMap(scriptsMap)); + } + + /** @param {SupportInput} supportInput */ + setSupportInput(supportInput) { + this.supportInput = supportInput; + } + + /** @param {SupportJSON} supportJSON */ + setSupportInputFromJSON(supportJSON) { + this.setSupportInput(SupportInput.fromJSON(supportJSON)); + } + + /** @param {AllCommandsInput} allCommandsInput */ + setAllCommandsInput(allCommandsInput) { + this.allCommandsInput = allCommandsInput; + } + + /** @param {AllCommandsJSON} allCommandsJSON */ + setAllCommandsInputFromJSON(allCommandsJSON) { + this.setAllCommandsInput(AllCommandsInput.fromJSON(allCommandsJSON)); + } + + /** @param {TitleInput} titleInput */ + setTitleInput(titleInput) { + this.titleInput = titleInput; + } + + /** @param {string} title */ + setTitleInputFromTitle(title) { + this.setTitleInput(TitleInput.fromTitle(title)); + } + + /** @param {UnexpectedInput} unexpectedInput */ + setUnexpectedInput(unexpectedInput) { + this.unexpectedInput = unexpectedInput; + } + + setUnexpectedInputFromBuiltin() { + this.setUnexpectedInput(UnexpectedInput.fromBuiltin()); + } + + /** @returns {AriaATTestRun.State} */ + testRunState() { + invariant( + this.behaviorInput !== null, + 'Call %s or %s before calling %s.', + this.setBehaviorInput.name, + this.setBehaviorInputFromJSONAndCommandsConfigKeysTitleUnexpected.name, + this.testRunState.name + ); + invariant( + this.configInput !== null, + 'Call %s or %s before calling %s.', + this.setConfigInput.name, + this.setConfigInputFromQueryParamsAndSupport.name, + this.testRunState.name + ); + + const errors = [ + ...this.behaviorInput.errors, + ...this.commandsInput.errors, + ...this.configInput.errors, + ]; + const test = this.behaviorInput.behavior(); + const config = this.configInput; + + function unescapeHTML(input) { + const textarea = document.createElement('textarea'); + textarea.innerHTML = input; + return textarea.value; + } + + const [atMode, screenText, instructions] = deriveModeWithTextAndInstructions( + test.mode, + config.at() + ); + + let state = { + errors, + info: { + description: test.description, + task: test.task, + mode: screenText || atMode, + modeInstructions: Array.isArray(instructions) + ? unescapeHTML(`${instructions[0]} ${instructions[1]}`) + : test.modeInstructions, + userInstructions: test.specificUserInstruction.split('|'), + setupScriptDescription: test.setupScriptDescription, + }, + config: { + at: config.at(), + displaySubmitButton: config.displaySubmitButton(), + renderResultsAfterSubmit: config.renderResultsAfterSubmit(), + }, + currentUserAction: UserActionMap.LOAD_PAGE, + openTest: { + enabled: true, + }, + assertionResponseQuestion: test.assertionResponseQuestion, + commands: test.commands.map( + command => + /** @type {import("./aria-at-test-run.mjs").TestRunCommand} */ ({ + description: command.command, + commandSettings: { + command: command.command, + description: command.settings, + text: command.settingsText, + instructions: command.settingsInstructions, + assertionExceptions: command.assertionExceptions, + }, + atOutput: { + highlightRequired: false, + value: '', + }, + assertions: test.assertions.map(assertion => ({ + description: assertion.assertion, + highlightRequired: false, + priority: assertion.priority, + result: CommonResultMap.NOT_SET, + })), + additionalAssertions: test.additionalAssertions.map(assertion => ({ + description: assertion.assertion, + highlightRequired: false, + priority: assertion.priority, + result: CommonResultMap.NOT_SET, + })), + unexpected: { + highlightRequired: false, + hasUnexpected: HasUnexpectedBehaviorMap.NOT_SET, + tabbedBehavior: 0, + behaviors: test.unexpectedBehaviors.map(({ description }) => ({ + description, + checked: false, + impact: UnexpectedBehaviorImpactMap.MODERATE, + more: { highlightRequired: false, value: '' }, + })), + }, + }) + ), + }; + + if (this.configInput.resultJSON()) { + state = this.testRunStateFromTestResultJSON(this.configInput.resultJSON(), state); + } + + return state; + } + + testWindowOptions() { + invariant( + this.behaviorInput !== null, + 'Call %s or %s before calling %s.', + this.setBehaviorInput.name, + this.setBehaviorInputFromJSONAndCommandsConfigKeysTitleUnexpected.name, + this.testWindowOptions.name + ); + invariant( + this.pageUriInput !== null, + 'Call %s or %s before calling %s.', + this.setPageUriInput.name, + this.setPageUriInputFromPageUri.name, + this.testWindowOptions.name + ); + invariant( + this.scriptsInput !== null, + 'Call %s or %s before calling %s.', + this.setScriptsInput.name, + this.setScriptsInputFromMap.name, + this.testWindowOptions.name + ); + + return { + pageUri: this.pageUriInput.pageUri(), + setupScriptName: this.behaviorInput.behavior().setupTestPage, + scripts: this.scriptsInput.scripts(), + }; + } + + /** + * @param {AriaATTestRun.State} state + * @returns {import("./aria-at-harness.mjs").SubmitResultJSON} + */ + submitResultsJSON(state) { + invariant( + this.behaviorInput !== null, + 'Call %s or %s before calling %s.', + this.setBehaviorInput.name, + this.setBehaviorInputFromJSONAndCommandsConfigKeysTitleUnexpected.name, + this.submitResultsJSON.name + ); + + const behavior = this.behaviorInput.behavior(); + + /** @type {SubmitResultDetailsJSON} */ + const details = { + name: state.info.description, + task: state.info.task, + specific_user_instruction: behavior.specificUserInstruction, + summary: { + 1: { + pass: countAssertions( + ({ priority, result }) => priority === 1 && result === CommonResultMap.PASS + ), + fail: countAssertions( + ({ priority, result }) => priority === 1 && result !== CommonResultMap.PASS + ), + }, + 2: { + pass: countAssertions( + ({ priority, result }) => priority === 2 && result === CommonResultMap.PASS + ), + fail: countAssertions( + ({ priority, result }) => priority === 2 && result !== CommonResultMap.PASS + ), + }, + unexpectedCount: countUnexpectedBehaviors(({ checked }) => checked), + }, + commands: state.commands.map(command => ({ + command: command.description, + output: command.atOutput.value, + support: commandSupport(command), + assertions: [...command.assertions, ...command.additionalAssertions].map( + assertionToAssertion + ), + unexpected_behaviors: command.unexpected.behaviors + .filter(({ checked }) => checked) + .map(({ description, more }) => (more ? more.value : description)), + })), + }; + + /** @type {SubmitResultStatusJSON} */ + const status = state.commands + .map(commandSupport) + .some(support => support === CommandSupportJSONMap.FAILING) + ? StatusJSONMap.FAIL + : StatusJSONMap.PASS; + + return { + test: state.info.description, + details, + status, + }; + + function commandSupport(command) { + const allAssertions = [...command.assertions, ...command.additionalAssertions]; + return allAssertions.some( + ({ priority, result }) => priority === 1 && result !== CommonResultMap.PASS + ) || command.unexpected.behaviors.some(({ checked }) => checked) + ? CommandSupportJSONMap.FAILING + : allAssertions.some( + ({ priority, result }) => priority === 2 && result !== CommonResultMap.PASS + ) + ? CommandSupportJSONMap.ALL_REQUIRED + : CommandSupportJSONMap.FULL; + } + + /** + * @param {(assertion: TestRunAssertion | TestRunAdditionalAssertion) => boolean} filter + * @returns {number} + */ + function countAssertions(filter) { + return state.commands.reduce( + (carry, command) => + carry + [...command.assertions, ...command.additionalAssertions].filter(filter).length, + 0 + ); + } + + /** + * @param {(behavior: TestRunUnexpected) => boolean} filter + * @returns {number} + */ + function countUnexpectedBehaviors(filter) { + return state.commands.reduce( + (carry, command) => carry + command.unexpected.behaviors.filter(filter).length, + 0 + ); + } + + /** + * @param {TestRunAssertion | TestRunAdditionalAssertion} assertion + * @returns {SubmitResultAssertionsJSON} + */ + function assertionToAssertion(assertion) { + return assertion.result === CommonResultMap.PASS + ? { + assertion: assertion.description, + priority: assertion.priority.toString(), + pass: AssertionPassJSONMap.GOOD_OUTPUT, + } + : { + assertion: assertion.description, + priority: assertion.priority.toString(), + fail: + assertion.result === AssertionResultMap.FAIL_MISSING + ? AssertionFailJSONMap.NO_OUTPUT + : assertion.result === AssertionResultMap.FAIL_INCORRECT + ? AssertionFailJSONMap.INCORRECT_OUTPUT + : AssertionFailJSONMap.NO_SUPPORT, + }; + } + } + + /** + * Transform a test run state into a test result json for serialization. + * @param {AriaATTestRun.State} state + * @returns {AriaATTestResult.JSON} + */ + testResultJSON(state) { + return { + test: { + title: state.info.description, + at: { + id: state.config.at.key, + }, + atMode: state.info.mode, + }, + scenarioResults: state.commands.map(command => ({ + scenario: { + command: { + id: command.description, + }, + }, + output: command.atOutput.value, + assertionResults: command.assertions.map(assertion => ({ + assertion: { + priority: assertion.priority === 1 ? 'MUST' : 'SHOULD', + text: assertion.description, + }, + passed: assertion.result === 'pass', + failedReason: + assertion.result === 'failIncorrect' + ? 'INCORRECT_OUTPUT' + : assertion.result === 'failMissing' + ? 'NO_OUTPUT' + : null, + })), + unexpectedBehaviors: command.unexpected.behaviors + .map(behavior => + behavior.checked + ? { + text: behavior.description, + impact: behavior.impact, + details: behavior.more.value, + } + : null + ) + .filter(Boolean), + })), + }; + } + + /** + * @param {AriaATTestRun.State} state + * @returns {SubmitResultJSON | AriaATTestResult.JSON} + */ + resultJSON(state) { + // If ConfigInput is available and resultFormat is TestResultJSON return result in that format. + if (this.configInput !== null) { + const resultFormat = this.configInput.resultFormat(); + if (resultFormat === 'TestResultJSON') { + return this.testResultJSON(state); + } + } + + return this.submitResultsJSON(state); + } + + /** + * Set a default or given test run state with the recorded results json. Intermediate state not stored into + * testResult, like highlightRequired, is to the default. + * @param {AriaATTestResult.JSON} testResult + * @param {AriaATTestRun.State} [state] + * @returns {AriaATTestRun.State} + */ + testRunStateFromTestResultJSON(testResult, state = this.testRunState()) { + return { + ...state, + commands: state.commands.map((command, commandIndex) => { + const scenarioResult = testResult.scenarioResults[commandIndex]; + return { + ...command, + atOutput: { highlightRequired: false, value: scenarioResult.output }, + assertions: command.assertions.map((assertion, assertionIndex) => { + const assertionResult = scenarioResult.assertionResults[assertionIndex]; + return { + ...assertion, + highlightRequired: false, + result: assertionResult.passed + ? 'pass' + : assertionResult.failedReason === 'INCORRECT_OUTPUT' + ? 'failIncorrect' + : assertionResult.failedReason === 'NO_OUTPUT' + ? 'failMissing' + : 'fail', + }; + }), + unexpected: { + ...command.unexpected, + highlightRequired: false, + hasUnexpected: + scenarioResult.unexpectedBehaviors.length > 0 + ? 'hasUnexpected' + : 'doesNotHaveUnexpected', + tabbedBehavior: 0, + behaviors: command.unexpected.behaviors.map(behavior => { + const behaviorResult = scenarioResult.unexpectedBehaviors.find( + unexpectedResult => unexpectedResult.text === behavior.description + ); + return { + ...behavior, + checked: behaviorResult ? true : false, + more: behavior.more + ? { + highlightRequired: false, + impact: behaviorResult + ? behavior.impact + : UnexpectedBehaviorImpactMap.MODERATE, + value: behaviorResult ? behaviorResult.details : '', + } + : behavior.more, + }; + }), + }, + }; + }), + }; + } +} + +/** + * Extended TestRun that can access methods to turn the TestRun["state"] into + * the desired output format. + */ +export class TestRunExport extends TestRun { + /** + * @param {TestRunOptions & TestRunExportOptions} options + */ + constructor({ resultsJSON, ...parentOptions }) { + super(parentOptions); + + this.resultsJSON = resultsJSON; + } + + testPageAndResults() { + const testPage = this.testPage(); + if ('results' in testPage) { + return { + ...testPage, + resultsJSON: this.resultsJSON(this.state), + }; + } + return { + ...testPage, + resultsJSON: + this.state.currentUserAction === UserActionMap.CLOSE_TEST_WINDOW + ? this.resultsJSON(this.state) + : null, + }; + } +} + +/** + * @typedef SubmitResultDetailsCommandsAssertionsPass + * @property {string} assertion + * @property {string} priority + * @property {AssertionPassJSON} pass + */ + +/** + * Passing assertion values submitted from the tester result form. + * + * In the submitted json object the values contain spaces and are title cased. + * @typedef {EnumValues} AssertionPassJSON + */ + +const AssertionPassJSONMap = createEnumMap({ + GOOD_OUTPUT: 'Good Output', + PASS: 'Pass', +}); + +/** + * @typedef SubmitResultDetailsCommandsAssertionsFail + * @property {string} assertion + * @property {string} priority + * @property {AssertionFailJSON} fail + */ + +/** + * Failing assertion values from the tester result form as are submitted in the + * JSON result object. + * + * In the submitted json object the values contain spaces and are title cased. + * @typedef {EnumValues} AssertionFailJSON + */ + +const AssertionFailJSONMap = createEnumMap({ + NO_OUTPUT: 'No Output', + INCORRECT_OUTPUT: 'Incorrect Output', + NO_SUPPORT: 'No Support', + FAIL: 'Fail', +}); + +const UnexpectedBehaviorImpactMap = createEnumMap({ + MODERATE: 'Moderate', + SEVERE: 'Severe', +}); + +/** @typedef {SubmitResultDetailsCommandsAssertionsPass | SubmitResultDetailsCommandsAssertionsFail} SubmitResultAssertionsJSON */ + +/** + * Command result derived from priority 1 and 2 assertions. + * + * Support is "FAILING" is priority 1 assertions fail. Support is "ALL REQUIRED" + * if priority 2 assertions fail. + * + * In the submitted json object values may contain spaces and are in ALL CAPS. + * + * @typedef {EnumValues} CommandSupportJSON + */ + +const CommandSupportJSONMap = createEnumMap({ + FULL: 'FULL', + FAILING: 'FAILING', + ALL_REQUIRED: 'ALL REQUIRED', +}); + +/** + * Highest level status submitted from test result. + * + * In the submitted json object values are in ALL CAPS. + * + * @typedef {EnumValues} SubmitResultStatusJSON + */ + +const StatusJSONMap = createEnumMap({ + PASS: 'PASS', + FAIL: 'FAIL', +}); + +/** + * + * @param {ATMode} mode + * @param {ATJSON} at + * @returns {[ATMode, string, [string]]} + */ +function deriveModeWithTextAndInstructions(mode, at) { + let atMode = mode; + let screenText = ''; + let instructions = []; + + if (mode.includes('_')) { + const atModes = mode.split('_'); + for (const _atMode of atModes) { + if (at.settings[_atMode]) { + atMode = _atMode; + screenText = at.settings[_atMode].screenText; + instructions = at.settings[_atMode].instructions; + } + } + } else { + if (at.settings && at.settings[atMode]) { + screenText = at.settings[atMode]?.screenText; + instructions = at.settings[atMode]?.instructions; + } + } + + return [atMode, screenText, instructions]; +} + +/** + * @param {boolean} test + * @param {string} message + * @param {any[]} args + * @returns {asserts test} + */ +function invariant(test, message, ...args) { + if (!test) { + let index = 0; + throw new Error(message.replace(/%%|%\w/g, match => (match[0] !== '%%' ? args[index++] : '%'))); + } +} + +/** @typedef {ConstructorParameters[0]} TestRunOptions */ +/** + * @typedef TestRunExportOptions + * @property {(state: AriaATTestRun.State) => SubmitResultJSON} resultsJSON + */ + +/** + * @typedef ATJSON + * @property {string} name + * @property {string} key + * @property {string} defaultConfigurationInstructionsHTML + * @property {object} settings + */ + +/** + * @typedef SupportJSON + * @property {ATJSON[]} ats + * @property {object} applies_to + * @property {object[]} examples + * @property {string} examples[].directory + * @property {string} examples[].name + */ + +/** + * @typedef AllCommandsJSON + * @property {object} modifiers + * @property {object} modifierAliases + * @property {object} keys + * @property {object} keyAliases + */ + +/** + * @typedef {([string] | [string, string])[]} CommandATJSON + */ + +/** + * @typedef {{[atMode: string]: CommandATJSON}} CommandModeJSON + */ + +/** + * @typedef CommandJSON + * @property {CommandModeJSON} [reading] + * @property {CommandModeJSON} [interaction] + */ + +/** + * @typedef {{[commandDescription: string]: CommandJSON}} CommandsJSON + */ + +/** + * @typedef {["at" | "showSubmitButton" | "showResults" | string, string][]} ConfigQueryParams + */ + +/** @typedef {"reading" | "interaction" | "virtualCursor", "pcCursor", "browseMode" | "focusMode" | "quickNavOn" | "quickNavOff" | "defaultMode"} ATMode */ + +/** @typedef OutputAssertion + * @property {string} assertionId + * @property {Number} priority + * @property {string} assertionStatement + * @property {string} assertionPhrase + * @property {string} refIds + */ + +/** + * @typedef BehaviorJSON + * @property {string} setup_script_description + * @property {string} setupTestPage + * @property {string[]} applies_to + * @property {ATMode | ATMode[]} mode + * @property {string} task + * @property {string} specific_user_instruction + * @property {[string, string][] | [OutputAssertion]} [output_assertions] + * @property {{[atKey: string]: [number, string][]}} [additional_assertions] + */ + +/** + * @typedef BehaviorAssertion + * @property {number} priority + * @property {string} assertion + */ + +/** + * @typedef BehaviorUnexpectedItem + * @property {string} description + */ + +/** + * @typedef Behavior + * @property {string} description + * @property {string} task + * @property {ATMode} mode + * @property {string} modeInstructions + * @property {string[]} appliesTo + * @property {string} specificUserInstruction + * @property {string} setupScriptDescription + * @property {string} setupTestPage + * @property {string[]} commands + * @property {BehaviorAssertion[]} assertions + * @property {BehaviorAssertion[]} additionalAssertions + * @property {BehaviorUnexpectedItem[]} unexpectedBehaviors + */ + +/** @typedef {{[key: string]: (document: Document) => void}} SetupScripts */ + +/** + * @typedef SubmitResultJSON + * @property {string} test + * @property {SubmitResultDetailsJSON} details + * @property {SubmitResultStatusJSON} status + */ + +/** + * @typedef SubmitResultSummaryPriorityJSON + * @property {number} pass + * @property {number} fail + */ + +/** + * @typedef {{[key in "1" | "2"]: SubmitResultSummaryPriorityJSON}} SubmitResultSummaryPriorityMapJSON + */ + +/** + * @typedef SubmitResultSummaryPropsJSON + * @property {number} unexpectedCount + */ + +/** + * @typedef {SubmitResultSummaryPriorityMapJSON & SubmitResultSummaryPropsJSON} SubmitResultSummaryJSON + */ + +/** + * @typedef SubmitResultDetailsJSON + * @property {string} name + * @property {string} specific_user_instruction + * @property {string} task + * @property {object[]} commands + * @property {string} commands[].command + * @property {string} commands[].output + * @property {string[]} commands[].unexpected_behaviors + * @property {CommandSupportJSON} commands[].support + * @property {SubmitResultAssertionsJSON[]} commands[].assertions + * @property {SubmitResultSummaryJSON} summary + */ + +/** + * @typedef ResultJSONDocument + * @property {SubmitResultJSON | null} resultsJSON + */ + +/** + * @typedef {TestPageDocument & ResultJSONDocument} TestPageAndResultsDocument + */ + +/** + * @typedef {import('./aria-at-test-run.mjs').EnumValues} EnumValues + * @template T + */ + +/** @typedef {import('./aria-at-test-run.mjs').TestRunAssertion} TestRunAssertion */ +/** @typedef {import('./aria-at-test-run.mjs').TestRunAdditionalAssertion} TestRunAdditionalAssertion */ +/** @typedef {import('./aria-at-test-run.mjs').TestRunCommand} TestRunCommand */ +/** @typedef {import("./aria-at-test-run.mjs").TestRunUnexpectedBehavior} TestRunUnexpected */ + +/** @typedef {import('./aria-at-test-run.mjs').TestPageDocument} TestPageDocument */ diff --git a/client/resources/aria-at-test-run.mjs b/client/resources/aria-at-test-run.mjs new file mode 100644 index 000000000..c45a3e179 --- /dev/null +++ b/client/resources/aria-at-test-run.mjs @@ -0,0 +1,1336 @@ +export class TestRun { + /** + * @param {object} param0 + * @param {Partial} [param0.hooks] + * @param {TestRunState} param0.state + */ + constructor({ hooks, state }) { + /** @type {TestRunState} */ + this.state = state; + + const bindDispatch = transform => arg => this.dispatch(transform(arg)); + /** @type {TestRunHooks} */ + this.hooks = { + closeTestPage: bindDispatch(userCloseWindow), + focusCommandUnexpectedBehavior: bindDispatch(userFocusCommandUnexpectedBehavior), + openTestPage: bindDispatch(userOpenWindow), + postResults: () => {}, + setCommandAdditionalAssertion: bindDispatch(userChangeCommandAdditionalAssertion), + setCommandAssertion: bindDispatch(userChangeCommandAssertion), + setCommandHasUnexpectedBehavior: bindDispatch(userChangeCommandHasUnexpectedBehavior), + setCommandUnexpectedBehavior: bindDispatch(userChangeCommandUnexpectedBehavior), + setCommandUnexpectedBehaviorImpact: bindDispatch(userChangeCommandUnexpectedBehaviorImpact), + setCommandUnexpectedBehaviorMore: bindDispatch(userChangeCommandUnexpectedBehaviorMore), + setCommandOutput: bindDispatch(userChangeCommandOutput), + submit: () => submitResult(this), + ...hooks, + }; + + this.observers = []; + + this.dispatch = this.dispatch.bind(this); + } + + /** + * @param {(state: TestRunState) => TestRunState} updateMethod + */ + dispatch(updateMethod) { + this.state = updateMethod(this.state); + this.observers.forEach(subscriber => subscriber(this)); + } + + /** + * @param {(app: TestRun) => void} subscriber + * @returns {() => void} + */ + observe(subscriber) { + this.observers.push(subscriber); + return () => { + const index = this.observers.indexOf(subscriber); + if (index > -1) { + this.observers.splice(index, 1); + } + }; + } + + testPage() { + return testPageDocument(this.state, this.hooks); + } + + instructions() { + return instructionDocument(this.state, this.hooks); + } + + resultsTable() { + return resultsTableDocument(this.state); + } +} + +/** + * @param {U} map + * @returns {Readonly} + * @template {string} T + * @template {{[key: string]: T}} U + */ +export function createEnumMap(map) { + return Object.freeze(map); +} + +export const WhitespaceStyleMap = createEnumMap({ + LINE_BREAK: 'lineBreak', +}); + +function bind(fn, ...args) { + return (...moreArgs) => fn(...args, ...moreArgs); +} + +/** + * @param {TestRunState} resultState + * @param {TestRunHooks} hooks + * @returns {InstructionDocument} + */ +export function instructionDocument(resultState, hooks) { + const mode = resultState.info.mode; + const modeInstructions = resultState.info.modeInstructions; + const userInstructions = resultState.info.userInstructions; + const lastInstruction = userInstructions[userInstructions.length - 1]; + const setupScriptDescription = resultState.info.setupScriptDescription + ? ` and runs a script that ${resultState.info.setupScriptDescription}.` + : resultState.info.setupScriptDescription; + // As a hack, special case mode instructions for VoiceOver for macOS until we + // support modeless tests. + const modePhrase = + resultState.config.at.name === 'VoiceOver for macOS' + ? 'Describe ' + : `With ${resultState.config.at.name} in ${mode} mode, describe `; + + // TODO: Wrap each command token in + const commands = resultState.commands.map(({ description }) => description); + const commandSettings = resultState.commands.map(({ commandSettings }) => commandSettings); + const assertions = resultState.commands[0].assertions.map(({ description }) => description); + const additionalAssertions = resultState.commands[0].additionalAssertions.map( + ({ description }) => description + ); + + let firstRequired = true; + function focusFirstRequired() { + if (firstRequired) { + firstRequired = false; + return true; + } + return false; + } + + function convertModeInstructionsToKbdArray(inputString) { + const container = document.createElement('div'); + container.innerHTML = inputString; + + const resultArray = []; + for (const node of container.childNodes) { + if (node.nodeName === 'KBD') { + // Handle elements + resultArray.push({ kbd: node.innerText.trim() }); + } else { + // Handle text nodes + resultArray.push(node.textContent); + } + } + + return resultArray.length ? resultArray : null; + } + + const convertedModeInstructions = + modeInstructions !== undefined && !modeInstructions.includes('undefined') + ? convertModeInstructionsToKbdArray(modeInstructions) + : null; + + let strongInstructions = [...userInstructions]; + if (convertedModeInstructions) + strongInstructions = [convertedModeInstructions, ...strongInstructions]; + + return { + errors: { + visible: resultState.errors && resultState.errors.length > 0 ? true : false, + header: 'Test cannot be performed due to error(s)!', + errors: resultState.errors || [], + }, + instructions: { + header: { + header: `Testing task: ${resultState.info.description}`, + focus: resultState.currentUserAction === UserActionMap.LOAD_PAGE, + }, + description: `${modePhrase} how ${resultState.config.at.name} behaves when performing task "${lastInstruction}"`, + instructions: { + header: 'Test instructions', + instructions: [ + [ + `Restore default settings for ${resultState.config.at.name}. For help, read `, + { + href: 'https://github.com/w3c/aria-at/wiki/Configuring-Screen-Readers-for-Testing', + description: 'Configuring Screen Readers for Testing', + }, + `.`, + ], + `Activate the "Open test page" button below, which opens the example to test in a new window${setupScriptDescription}`, + ], + strongInstructions: strongInstructions.filter(el => el), + commands: { + description: `Using the following commands, ${lastInstruction}`, + commands: commands.map((command, index) => { + const { description: settings, text: settingsText } = commandSettings[index]; + return `${command}${ + settingsText && settings !== 'defaultMode' ? ` (${settingsText})` : '' + }`; + }), + }, + }, + assertions: { + header: 'Success Criteria', + description: `To pass this test, ${resultState.config.at.name} needs to meet all the following assertions when each specified command is executed:`, + assertions, + }, + openTestPage: { + button: 'Open Test Page', + enabled: resultState.openTest.enabled, + click: hooks.openTestPage, + }, + }, + results: { + header: { + header: 'Record Results', + description: `${resultState.info.description}`, + }, + commands: commands.map(commandResult), + }, + submit: resultState.config.displaySubmitButton + ? { + button: 'Submit Results', + click: hooks.submit, + } + : null, + }; + + /** + * @param {string} command + * @param {number} commandIndex + * @returns {InstructionDocumentResultsCommand} + */ + function commandResult(command, commandIndex) { + const resultStateCommand = resultState.commands[commandIndex]; + const resultUnexpectedBehavior = resultStateCommand.unexpected; + + const { + commandSettings: { description: settings, text: settingsText, assertionExceptions }, + } = resultStateCommand; + + return { + header: `After '${command}'${ + settingsText && settings !== 'defaultMode' ? ` (${settingsText})` : '' + }`, + atOutput: { + description: [ + `${resultState.config.at.name} output after ${command}`, + { + required: true, + highlightRequired: resultStateCommand.atOutput.highlightRequired, + description: '(required)', + }, + ], + value: resultStateCommand.atOutput.value, + focus: + resultState.currentUserAction === 'validateResults' && + resultStateCommand.atOutput.highlightRequired && + focusFirstRequired(), + change: atOutput => hooks.setCommandOutput({ commandIndex, atOutput }), + }, + assertionsHeader: { + descriptionHeader: `${resultState.assertionResponseQuestion} ${command}${ + settingsText && settings !== 'defaultMode' ? ` (${settingsText})` : '' + }?`, + }, + assertions: [ + ...assertions + // Ignore assertion if level 0 priority exception found for assertion's command + .filter((each, index) => (assertionExceptions ? assertionExceptions[index] !== 0 : each)) + .map(each => + assertionResult( + commandIndex, + each, + assertions.findIndex(e => e === each) + ) + ), + ...additionalAssertions.map(bind(additionalAssertionResult, commandIndex)), + ], + unexpectedBehaviors: { + description: [ + 'Were there additional undesirable behaviors?', + { + required: true, + highlightRequired: resultStateCommand.unexpected.highlightRequired, + description: '(required)', + }, + ], + passChoice: { + label: 'No, there were no additional undesirable behaviors.', + checked: + resultUnexpectedBehavior.hasUnexpected === + HasUnexpectedBehaviorMap.DOES_NOT_HAVE_UNEXPECTED, + focus: + resultState.currentUserAction === 'validateResults' && + resultUnexpectedBehavior.highlightRequired && + resultUnexpectedBehavior.hasUnexpected === HasUnexpectedBehaviorMap.NOT_SET && + focusFirstRequired(), + click: () => + hooks.setCommandHasUnexpectedBehavior({ + commandIndex, + hasUnexpected: HasUnexpectedBehaviorMap.DOES_NOT_HAVE_UNEXPECTED, + }), + }, + failChoice: { + label: 'Yes, there were additional undesirable behaviors', + checked: + resultUnexpectedBehavior.hasUnexpected === HasUnexpectedBehaviorMap.HAS_UNEXPECTED, + focus: + resultState.currentUserAction === 'validateResults' && + resultUnexpectedBehavior.highlightRequired && + resultUnexpectedBehavior.hasUnexpected === HasUnexpectedBehaviorMap.NOT_SET && + focusFirstRequired(), + click: () => + hooks.setCommandHasUnexpectedBehavior({ + commandIndex, + hasUnexpected: HasUnexpectedBehaviorMap.HAS_UNEXPECTED, + }), + options: { + header: 'Undesirable behaviors', + options: resultUnexpectedBehavior.behaviors.map((behavior, unexpectedIndex) => { + return { + description: behavior.description, + impact: behavior.impact, + enabled: + resultUnexpectedBehavior.hasUnexpected === + HasUnexpectedBehaviorMap.HAS_UNEXPECTED, + tabbable: resultUnexpectedBehavior.tabbedBehavior === unexpectedIndex, + checked: behavior.checked, + focus: + typeof resultState.currentUserAction === 'object' && + resultState.currentUserAction.action === UserObjectActionMap.FOCUS_UNDESIRABLE + ? resultState.currentUserAction.commandIndex === commandIndex && + resultUnexpectedBehavior.tabbedBehavior === unexpectedIndex + : resultState.currentUserAction === UserActionMap.VALIDATE_RESULTS && + resultUnexpectedBehavior.hasUnexpected === + HasUnexpectedBehaviorMap.HAS_UNEXPECTED && + resultUnexpectedBehavior.behaviors.every(({ checked }) => !checked) && + focusFirstRequired(), + change: checked => + hooks.setCommandUnexpectedBehavior({ commandIndex, unexpectedIndex, checked }), + impactchange: impact => + hooks.setCommandUnexpectedBehaviorImpact({ + commandIndex, + unexpectedIndex, + impact, + }), + keydown: key => { + const increment = keyToFocusIncrement(key); + if (increment) { + hooks.focusCommandUnexpectedBehavior({ + commandIndex, + unexpectedIndex, + increment, + }); + return true; + } + return false; + }, + more: { + description: /** @type {Description[]} */ ([ + `Details:`, + { + required: true, + highlightRequired: behavior.more.highlightRequired, + description: '(required)', + }, + ]), + enabled: behavior.checked, + value: behavior.more.value, + focus: + resultState.currentUserAction === 'validateResults' && + behavior.more.highlightRequired && + focusFirstRequired(), + change: value => + hooks.setCommandUnexpectedBehaviorMore({ + commandIndex, + unexpectedIndex, + more: value, + }), + }, + }; + }), + }, + }, + }, + }; + } + + /** + * @param {number} commandIndex + * @param {string} assertion + * @param {number} assertionIndex + */ + function assertionResult(commandIndex, assertion, assertionIndex) { + const resultAssertion = resultState.commands[commandIndex].assertions[assertionIndex]; + return /** @type {InstructionDocumentResultsCommandsAssertion} */ ({ + description: [assertion], + passed: resultAssertion.result === AssertionResultMap.PASS, + click: () => + hooks.setCommandAssertion({ + commandIndex, + assertionIndex, + result: + resultAssertion.result === AssertionResultMap.PASS + ? AssertionResultMap.FAIL + : AssertionResultMap.PASS, + }), + }); + } + + /** + * @param {number} commandIndex + * @param {string} assertion + * @param {number} assertionIndex + */ + function additionalAssertionResult(commandIndex, assertion, assertionIndex) { + const resultAdditionalAssertion = + resultState.commands[commandIndex].additionalAssertions[assertionIndex]; + return /** @type {InstructionDocumentResultsCommandsAssertion} */ ({ + description: [assertion], + passed: resultAdditionalAssertion.result === CommonResultMap.PASS, + click: () => + hooks.setCommandAssertion({ + commandIndex, + assertionIndex, + result: + resultAdditionalAssertion.result === AssertionResultMap.PASS + ? AssertionResultMap.FAIL + : AssertionResultMap.PASS, + }), + }); + } +} + +/** + * @typedef {typeof UserActionMap[keyof typeof UserActionMap]} UserAction + */ + +export const UserActionMap = createEnumMap({ + LOAD_PAGE: 'loadPage', + OPEN_TEST_WINDOW: 'openTestWindow', + CLOSE_TEST_WINDOW: 'closeTestWindow', + VALIDATE_RESULTS: 'validateResults', + CHANGE_TEXT: 'changeText', + CHANGE_SELECTION: 'changeSelection', + SHOW_RESULTS: 'showResults', +}); + +/** + * @typedef {typeof UserObjectActionMap[keyof typeof UserObjectActionMap]} UserObjectAction + */ + +export const UserObjectActionMap = createEnumMap({ + FOCUS_UNDESIRABLE: 'focusUndesirable', +}); + +/** + * @typedef {UserAction | UserActionFocusUnexpected} TestRunUserAction + */ + +/** + * @typedef {EnumValues} HasUnexpectedBehavior + */ + +export const HasUnexpectedBehaviorMap = createEnumMap({ + NOT_SET: 'notSet', + HAS_UNEXPECTED: 'hasUnexpected', + DOES_NOT_HAVE_UNEXPECTED: 'doesNotHaveUnexpected', +}); + +export const CommonResultMap = createEnumMap({ + NOT_SET: 'notSet', + PASS: 'pass', +}); + +/** + * @typedef {EnumValues} AdditionalAssertionResult + */ + +export const AdditionalAssertionResultMap = createEnumMap({ + ...CommonResultMap, + FAIL_SUPPORT: 'failSupport', +}); + +/** + * @typedef {EnumValues} AssertionResult + */ + +export const AssertionResultMap = createEnumMap({ + ...CommonResultMap, + FAIL_MISSING: 'failMissing', + FAIL_INCORRECT: 'failIncorrect', + FAIL: 'fail', +}); + +/** + * @typedef {EnumValues} UnexpectedBehaviorImpact + */ + +export const UnexpectedBehaviorImpactMap = createEnumMap({ + MODERATE: 'Moderate', + SEVERE: 'Severe', +}); + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {string} props.atOutput + * @returns {(state: TestRunState) => TestRunState} + */ +export function userChangeCommandOutput({ commandIndex, atOutput }) { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.CHANGE_TEXT, + commands: state.commands.map((commandState, index) => + index !== commandIndex + ? commandState + : { + ...commandState, + atOutput: { + ...commandState.atOutput, + value: atOutput, + }, + } + ), + }; + }; +} + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {number} props.assertionIndex + * @param {AssertionResult} props.result + * @returns {(state: TestRunState) => TestRunState} + */ +export function userChangeCommandAssertion({ commandIndex, assertionIndex, result }) { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.CHANGE_SELECTION, + commands: state.commands.map((command, commandI) => + commandI !== commandIndex + ? command + : { + ...command, + assertions: command.assertions.map((assertion, assertionI) => + assertionI !== assertionIndex ? assertion : { ...assertion, result } + ), + } + ), + }; + }; +} + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {number} props.additionalAssertionIndex + * @param {AdditionalAssertionResult} props.result + * @returns {(state: TestRunState) => TestRunState} + */ +export function userChangeCommandAdditionalAssertion({ + commandIndex, + additionalAssertionIndex, + result, +}) { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.CHANGE_SELECTION, + commands: state.commands.map((command, commandI) => + commandI !== commandIndex + ? command + : { + ...command, + additionalAssertions: command.additionalAssertions.map((assertion, assertionI) => + assertionI !== additionalAssertionIndex ? assertion : { ...assertion, result } + ), + } + ), + }; + }; +} + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {HasUnexpectedBehavior} props.hasUnexpected + * @returns {(state: TestRunState) => TestRunState} + */ +export function userChangeCommandHasUnexpectedBehavior({ commandIndex, hasUnexpected }) { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.CHANGE_SELECTION, + commands: state.commands.map((command, commandI) => + commandI !== commandIndex + ? command + : { + ...command, + unexpected: { + ...command.unexpected, + hasUnexpected: hasUnexpected, + tabbedBehavior: hasUnexpected === HasUnexpectedBehaviorMap.HAS_UNEXPECTED ? 0 : -1, + behaviors: command.unexpected.behaviors.map(behavior => ({ + ...behavior, + checked: false, + more: behavior.more ? { ...behavior.more, value: '' } : null, + })), + }, + } + ), + }; + }; +} + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {number} props.unexpectedIndex + * @param {boolean} props.checked + * @returns {(state: TestRunState) => TestRunState} + */ +export function userChangeCommandUnexpectedBehavior({ commandIndex, unexpectedIndex, checked }) { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.CHANGE_SELECTION, + commands: state.commands.map((command, commandI) => + commandI !== commandIndex + ? command + : { + ...command, + unexpected: { + ...command.unexpected, + behaviors: command.unexpected.behaviors.map((unexpected, unexpectedI) => + unexpectedI !== unexpectedIndex + ? unexpected + : { + ...unexpected, + checked, + } + ), + }, + } + ), + }; + }; +} + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {number} props.unexpectedIndex + * @param {string} props.impact + * @returns {(state: TestRunState) => TestRunState} + */ +export function userChangeCommandUnexpectedBehaviorImpact({ + commandIndex, + unexpectedIndex, + impact, +}) { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.CHANGE_TEXT, + commands: state.commands.map((command, commandI) => + commandI !== commandIndex + ? command + : /** @type {TestRunCommand} */ ({ + ...command, + unexpected: { + ...command.unexpected, + behaviors: command.unexpected.behaviors.map((unexpected, unexpectedI) => + unexpectedI !== unexpectedIndex + ? unexpected + : /** @type {TestRunUnexpectedBehavior} */ ({ + ...unexpected, + impact: impact, + }) + ), + }, + }) + ), + }; + }; +} + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {number} props.unexpectedIndex + * @param {string} props.more + * @returns {(state: TestRunState) => TestRunState} + */ +export function userChangeCommandUnexpectedBehaviorMore({ commandIndex, unexpectedIndex, more }) { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.CHANGE_TEXT, + commands: state.commands.map((command, commandI) => + commandI !== commandIndex + ? command + : /** @type {TestRunCommand} */ ({ + ...command, + unexpected: { + ...command.unexpected, + behaviors: command.unexpected.behaviors.map((unexpected, unexpectedI) => + unexpectedI !== unexpectedIndex + ? unexpected + : /** @type {TestRunUnexpectedBehavior} */ ({ + ...unexpected, + more: { + ...unexpected.more, + value: more, + }, + }) + ), + }, + }) + ), + }; + }; +} + +/** + * @param {string} key + * @returns {TestRunFocusIncrement} + */ +function keyToFocusIncrement(key) { + switch (key) { + case 'Up': + case 'ArrowUp': + case 'Left': + case 'ArrowLeft': + return 'previous'; + + case 'Down': + case 'ArrowDown': + case 'Right': + case 'ArrowRight': + return 'next'; + } +} + +/** + * @param {TestRunState} state + * @param {TestRunHooks} hooks + * @returns {TestPageDocument} + */ +function testPageDocument(state, hooks) { + if (state.currentUserAction === UserActionMap.SHOW_RESULTS) { + return { + results: resultsTableDocument(state), + }; + } + const instructions = instructionDocument(state, hooks); + return { + errors: instructions.errors, + instructions, + }; +} + +/** + * @param {TestRun} app + */ +function submitResult(app) { + app.dispatch(userValidateState()); + + if (isSomeFieldRequired(app.state)) { + return; + } + + app.hooks.postResults(); + + app.hooks.closeTestPage(); + + if (app.state.config.renderResultsAfterSubmit) { + app.dispatch(userShowResults()); + } +} + +export function userShowResults() { + return function (/** @type {TestRunState} */ state) { + return /** @type {TestRunState} */ ({ + ...state, + currentUserAction: UserActionMap.SHOW_RESULTS, + }); + }; +} + +/** + * @param {TestRunState} state + * @returns + */ +function isSomeFieldRequired(state) { + return state.commands.some( + command => + command.atOutput.value.trim() === '' || + command.unexpected.hasUnexpected === HasUnexpectedBehaviorMap.NOT_SET || + (command.unexpected.hasUnexpected === HasUnexpectedBehaviorMap.HAS_UNEXPECTED && + (command.unexpected.behaviors.every(({ checked }) => !checked) || + command.unexpected.behaviors.some( + behavior => behavior.checked && behavior.more && behavior.more.value.trim() === '' + ))) + ); +} + +/** + * @param {TestRunState} state + * @returns {ResultsTableDocument} + */ +function resultsTableDocument(state) { + return { + header: state.info.description, + status: { + header: [ + 'Test result: ', + state.commands.some( + ({ + assertions, + additionalAssertions, + unexpected, + commandSettings: { assertionExceptions }, + }) => + [ + // Ignore assertion if level 0 priority exception found for assertion's command + ...assertions.filter((each, index) => + assertionExceptions ? assertionExceptions[index] !== 0 : each + ), + ...additionalAssertions, + ].some(({ priority, result }) => priority === 1 && result !== CommonResultMap.PASS) || + unexpected.behaviors.some(({ checked }) => checked) + ) + ? 'FAIL' + : 'PASS', + ], + }, + table: { + headers: { + description: 'Command', + support: 'Support', + details: 'Details', + }, + commands: state.commands.map(command => { + const { + commandSettings: { assertionExceptions }, + } = command; + const allAssertions = [ + // Ignore assertion if level 0 priority exception found for assertion's command + ...command.assertions.filter((each, index) => + assertionExceptions ? assertionExceptions[index] !== 0 : each + ), + ...command.additionalAssertions, + ]; + + let passingAssertions = ['No passing assertions']; + let failingAssertions = ['No failing assertions']; + let unexpectedBehaviors = ['None']; + + if (allAssertions.some(({ result }) => result === CommonResultMap.PASS)) { + passingAssertions = allAssertions + .filter(({ result }) => result === CommonResultMap.PASS) + .map(({ description }) => description); + } + if (allAssertions.some(({ result }) => result !== CommonResultMap.PASS)) { + failingAssertions = allAssertions + .filter(({ result }) => result !== CommonResultMap.PASS) + .map(({ description }) => description); + } + if (command.unexpected.behaviors.some(({ checked }) => checked)) { + unexpectedBehaviors = command.unexpected.behaviors + .filter(({ checked }) => checked) + .map(({ description, more, impact }) => { + let result = `${description} (`; + if (more) result = `${result}Details: ${more.value}, `; + result = `${result}Impact: ${impact})`; + return result; + }); + } + + return { + description: command.description, + support: + allAssertions.some( + ({ priority, result }) => priority === 1 && result !== CommonResultMap.PASS + ) || command.unexpected.behaviors.some(({ checked }) => checked) + ? 'FAILING' + : allAssertions.some( + ({ priority, result }) => priority === 2 && result !== CommonResultMap.PASS + ) + ? 'ALL_REQUIRED' + : 'FULL', + details: { + output: /** @type {Description} */ [ + 'Output:', + /** @type {DescriptionWhitespace} */ ({ whitespace: WhitespaceStyleMap.LINE_BREAK }), + ' ', + ...command.atOutput.value.split(/(\r\n|\r|\n)/g).map(output => + /\r\n|\r|\n/.test(output) + ? /** @type {DescriptionWhitespace} */ ({ + whitespace: WhitespaceStyleMap.LINE_BREAK, + }) + : output + ), + ], + passingAssertions: { + description: 'Passing Assertions:', + items: passingAssertions, + }, + failingAssertions: { + description: 'Failing Assertions:', + items: failingAssertions, + }, + unexpectedBehaviors: { + description: 'Other behaviors that create negative impact:', + items: unexpectedBehaviors, + }, + }, + }; + }), + }, + }; +} + +export function userOpenWindow() { + return (/** @type {TestRunState} */ state) => + /** @type {TestRunState} */ ({ + ...state, + currentUserAction: UserActionMap.OPEN_TEST_WINDOW, + openTest: { ...state.openTest, enabled: false }, + }); +} + +export function userCloseWindow() { + return (/** @type {TestRunState} */ state) => + /** @type {TestRunState} */ ({ + ...state, + currentUserAction: UserActionMap.CLOSE_TEST_WINDOW, + openTest: { ...state.openTest, enabled: true }, + }); +} + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {number} props.unexpectedIndex + * @param {TestRunFocusIncrement} props.increment + * @returns {(state: TestRunState) => TestRunState} + */ +export function userFocusCommandUnexpectedBehavior({ commandIndex, unexpectedIndex, increment }) { + return function (state) { + const unexpectedLength = state.commands[commandIndex].unexpected.behaviors.length; + const incrementValue = increment === 'next' ? 1 : -1; + const newUnexpectedIndex = + (unexpectedIndex + incrementValue + unexpectedLength) % unexpectedLength; + + return { + ...state, + currentUserAction: { + action: UserObjectActionMap.FOCUS_UNDESIRABLE, + commandIndex, + unexpectedIndex: newUnexpectedIndex, + }, + commands: state.commands.map((command, commandI) => { + const tabbed = command.unexpected.tabbedBehavior; + const unexpectedLength = command.unexpected.behaviors.length; + const newTabbed = + (tabbed + (increment === 'next' ? 1 : -1) + unexpectedLength) % unexpectedLength; + return commandI !== commandIndex + ? command + : { + ...command, + unexpected: { + ...command.unexpected, + tabbedBehavior: newTabbed, + }, + }; + }), + }; + }; +} + +/** + * @returns {(state: TestRunState) => TestRunState} + */ +export function userValidateState() { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.VALIDATE_RESULTS, + commands: state.commands.map(command => { + return { + ...command, + atOutput: { + ...command.atOutput, + highlightRequired: !command.atOutput.value.trim(), + }, + unexpected: { + ...command.unexpected, + highlightRequired: + command.unexpected.hasUnexpected === HasUnexpectedBehaviorMap.NOT_SET || + (command.unexpected.hasUnexpected === HasUnexpectedBehaviorMap.HAS_UNEXPECTED && + command.unexpected.behaviors.every(({ checked }) => !checked)), + behaviors: command.unexpected.behaviors.map(unexpected => { + return unexpected.more + ? { + ...unexpected, + more: { + ...unexpected.more, + highlightRequired: unexpected.checked && !unexpected.more.value.trim(), + }, + } + : unexpected; + }), + }, + }; + }), + }; + }; +} + +/** + * @typedef AT + * @property {string} name + * @property {string} key + */ + +/** + * @typedef Behavior + * @property {string} description + * @property {string} task + * @property {string} mode + * @property {string} modeInstructions + * @property {string[]} appliesTo + * @property {string} specificUserInstruction + * @property {string} setupScriptDescription + * @property {string} setupTestPage + * @property {string[]} commands + * @property {[string, string][]} outputAssertions + * @property {[number, string][]} additionalAssertions + */ + +/** + * @typedef {"previous" | "next"} TestRunFocusIncrement + */ + +/** + * @typedef {(action: (state: TestRunState) => TestRunState) => void} Dispatcher + */ + +/** + * @typedef InstructionDocumentButton + * @property {Description} button + * @property {boolean} [enabled] + * @property {() => void} click + */ + +/** + * @typedef InstructionDocumentAssertionChoiceOptionsOptionsMore + * @property {Description} description + * @property {string} value + * @property {boolean} enabled + * @property {boolean} [focus] + * @property {(value: string) => void} change + */ + +/** + * @typedef InstructionDocumentAssertionChoiceOptionsOption + * @property {Description} description + * @property {boolean} checked + * @property {boolean} enabled + * @property {boolean} tabbable + * @property {boolean} [focus] + * @property {(checked: boolean) => void} change + * @property {(key: string) => boolean} keydown + * @property {InstructionDocumentAssertionChoiceOptionsOptionsMore} [more] + */ + +/** + * @typedef InstructionDocumentAssertionChoiceOptions + * @property {Description} header + * @property {InstructionDocumentAssertionChoiceOptionsOption[]} options + */ + +/** + * @typedef InstructionDocumentAssertionChoice + * @property {Description} label + * @property {boolean} checked + * @property {boolean} [focus] + * @property {() => void} click + * @property {InstructionDocumentAssertionChoiceOptions} [options] + */ + +/** + * @typedef DescriptionRich + * @property {string} [href] + * @property {boolean} [required] + * @property {boolean} [highlightRequired] + * @property {boolean} [offScreen] + * @property {Description} description + */ + +/** + * @typedef DescriptionWhitespace + * @property {typeof WhitespaceStyleMap["LINE_BREAK"]} whitespace + */ + +/** @typedef {string | DescriptionRich | DescriptionWhitespace | DescriptionArray} Description */ + +/** @typedef {Description[]} DescriptionArray */ + +/** + * @typedef InstructionDocumentResultsCommandsAssertion + * @property {Description} description + * @property {Boolean} passed + * @property {boolean} [focus] + * @property {() => void} click + */ + +/** + * @typedef InstructionDocumentResultsCommandsAssertionsHeader + * @property {Description} descriptionHeader + */ + +/** + * @typedef InstructionDocumentResultsCommandsATOutput + * @property {Description} description + * @property {string} value + * @property {boolean} focus + * @property {(value: string) => void} change + */ + +/** + * @typedef InstructionDocumentResultsCommandsUnexpected + * @property {Description} description + * @property {InstructionDocumentAssertionChoice} passChoice + * @property {InstructionDocumentAssertionChoice} failChoice + */ + +/** + * @typedef InstructionDocumentResultsCommand + * @property {Description} header + * @property {InstructionDocumentResultsCommandsATOutput} atOutput + * @property {InstructionDocumentResultsCommandsAssertionsHeader} assertionsHeader + * @property {InstructionDocumentResultsCommandsAssertion[]} assertions + * @property {InstructionDocumentResultsCommandsUnexpected} unexpectedBehaviors + */ + +/** + * @typedef InstructionDocumentResultsHeader + * @property {Description} header + * @property {Description} description + */ + +/** + * @typedef InstructionDocumentResults + * @property {InstructionDocumentResultsHeader} header + * @property {InstructionDocumentResultsCommand[]} commands + */ + +/** + * @typedef InstructionDocumentInstructionsInstructionsCommands + * @property {Description} description + * @property {Description[]} commands + */ + +/** + * @typedef InstructionDocumentInstructionsInstructions + * @property {Description} header + * @property {Description[]} instructions + * @property {Description[]} strongInstructions + * @property {InstructionDocumentInstructionsInstructionsCommands} commands + */ + +/** + * @typedef InstructionDocumentErrors + * @property {boolean} visible + * @property {Description} header + * @property {Description[]} errors + */ + +/** + * @typedef InstructionDocumentInstructionsHeader + * @property {Description} header + * @property {boolean} focus + */ + +/** + * @typedef InstructionDocumentInstructionsAssertions + * @property {Description} header + * @property {Description} description + * @property {Description[]} assertions + */ + +/** + * @typedef InstructionDocumentInstructions + * @property {InstructionDocumentInstructionsHeader} header + * @property {Description} description + * @property {InstructionDocumentInstructionsInstructions} instructions + * @property {InstructionDocumentInstructionsAssertions} assertions + * @property {InstructionDocumentButton} openTestPage + */ + +/** + * @typedef InstructionDocument + * @property {InstructionDocumentErrors} errors + * @property {InstructionDocumentInstructions} instructions + * @property {InstructionDocumentResults} results + * @property {InstructionDocumentButton} submit + */ + +/** + * @typedef TestRunHooks + * @property {() => void} closeTestPage + * @property {(options: {commandIndex: number, unexpectedIndex: number, increment: TestRunFocusIncrement}) => void} focusCommandUnexpectedBehavior + * @property {() => void} openTestPage + * @property {() => void} postResults + * @property {(options: {commandIndex: number, additionalAssertionIndex: number, result: AdditionalAssertionResult}) => void} setCommandAdditionalAssertion + * @property {(options: {commandIndex: number, assertionIndex: number, result: AssertionResult}) => void} setCommandAssertion + * @property {(options: {commandIndex: number, hasUnexpected: HasUnexpectedBehavior}) => void } setCommandHasUnexpectedBehavior + * @property {(options: {commandIndex: number, atOutput: string}) => void} setCommandOutput + * @property {(options: {commandIndex: number, unexpectedIndex: number, checked}) => void } setCommandUnexpectedBehavior + * @property {(options: {commandIndex: number, unexpectedIndex: number, impact: string}) => void } setCommandUnexpectedBehaviorImpact + * @property {(options: {commandIndex: number, unexpectedIndex: number, more: string}) => void } setCommandUnexpectedBehaviorMore + * @property {() => void} submit + */ + +/** + * @typedef UserActionFocusUnexpected + * @property {typeof UserObjectActionMap["FOCUS_UNDESIRABLE"]} action + * @property {number} commandIndex + * @property {number} unexpectedIndex + */ + +/** + * @typedef {T[keyof T]} EnumValues + * @template {{[key: string]: string}} T + */ + +/** + * @typedef TestRunAssertion + * @property {string} description + * @property {boolean} highlightRequired + * @property {number} priority + * @property {AssertionResult} result + */ + +/** + * @typedef TestRunAdditionalAssertion + * @property {string} description + * @property {boolean} highlightRequired + * @property {number} priority + * @property {AdditionalAssertionResult} result + */ + +/** + * @typedef TestRunUnexpectedBehavior + * @property {string} description + * @property {boolean} checked + * @property {object} [more] + * @property {boolean} more.highlightRequired + * @property {string} more.value + * @property {string} impact + */ + +/** + * @typedef TestRunUnexpectedGroup + * @property {boolean} highlightRequired + * @property {HasUnexpectedBehavior} hasUnexpected + * @property {number} tabbedBehavior + * @property {TestRunUnexpectedBehavior[]} behaviors + */ + +/** + * @typedef TestRunCommand + * @property {string} description + * @property {object} atOutput + * @property {boolean} atOutput.highlightRequired + * @property {string} atOutput.value + * @property {TestRunAssertion[]} assertions + * @property {TestRunAdditionalAssertion[]} additionalAssertions + * @property {TestRunUnexpectedGroup} unexpected + */ + +/** + * @typedef TestRunState + * This state contains all the serializable values that are needed to render any + * of the documents (InstructionDocument, ResultsTableDocument, and + * TestPageDocument) from this module. + * + * @property {string[] | null} errors + * @property {object} info + * @property {string} info.description + * @property {string} info.task + * @property {ATMode} info.mode + * @property {string} info.modeInstructions + * @property {string[]} info.userInstructions + * @property {string} info.setupScriptDescription + * @property {object} config + * @property {AT} config.at + * @property {boolean} config.renderResultsAfterSubmit + * @property {boolean} config.displaySubmitButton + * @property {TestRunUserAction} currentUserAction + * @property {TestRunCommand[]} commands + * @property {object} openTest + * @property {boolean} openTest.enabled + */ + +/** + * @typedef ResultsTableDetailsList + * @property {Description} description + * @property {Description[]} items + */ + +/** + * @typedef ResultsTableDocument + * @property {string} header + * @property {object} status + * @property {Description} status.header + * @property {object} table + * @property {object} table.headers + * @property {string} table.headers.description + * @property {string} table.headers.support + * @property {string} table.headers.details + * @property {object[]} table.commands + * @property {string} table.commands[].description + * @property {Description} table.commands[].support + * @property {object} table.commands[].details + * @property {Description} table.commands[].details.output + * @property {ResultsTableDetailsList} table.commands[].details.passingAssertions + * @property {ResultsTableDetailsList} table.commands[].details.failingAssertions + * @property {ResultsTableDetailsList} table.commands[].details.unexpectedBehaviors + */ + +/** + * @typedef TestPageDocumentResults + * @property {ResultsTableDocument} results + */ + +/** + * @typedef TestPageDocumentInstructions + * @property {string[] | null} errors + * @property {InstructionDocument} instructions + */ + +/** @typedef {TestPageDocumentInstructions | TestPageDocumentResults} TestPageDocument */ + +/** @typedef {"reading" | "interaction"} ATMode */ diff --git a/client/resources/aria-at-test-window.mjs b/client/resources/aria-at-test-window.mjs new file mode 100644 index 000000000..c60b8c828 --- /dev/null +++ b/client/resources/aria-at-test-window.mjs @@ -0,0 +1,73 @@ +export class TestWindow { + /** + * @param {object} options + * @param {Window | null} [options.window] + * @param {string} options.pageUri + * @param {TestWindowHooks} [options.hooks] + */ + constructor({ window = null, pageUri, hooks }) { + /** @type {Window | null} */ + this.window = window; + + /** @type {string} */ + this.pageUri = pageUri; + + /** @type {TestWindowHooks} */ + this.hooks = { + windowOpened: () => {}, + windowClosed: () => {}, + ...hooks, + }; + } + + open() { + this.window = window.open( + this.pageUri, + '_blank', + 'toolbar=0,location=0,menubar=0,width=800,height=800' + ); + + this.hooks.windowOpened(); + + this.prepare(); + } + + prepare() { + if (!this.window) { + return; + } + + if (this.window.closed) { + this.window = undefined; + this.hooks.windowClosed(); + return; + } + + if ( + this.window.location.origin !== window.location.origin || // make sure the origin is the same, and prevent this from firing on an 'about' page + this.window.document.readyState !== 'complete' + ) { + window.setTimeout(() => { + this.prepare(); + }, 100); + return; + } + + // If the window is closed, re-enable open popup button + this.window.onunload = () => { + window.setTimeout(() => this.prepare(), 100); + }; + } + + close() { + if (this.window) { + this.window.close(); + } + } +} + +/** + * @typedef TestWindowHooks + * @property {() => void} windowOpened + * @property {() => void} windowClosed + */ diff --git a/client/resources/at-commands.mjs b/client/resources/at-commands.mjs new file mode 100644 index 000000000..6bbb5b776 --- /dev/null +++ b/client/resources/at-commands.mjs @@ -0,0 +1,273 @@ +/** @deprecated See aria-at-test-io-format.mjs */ + +import * as keys from './keys.mjs'; + +/** + * Class for getting AT-specific instructions for a test against a design pattern. + * @deprecated See aria-at-test-io-format.mjs:CommandsInput + */ +export class commandsAPI { + /** + * Creates an API to get AT-specific instructions for a design pattern. + * @param {object} commands - A data structure which is a nested object with the following format: + * { + * task: { + * mode: { + * at: [ + * key-command (string corresponding to export in keys.mjs), + * optional additional instructions to list after key command (string), + * ] + * } + * } + * } + * @param {object} supportJson - The data object found in `tests/support.json` + * @param {object} commandsJson - The data object found in `tests/commands.json` + */ + constructor(commands, supportJson, commandsJson) { + if (!commands) { + throw new Error('You must initialize commandsAPI with a commands data object'); + } + + if (!supportJson) { + throw new Error('You must initialize commandsAPI with a supportJson data object'); + } + + if (!commandsJson) { + throw new Error('You must initialize commandsAPI with a commandsJson data object'); + } + + this.AT_COMMAND_MAP = commands; + + this.MODE_INSTRUCTIONS = { + reading: { + jaws: `Verify the Virtual Cursor is active by pressing ${keys.ALT_DELETE}. If it is not, exit Forms Mode to activate the Virtual Cursor by pressing ${keys.ESC}.`, + nvda: `Ensure NVDA is in browse mode by pressing ${keys.ESC}. Note: This command has no effect if NVDA is already in browse mode.`, + voiceover_macos: `Toggle Quick Nav ON by pressing the ${keys.LEFT} and ${keys.RIGHT} keys at the same time.`, + }, + interaction: { + jaws: `Verify the PC Cursor is active by pressing ${keys.ALT_DELETE}. If it is not, turn off the Virtual Cursor by pressing ${keys.INS_Z}.`, + nvda: `If NVDA did not make the focus mode sound when the test page loaded, press ${keys.INS_SPACE} to turn focus mode on.`, + voiceover_macos: `Toggle Quick Nav OFF by pressing the ${keys.LEFT} and ${keys.RIGHT} keys at the same time.`, + }, + }; + + this.supportJson = supportJson; + this.commandsJson = this.flattenObject(commandsJson); + } + + /** + * Get AT-specific instruction + * @param {string} mode - The mode of the screen reader, "reading" or "interaction" + * @param {string} task - The task of the test. + * @param {object} assistiveTech - The assistive technology. + * @return {Array} - A list of commands (strings) + */ + getATCommands(mode, task, assistiveTech) { + let commands = []; + + for (const _atMode of mode.split('_')) { + if (this.AT_COMMAND_MAP[task][_atMode][assistiveTech.key]) { + mode = _atMode; + + if (!this.AT_COMMAND_MAP[task]) { + throw new Error( + `Task "${task}" does not exist, please add to at-commands or correct your spelling.` + ); + } + + if (!this.AT_COMMAND_MAP[task][mode]) { + throw new Error( + `Mode "${mode}" instructions for task "${task}" does not exist, please add to at-commands or correct your spelling.` + ); + } + + let commandsData = this.AT_COMMAND_MAP[task][mode][assistiveTech.key] || []; + + // V1 + if (mode === 'reading' || mode === 'interaction') { + for (let c of commandsData) { + let innerCommands = []; + let commandSequence = c[0].split(','); + for (let command of commandSequence) { + command = keys[command]; + if (typeof command === 'undefined') { + throw new Error( + `Key instruction identifier "${c}" for AT "${assistiveTech.name}", mode "${mode}", task "${task}" is not an available identifier. Update your commands.json file to the correct identifier or add your identifier to resources/keys.mjs.` + ); + } + + let furtherInstruction = c[1]; + command = furtherInstruction ? `${command} ${furtherInstruction}` : command; + innerCommands.push(command); + } + commands.push(innerCommands.join(', then ')); + } + } else { + // V2 + for (let c of commandsData) { + const commandWithPresentationNumber = c[0]; + const [commandId, presentationNumber] = commandWithPresentationNumber.split('|'); + + const commandKVs = this.findValuesByKeys([commandId]); + if (!commandKVs.length) { + throw new Error( + `Key instruction identifier "${commandId}" for AT "${assistiveTech.name}", mode "${mode}", task "${task}" is not an available identifier. Update your commands.json file to the correct identifier or add your identifier to tests/commands.json.` + ); + } + + commands.push( + ...commandKVs.map(({ value, key }) => { + value = assistiveTech.settings[mode].screenText + ? `${value} (${assistiveTech.settings[mode].screenText})` + : value; + return { + value, + key, + settings: mode, + }; + }) + ); + } + } + } + } + + return commands; + } + + /** + * Get AT-specific mode switching instructions + * @param {string} mode - The mode of the screen reader, "reading" or "interaction" + * @param {string} assistiveTech - The assistive technology. + * @return {string} - Instructions for switching into the correct mode. + */ + getModeInstructions(mode, assistiveTech) { + if (this.MODE_INSTRUCTIONS[mode] && this.MODE_INSTRUCTIONS[mode][assistiveTech.key]) { + return this.MODE_INSTRUCTIONS[mode][assistiveTech.key]; + } + return ''; + } + + /** + * Get AT-specific instruction + * @param {string} at - an assitve technology with any capitalization + * @return {string} - if this API knows instructions for `at`, it will return the `at` with proper capitalization + */ + isKnownAT(at) { + return this.supportJson.ats.find(o => o.key === at.toLowerCase()); + } + + defaultConfigurationInstructions(at) { + return this.supportJson.ats.find(o => o.key === at.toLowerCase()) + .defaultConfigurationInstructionsHTML; + } + + flattenObject(obj, parentKey) { + const flattened = {}; + + for (const key in obj) { + if (typeof obj[key] === 'object') { + const subObject = this.flattenObject(obj[key], parentKey + key + '.'); + Object.assign(flattened, subObject); + } else { + flattened[parentKey + key] = obj[key]; + } + } + + return flattened; + } + + findValueByKey(keyToFind) { + const keys = Object.keys(this.commandsJson); + + // Need to specially handle VO modifier key combination + if (keyToFind === 'vo') + return this.findValuesByKeys([this.commandsJson['modifierAliases.vo']])[0]; + + if (keyToFind.includes('modifiers.') || keyToFind.includes('keys.')) { + const parts = keyToFind.split('.'); + const keyToCheck = parts[parts.length - 1]; // value after the '.' + + if (this.commandsJson[keyToFind]) + return { + value: this.commandsJson[keyToFind], + key: keyToCheck, + }; + + return null; + } + + for (const key of keys) { + const parts = key.split('.'); + const parentKey = parts[0]; + const keyToCheck = parts[parts.length - 1]; // value after the '.' + + if (keyToCheck === keyToFind) { + if (parentKey === 'modifierAliases') { + return this.findValueByKey(`modifiers.${this.commandsJson[key]}`); + } else if (parentKey === 'keyAliases') { + return this.findValueByKey(`keys.${this.commandsJson[key]}`); + } + + return { + value: this.commandsJson[key], + key: keyToCheck, + }; + } + } + + // Return null if the key is not found + return null; + } + + findValuesByKeys(keysToFind = []) { + const result = []; + + const patternSepWithReplacement = (keyToFind, pattern, replacement) => { + if (keyToFind.includes(pattern)) { + let value = ''; + let validKeys = true; + const keys = keyToFind.split(pattern); + + for (const key of keys) { + const keyResult = this.findValueByKey(key); + if (keyResult) + value = value ? `${value}${replacement}${keyResult.value}` : keyResult.value; + else validKeys = false; + } + if (validKeys) return { value, key: keyToFind }; + } + + return null; + }; + + const patternSepHandler = keyToFind => { + let value = ''; + + if (keyToFind.includes(' ') && keyToFind.includes('+')) { + const keys = keyToFind.split(' '); + for (let [index, key] of keys.entries()) { + const keyToFindResult = this.findValueByKey(key); + if (keyToFindResult) keys[index] = keyToFindResult.value; + if (key.includes('+')) keys[index] = patternSepWithReplacement(key, '+', '+').value; + } + value = keys.join(' then '); + + return { value, key: keyToFind }; + } else if (keyToFind.includes(' ')) + return patternSepWithReplacement(keyToFind, ' ', ' then '); + else if (keyToFind.includes('+')) return patternSepWithReplacement(keyToFind, '+', '+'); + }; + + for (const keyToFind of keysToFind) { + if (keyToFind.includes(' ') || keyToFind.includes('+')) { + result.push(patternSepHandler(keyToFind)); + } else { + const keyToFindResult = this.findValueByKey(keyToFind); + if (keyToFindResult) result.push(keyToFindResult); + } + } + + return result; + } +} diff --git a/client/resources/commands.json b/client/resources/commands.json new file mode 100644 index 000000000..9f5e3cf94 --- /dev/null +++ b/client/resources/commands.json @@ -0,0 +1,115 @@ +{ + "modifiers": { + "alt": "Alt", + "opt": "Option", + "shift": "Shift", + "ctrl": "Control", + "cmd": "Command", + "win": "Windows", + "ins": "Insert" + }, + "modifierAliases": { + "jaws": "ins", + "nvda": "ins", + "vo": "ctrl+opt" + }, + "keys": { + "a": "a", + "b": "b", + "c": "c", + "d": "d", + "e": "e", + "f": "f", + "g": "g", + "h": "h", + "i": "i", + "j": "j", + "k": "k", + "l": "l", + "m": "m", + "n": "n", + "o": "o", + "p": "p", + "q": "q", + "r": "r", + "s": "s", + "t": "t", + "u": "u", + "v": "v", + "w": "w", + "x": "x", + "y": "y", + "z": "z", + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + "6": "6", + "7": "7", + "8": "8", + "9": "9", + "0": "0", + "dash": "Dash", + "equals": "Equals", + "grave": "Grave", + "leftBracket": "Left Bracket", + "rightBracket": "Right Bracket", + "backslash": "Backslash", + "semicolon": "Semicolon", + "apostrophe": "Apostrophe", + "comma": "Comma", + "period": "Period", + "slash": "Slash", + "esc": "Escape", + "backspace": "Backspace", + "tab": "Tab", + "capsLock": "Caps Lock", + "enter": "Enter", + "space": "Space", + "f1": "F1", + "f2": "F2", + "f3": "F3", + "f4": "F4", + "f5": "F5", + "f6": "F6", + "f7": "F7", + "f8": "F8", + "f9": "F9", + "f10": "F10", + "f11": "F11", + "f12": "F12", + "scrollLock": "Scroll Lock", + "pause": "Pause", + "home": "Home", + "end": "End", + "pageUp": "Page Up", + "pageDown": "Page Down", + "del": "Delete", + "left": "Left Arrow", + "right": "Right Arrow", + "up": "Up Arrow", + "down": "Down Arrow", + "numLock": "Num Lock", + "numpadSlash": "Numpad Slash", + "numpadAsterisk": "Numpad Asterisk", + "numpadMinus": "Numpad Minus", + "numpadPlus": "Numpad Plus", + "numpadEnter": "Numpad Enter", + "numpad1": "Numpad 1", + "numpad2": "Numpad 2", + "numpad3": "Numpad 3", + "numpad4": "Numpad 4", + "numpad5": "Numpad 5", + "numpad6": "Numpad 6", + "numpad7": "Numpad 7", + "numpad8": "Numpad 8", + "numpad9": "Numpad 9", + "numpad0": "Numpad 0", + "numpadPeriod": "Numpad Period" + }, + "keyAliases": { + "delete": "del", + "escape": "esc" + } +} \ No newline at end of file diff --git a/client/resources/keys.json b/client/resources/keys.json new file mode 100644 index 000000000..640724f58 --- /dev/null +++ b/client/resources/keys.json @@ -0,0 +1,117 @@ +{ + "CTRL_HOME": "Control+Home", + "CTRL_OPT_HOME": "Control+Option+Home", + "CTRL_END": "Control+End", + "CTRL_OPT_END": "Control+Option+End", + "CTRL_HOME_THEN_DOWN": "Control+Home followed by Down Arrow", + "DELETE": "Delete", + "ALT_DELETE": "Alt+Delete", + "ALT_DOWN": "Alt+Down", + "CTRL_ALT_DOWN": "Control+Alt+Down", + "ALT_UP": "Alt+Up", + "C_AND_SHIFT_C": "C / Shift+C", + "SHIFT_C": "Shift+C", + "CTRL_INS_X": "Control+Insert+X", + "OPT_DOWN": "Option+Down", + "OPT_UP": "Option+Up", + "CTRL_OPT_LEFT": "Ctrl+Option+Left", + "CTRL_ALT_LEFT": "Control+Alt+Left", + "CTRL_OPT_RIGHT": "Control+Option+Right", + "CTRL_ALT_RIGHT": "Control+Alt+Right", + "CTRL_OPT_UP": "Control+Option+Up", + "CTRL_OPT_DOWN": "Control+Option+Down", + "CTRL_OPT_RIGHT_AND_CTRL_OPT_LEFT": "Control+Option+Right / Ctrl+Option+Left", + "CTRL_OPT_A": "Control+Option+A", + "CTRL_OPT_CMD_J": "Control+Option+Command+J", + "CTRL_OPT_CMD_L": "Control+Option+Command+L", + "CTRL_OPT_CMD_P": "Control+Option+Command+P", + "SHIFT_CTRL_OPT_CMD_J": "Shift+Control+Option+Command+J", + "SHIFT_CTRL_OPT_CMD_L": "Shift+Control+Option+Command+L", + "CTRL_OPT_CMD_J_AND_SHIFT_CTRL_OPT_CMD_J": "Control+Option+Command+J / Shift+Control+Option+Command+J", + "CTRL_OPT_CMD_C_AND_SHIFT_CTRL_OPT_CMD_C": "Control+Option+Command+C / Shift+Control+Option+Command+C", + "CTRL_OPT_F3": "Control+Option+F3", + "CTRL_OPT_F4": "Control+Option+F4", + "CTRL_OPT_SPACE": "Control+Option+Space", + "CTRL_OPT_SPACE_THEN_CTRL_OPT_RIGHT": "Control+Option+Space followed by Control+Option+Right", + "CTRL_U": "Control+U", + "CMD": "Command", + "CMD_LEFT": "Command+Left", + "CMD_RIGHT": "Command+Right", + "CMD_DOWN": "Command+Down", + "CMD_UP": "Command+Up", + "DOWN": "Down Arrow", + "END": "End", + "ENTER": "Enter", + "E_AND_SHIFT_E": "E / Shift+E", + "ESC": "Escape", + "F_AND_SHIFT_F": "F / Shift+F", + "HOME": "Home", + "INS_DOWN_OR_CAPS_DOWN": "Insert+Down (or CapsLock+Down)", + "INS_F7_OR_CAPS_F7": "Insert+F7 (or CapsLock+F7)", + "INS_SPACE": "Insert+Space", + "INS_TAB": "Insert+Tab", + "INS_TAB_OR_CAPS_TAB": "Insert+Tab (or CapsLock+Tab)", + "INS_UP_OR_CAPS_I": "Insert+Up (or CapsLock+I)", + "INS_UP": "Insert+Up", + "INS_UP_OR_CAPS_UP": "Insert+Up (or CapsLock+Up)", + "INS_Z": "Insert+Z", + "LEFT_AND_RIGHT": "Left Arrow / Right Arrow", + "LEFT": "Left Arrow", + "NUMPAD_5": "Numpad 5", + "INS_NUMPAD_5": "Insert+Numpad 5 (or CapsLock+Numpad 5)", + "INS_NUMPAD_6": "Insert+Numpad 6 (or CapsLock+Numpad 6)", + "NUMPAD_PLUS": "Numpad Plus", + "RIGHT": "Right Arrow", + "SPACE": "Space", + "TAB": "Tab", + "SHIFT_TAB": "Shift+Tab", + "TAB_AND_SHIFT_TAB": "Tab / Shift+Tab", + "UP": "Up Arrow", + "CTRL_ALT_UP": "Control+Alt+Up", + "UP_AND_DOWN": "Up Arrow / Down Arrow", + "SHIFT_X": "Shift+X", + "X_AND_SHIFT_X": "X / Shift+X", + "A": "A", + "SHIFT_A": "Shift+A", + "B": "B", + "SHIFT_B": "Shift+B", + "C": "C", + "D": "D", + "E": "E", + "SHIFT_E": "Shift+E", + "F": "F", + "SHIFT_F": "Shift+F", + "G": "G", + "H": "H", + "I": "I", + "SHIFT_I": "Shift+I", + "J": "J", + "K": "K", + "SHIFT_K": "Shift+K", + "L": "L", + "SHIFT_L": "Shift+L", + "M": "M", + "N": "N", + "O": "O", + "P": "P", + "Q": "Q", + "R": "R", + "SHIFT_R": "Shift+R", + "S": "S", + "T": "T", + "SHIFT_T": "Shift+T", + "CTRL_OPT_CMD_T": "Control+Option+Command+T", + "T_THEN_DOWN": "T followed by Down Arrow", + "SHIFT_T_THEN_DOWN": "Shift+T followed by Down Arrow", + "U": "U", + "SHIFT_U": "Shift+U", + "V": "V", + "W": "W", + "X": "X", + "Y": "Y", + "CTRL_OPT_CMD_Y": "Control+Option+Command+Y", + "SHIFT_CTRL_OPT_CMD_Y": "Shift+Control+Option+Command+Y", + "Z": "Z", + "PAGE_DOWN": "Page Down", + "PAGE_UP": "Page Up" +} diff --git a/client/resources/keys.mjs b/client/resources/keys.mjs new file mode 100644 index 000000000..bfeff2bde --- /dev/null +++ b/client/resources/keys.mjs @@ -0,0 +1,133 @@ +// Keys +export const CTRL_HOME = "Control+Home"; +export const CTRL_OPT_HOME = "Control+Option+Home"; +export const CTRL_END = "Control+End"; +export const CTRL_OPT_END = "Control+Option+End"; +export const CTRL_HOME_THEN_DOWN = "Control+Home followed by Down Arrow"; +export const DELETE = "Delete"; +export const ALT_DELETE = "Alt+Delete"; +export const ALT_DOWN = "Alt+Down"; +export const CTRL_ALT_DOWN = "Control+Alt+Down"; +export const ALT_UP = "Alt+Up"; +export const C_AND_SHIFT_C = "C / Shift+C"; +export const SHIFT_C = "Shift+C"; +export const CTRL_INS_X = "Control+Insert+X"; +export const OPT_DOWN = "Option+Down"; +export const OPT_UP = "Option+Up"; +export const CTRL_OPT_LEFT = "Ctrl+Option+Left"; +export const CTRL_ALT_LEFT = "Control+Alt+Left"; +export const CTRL_OPT_RIGHT = "Control+Option+Right"; +export const CTRL_ALT_RIGHT = "Control+Alt+Right"; +export const CTRL_OPT_UP = "Control+Option+Up"; +export const CTRL_OPT_DOWN = "Control+Option+Down"; +export const CTRL_OPT_RIGHT_AND_CTRL_OPT_LEFT = "Control+Option+Right / Ctrl+Option+Left"; +export const CTRL_OPT_A = "Control+Option+A"; +export const CTRL_OPT_CMD_J = "Control+Option+Command+J"; +export const CTRL_OPT_CMD_L = "Control+Option+Command+L"; +export const CTRL_OPT_CMD_P = "Control+Option+Command+P"; +export const SHIFT_CTRL_OPT_CMD_J = "Shift+Control+Option+Command+J"; +export const SHIFT_CTRL_OPT_CMD_L = "Shift+Control+Option+Command+L"; +export const CTRL_OPT_CMD_J_AND_SHIFT_CTRL_OPT_CMD_J = "Control+Option+Command+J / Shift+Control+Option+Command+J"; +export const CTRL_OPT_CMD_C_AND_SHIFT_CTRL_OPT_CMD_C = "Control+Option+Command+C / Shift+Control+Option+Command+C"; +export const CTRL_OPT_F3 = "Control+Option+F3"; +export const CTRL_OPT_F4 = "Control+Option+F4"; +export const CTRL_OPT_SPACE = "Control+Option+Space"; +export const CTRL_OPT_SPACE_THEN_CTRL_OPT_RIGHT = "Control+Option+Space followed by Control+Option+Right"; +export const CTRL_U = "Control+U"; +export const CMD = "Command"; +export const CMD_LEFT = "Command+Left"; +export const CMD_RIGHT = "Command+Right"; +export const CMD_DOWN = "Command+Down"; +export const CMD_UP = "Command+Up"; +export const DOWN = "Down Arrow"; +export const END = "End"; +export const ENTER = "Enter"; +export const E_AND_SHIFT_E = "E / Shift+E"; +export const ESC = "Escape"; +export const F_AND_SHIFT_F = "F / Shift+F"; +export const HOME = "Home"; +export const INS_DOWN_OR_CAPS_DOWN = "Insert+Down (or CapsLock+Down)"; +export const INS_F7_OR_CAPS_F7 = "Insert+F7 (or CapsLock+F7)"; +export const INS_SPACE = "Insert+Space"; +export const INS_TAB = "Insert+Tab"; +export const INS_TAB_OR_CAPS_TAB = "Insert+Tab (or CapsLock+Tab)"; +export const INS_UP_OR_CAPS_I = "Insert+Up (or CapsLock+I)"; +export const INS_UP = "Insert+Up"; +export const INS_UP_OR_CAPS_UP = "Insert+Up (or CapsLock+Up)"; +export const INS_Z = "Insert+Z"; +export const LEFT_AND_RIGHT = "Left Arrow / Right Arrow"; +export const LEFT = "Left Arrow"; +export const NUMPAD_5 = "Numpad 5"; +export const INS_NUMPAD_5 = "Insert+Numpad 5 (or CapsLock+Numpad 5)"; +export const INS_NUMPAD_6 = "Insert+Numpad 6 (or CapsLock+Numpad 6)"; +export const NUMPAD_PLUS = "Numpad Plus"; +export const RIGHT = "Right Arrow"; +export const SPACE = "Space"; +export const TAB = "Tab"; +export const SHIFT_TAB = "Shift+Tab"; +export const TAB_AND_SHIFT_TAB = "Tab / Shift+Tab"; +export const UP = "Up Arrow"; +export const CTRL_ALT_UP = "Control+Alt+Up"; +export const UP_AND_DOWN = "Up Arrow / Down Arrow"; +export const SHIFT_X = "Shift+X"; +export const X_AND_SHIFT_X = "X / Shift+X"; +export const A = "A"; +export const SHIFT_A = "Shift+A"; +export const B = "B"; +export const SHIFT_B = "Shift+B"; +export const C = "C"; +export const D = "D"; +export const E = "E"; +export const SHIFT_E = "Shift+E"; +export const F = "F"; +export const SHIFT_F = "Shift+F"; +export const G = "G"; +export const H = "H"; +export const I = "I"; +export const SHIFT_I = "Shift+I"; +export const J = "J"; +export const K = "K"; +export const SHIFT_K = "Shift+K"; +export const L = "L"; +export const SHIFT_L = "Shift+L"; +export const M = "M"; +export const N = "N"; +export const O = "O"; +export const P = "P"; +export const Q = "Q"; +export const R = "R"; +export const SHIFT_R = "Shift+R"; +export const S = "S"; +export const T = "T"; +export const SHIFT_T = "Shift+T"; +export const CTRL_OPT_CMD_T = "Control+Option+Command+T"; +export const T_THEN_DOWN = "T followed by Down Arrow"; +export const SHIFT_T_THEN_DOWN = "Shift+T followed by Down Arrow"; +export const U = "U"; +export const SHIFT_U = "Shift+U"; +export const V = "V"; +export const W = "W"; +export const X = "X"; +export const Y = "Y"; +export const CTRL_OPT_CMD_Y = "Control+Option+Command+Y"; +export const SHIFT_CTRL_OPT_CMD_Y = "Shift+Control+Option+Command+Y"; +export const Z = "Z"; +export const PAGE_DOWN = "Page Down"; +export const PAGE_UP = "Page Up"; +export const SHIFT_D = "Shift+D"; +export const CTRL_OPT_CMD_G = "Control+Option+Command+G"; +export const CTRL_OPT_CMD_H = "Control+Option+Command+H"; +export const CTRL_OPT_CMD_X = "Control+Option+Command+X"; +export const SHIFT_CTRL_OPT_CMD_X = "Shift+Control+Option+Command+X"; +export const SHIFT_CTRL_OPT_CMD_G = "Shift+Control+Option+Command+G"; +export const SHIFT_CTRL_OPT_CMD_H = "Shift+Control+Option+Command+H"; +export const SHIFT_CTRL_OPT_CMD_P = "Shift+Control+Option+Command+P"; +export const SHIFT_G = "Shift+G"; +export const SHIFT_H = "Shift+H"; +export const ONE = "1"; +export const TWO = "2"; +export const SHIFT_ONE = "Shift+1"; +export const SHIFT_TWO = "Shift+2"; +export const SHIFT_P = "Shift+P"; +export const COMMA = "Comma"; +export const SHIFT_PERIOD = "Shift+Period"; diff --git a/client/resources/support.json b/client/resources/support.json new file mode 100644 index 000000000..4b91b7f71 --- /dev/null +++ b/client/resources/support.json @@ -0,0 +1,410 @@ +{ + "ats": [ + { + "name": "JAWS", + "key": "jaws", + "defaultConfigurationInstructionsHTML": "Configure JAWS with default settings. For help, read <a href="https://github.com/w3c/aria-at/wiki/Configuring-Screen-Readers-for-Testing">Configuring Screen Readers for Testing</a>.", + "assertionTokens": { + "screenReader": "JAWS", + "readingMode": "virtual cursor active", + "interactionMode": "PC cursor active" + }, + "settings": { + "virtualCursor": { + "screenText": "virtual cursor active", + "instructions": [ + "Press <kbd>Alt</kbd>+<kbd>Delete</kbd> to determine which cursor is active.", + "If the PC cursor is active, press <kbd>Escape</kbd> to activate the virtual cursor." + ] + }, + "pcCursor": { + "screenText": "PC cursor active", + "instructions": [ + "Press <kbd>Alt</kbd>+<kbd>Delete</kbd> to determine which cursor is active.", + "If the virtual cursor is active, press <kbd>Insert</kbd>+<kbd>z</kbd> to disable the virtual cursor." + ] + } + } + }, + { + "name": "NVDA", + "key": "nvda", + "defaultConfigurationInstructionsHTML": "Configure NVDA with default settings. For help, read <a href="https://github.com/w3c/aria-at/wiki/Configuring-Screen-Readers-for-Testing">Configuring Screen Readers for Testing</a>.", + "assertionTokens": { + "screenReader": "NVDA", + "readingMode": "browse mode", + "interactionMode": "focus mode" + }, + "settings": { + "browseMode": { + "screenText": "browse mode on", + "instructions": [ + "Press <kbd>Insert</kbd>+<kbd>Space</kbd>.", + "If NVDA made the focus mode sound, press <kbd>Insert</kbd>+<kbd>Space</kbd> again to turn browse mode back on." + ] + }, + "focusMode": { + "screenText": "focus mode on", + "instructions": [ + "Press <kbd>Insert</kbd>+<kbd>Space</kbd>.", + "If NVDA made the browse mode sound, press <kbd>Insert</kbd>+<kbd>Space</kbd> again to turn focus mode back on." + ] + } + } + }, + { + "name": "VoiceOver for macOS", + "key": "voiceover_macos", + "defaultConfigurationInstructionsHTML": "Configure VoiceOver with default settings. For help, read <a href="https://github.com/w3c/aria-at/wiki/Configuring-Screen-Readers-for-Testing">Configuring Screen Readers for Testing</a>.", + "settings": { + "quickNavOn": { + "screenText": "quick nav on", + "instructions": [ + "Simultaneously press <kbd>Left Arrow</kbd> and <kbd>Right Arrow</kbd>.", + "If VoiceOver said 'quick nav off', press <kbd>Left Arrow</kbd> and <kbd>Right Arrow</kbd> again to turn it back on." + ] + }, + "quickNavOff": { + "screenText": "quick nav off", + "instructions": [ + "Simultaneously press <kbd>Left Arrow</kbd> and <kbd>Right Arrow</kbd>.", + "If VoiceOver said 'quick nav on', press <kbd>Left Arrow</kbd> and <kbd>Right Arrow</kbd> again to turn it back off." + ] + } + } + } + ], + "applies_to": { + "Desktop Screen Readers": [ + "VoiceOver for macOS", + "NVDA", + "JAWS" + ], + "Screen Readers": [ + "VoiceOver for macOS", + "NVDA", + "JAWS" + ] + }, + "testPlanStrings": { + "ariaSpecsPreface": "Tested ARIA features:", + "openExampleInstruction": "Activate the "Open test page" button, which opens the example to test in a new window and runs a script that", + "commandListPreface": "Do this with each of the following commands or command sequences.", + "commandListSettingsPreface": "If any settings are specified in parentheses, ensure the settings are active before executing the command or command sequence.", + "settingInstructionsPreface": "To perform a task with", + "assertionResponseQuestion": "Which statements are true about the response to" + }, + "references": { + "aria": { + "baseUrl": "https://www.w3.org/TR/wai-aria/", + "linkText": "ARIA Specification", + "fragmentIds": { + "alert": "#alert", + "alertdialog": "#alertdialog", + "application": "#application", + "article": "#article", + "associationlist": "#associationlist", + "associationlistitemkey": "#associationlistitemkey", + "associationlistitemvalue": "#associationlistitemvalue", + "banner": "#banner", + "blockquote": "#blockquote", + "button": "#button", + "caption": "#caption", + "cell": "#cell", + "checkbox": "#checkbox", + "code": "#code", + "columnheader": "#columnheader", + "combobox": "#combobox", + "command": "#command", + "comment": "#comment", + "complementary": "#complementary", + "composite": "#composite", + "contentinfo": "#contentinfo", + "definition": "#definition", + "deletion": "#deletion", + "dialog": "#dialog", + "directory": "#directory", + "document": "#document", + "emphasis": "#emphasis", + "feed": "#feed", + "figure": "#figure", + "form": "#form", + "generic": "#generic", + "grid": "#grid", + "gridcell": "#gridcell", + "group": "#group", + "heading": "#heading", + "image": "#image", + "img": "#img", + "input": "#input", + "insertion": "#insertion", + "landmark": "#landmark", + "link": "#link", + "list": "#list", + "listbox": "#listbox", + "listitem": "#listitem", + "log": "#log", + "main": "#main", + "mark": "#mark", + "marquee": "#marquee", + "math": "#math", + "menu": "#menu", + "menubar": "#menubar", + "menuitem": "#menuitem", + "menuitemcheckbox": "#menuitemcheckbox", + "menuitemradio": "#menuitemradio", + "meter": "#meter", + "navigation": "#navigation", + "none": "#none", + "note": "#note", + "option": "#option", + "paragraph": "#paragraph", + "presentation": "#presentation", + "progressbar": "#progressbar", + "radio": "#radio", + "radiogroup": "#radiogroup", + "range": "#range", + "region": "#region", + "roletype": "#roletype", + "row": "#row", + "rowgroup": "#rowgroup", + "rowheader": "#rowheader", + "scrollbar": "#scrollbar", + "search": "#search", + "searchbox": "#searchbox", + "section": "#section", + "sectionhead": "#sectionhead", + "select": "#select", + "separator": "#separator", + "slider": "#slider", + "spinbutton": "#spinbutton", + "status": "#status", + "strong": "#strong", + "structure": "#structure", + "subscript": "#subscript", + "suggestion": "#suggestion", + "superscript": "#superscript", + "switch": "#switch", + "tab": "#tab", + "table": "#table", + "tablist": "#tablist", + "tabpanel": "#tabpanel", + "term": "#term", + "textbox": "#textbox", + "time": "#time", + "timer": "#timer", + "toolbar": "#toolbar", + "tooltip": "#tooltip", + "tree": "#tree", + "treegrid": "#treegrid", + "treeitem": "#treeitem", + "widget": "#widget", + "window": "#window", + "aria-activedescendant": "#aria-activedescendant", + "aria-atomic": "#aria-atomic", + "aria-autocomplete": "#aria-autocomplete", + "aria-braillelabel": "#aria-braillelabel", + "aria-brailleroledescription": "#aria-brailleroledescription", + "aria-busy": "#aria-busy", + "aria-checked": "#aria-checked", + "aria-colcount": "#aria-colcount", + "aria-colindex": "#aria-colindex", + "aria-colindextext": "#aria-colindextext", + "aria-colspan": "#aria-colspan", + "aria-controls": "#aria-controls", + "aria-current": "#aria-current", + "aria-describedby": "#aria-describedby", + "aria-description": "#aria-description", + "aria-details": "#aria-details", + "aria-disabled": "#aria-disabled", + "aria-errormessage": "#aria-errormessage", + "aria-expanded": "#aria-expanded", + "aria-flowto": "#aria-flowto", + "aria-haspopup": "#aria-haspopup", + "aria-hidden": "#aria-hidden", + "aria-invalid": "#aria-invalid", + "aria-keyshortcuts": "#aria-keyshortcuts", + "aria-label": "#aria-label", + "aria-labelledby": "#aria-labelledby", + "aria-level": "#aria-level", + "aria-live": "#aria-live", + "aria-modal": "#aria-modal", + "aria-multiline": "#aria-multiline", + "aria-multiselectable": "#aria-multiselectable", + "aria-orientation": "#aria-orientation", + "aria-owns": "#aria-owns", + "aria-placeholder": "#aria-placeholder", + "aria-posinset": "#aria-posinset", + "aria-pressed": "#aria-pressed", + "aria-readonly": "#aria-readonly", + "aria-relevant": "#aria-relevant", + "aria-required": "#aria-required", + "aria-roledescription": "#aria-roledescription", + "aria-rowcount": "#aria-rowcount", + "aria-rowindex": "#aria-rowindex", + "aria-rowindextext": "#aria-rowindextext", + "aria-rowspan": "#aria-rowspan", + "aria-selected": "#aria-selected", + "aria-setsize": "#aria-setsize", + "aria-sort": "#aria-sort", + "aria-valuemax": "#aria-valuemax", + "aria-valuemin": "#aria-valuemin", + "aria-valuenow": "#aria-valuenow", + "aria-valuetext": "#aria-valuetext" + } + }, + "htmlAam": { + "baseUrl": "https://www.w3.org/TR/html-aam-1.0/", + "linkText": "Accessibility API Mapping", + "fragmentIds": { + "a": "#el-a", + "aNoHref": "#el-a-no-href", + "abbr": "#el-abbr", + "address": "#el-address", + "area": "#el-area", + "areaNoHref": "#el-area-no-href", + "article": "#el-article", + "asideBodyOrMainScope": "#el-aside-ancestorbodymain", + "asideSectionScope": "#el-aside", + "audio": "#el-audio", + "autonomous custom element": "#el-autonomous-custom-element", + "b": "#el-b", + "base": "#el-base", + "bdi": "#el-bdi", + "bdo": "#el-bdo", + "blockquote": "#el-blockquote", + "body": "#el-body", + "br": "#el-br", + "button": "#el-button", + "canvas": "#el-canvas", + "caption": "#el-caption", + "cite": "#el-cite", + "code": "#el-code", + "col": "#el-col", + "colgroup": "#el-colgroup", + "data": "#el-data", + "datalist": "#el-datalist", + "dd": "#el-dd", + "del": "#el-del", + "details": "#el-details", + "dfn": "#el-dfn", + "dialog": "#el-dialog", + "div": "#el-div", + "dl": "#el-dl", + "dt": "#el-dt", + "em": "#el-em", + "embed": "#el-embed", + "fieldset": "#el-fieldset", + "figcaption": "#el-figcaption", + "figure": "#el-figure", + "footerBodyScope": "#el-footer-ancestorbody", + "footerMainScope": "#el-footer", + "form": "#el-form", + "formAssociatedCustomElement": "#el-form-associated-custom-element", + "heading": "#el-h1-h6", + "head": "#el-head", + "headerBodyScope": "#el-header-ancestorbody", + "headerMainScope": "#el-header", + "hgroup": "#el-hgroup", + "hr": "#el-hr", + "html": "#el-html", + "i": "#el-i", + "iframe": "#el-iframe", + "img": "#el-img", + "imgEmptyAlt": "#el-img-empty-alt", + "inputTypeButton": "#el-input-button", + "inputTypeCheckbox": "#el-input-checkbox", + "inputTypeColor": "#el-input-color", + "inputTypeDate": "#el-input-date", + "inputTypeDateTime": "#el-input-datetime-local", + "inputTypeEmail": "#el-input-email", + "inputTypeFile": "#el-input-file", + "inputTypeHidden": "#el-input-hidden", + "inputTypeImage": "#el-input-image", + "inputTypeMonth": "#el-input-month", + "inputTypeNumber": "#el-input-number", + "inputTypePassword": "#el-input-password", + "inputTypeRadio": "#el-input-radio", + "inputTypeRange": "#el-input-range", + "inputTypeReset": "#el-input-reset", + "inputTypeSearch": "#el-input-search", + "inputTypeSubmit": "#el-input-submit", + "inputTypeTelephone": "#el-input-tel", + "inputTypeText": "#el-input-text", + "inputTypeTextAutocomplete": "#el-input-textetc-autocomplete", + "inputTypeTime": "#el-input-time", + "inputTypeUrl": "#el-input-url", + "inputTypeWeek": "#el-input-week", + "ins": "#el-ins", + "kbd": "#el-kbd", + "label": "#el-label", + "legend": "#el-legend", + "li": "#el-li", + "link": "#el-link", + "main": "#el-main", + "map": "#el-map", + "mark": "#el-mark", + "math": "#el-math", + "menu": "#el-menu", + "meta": "#el-meta", + "meter": "#el-meter", + "nav": "#el-nav", + "noscript": "#el-noscript", + "object": "#el-object", + "ol": "#el-ol", + "optgroup": "#el-optgroup", + "option": "#el-option", + "output": "#el-output", + "p": "#el-p", + "param": "#el-param", + "picture": "#el-picture", + "pre": "#el-pre", + "progress": "#el-progress", + "q": "#el-q", + "rb": "#el-rb", + "rp": "#el-rp", + "rt": "#el-rt", + "rtc": "#el-rtc", + "ruby": "#el-ruby", + "s": "#el-s", + "samp": "#el-samp", + "script": "#el-script", + "search": "#el-search", + "section": "#el-section", + "select": "#el-select-listbox", + "selectSize1": "#el-select-combobox", + "slot": "#el-slot", + "small": "#el-small", + "source": "#el-source", + "span": "#el-span", + "strong": "#el-strong", + "style": "#el-style", + "sub": "#el-sub", + "summary": "#el-summary", + "sup": "#el-sup", + "svg": "#el-svg", + "table": "#el-table", + "tbody": "#el-tbody", + "td": "#el-td", + "tdGridcell": "#el-td-gridcell", + "template": "#el-template", + "textarea": "#el-textarea", + "tfoot": "#el-tfoot", + "th": "#el-th", + "thGridcell": "#el-th-gridcell", + "thColgroupHeader": "#el-th-columnheader", + "thRowgroupHeader": "#el-th-rowheader", + "thead": "#el-thead", + "time": "#el-time", + "title": "#el-title", + "tr": "#el-tr", + "track": "#el-track", + "u": "#el-u", + "ul": "#el-ul", + "var": "#el-var", + "video": "#el-video", + "wbr": "#el-wbr" + } + } + } +} diff --git a/client/resources/types/aria-at-test-result.js b/client/resources/types/aria-at-test-result.js new file mode 100644 index 000000000..ec8eab55d --- /dev/null +++ b/client/resources/types/aria-at-test-result.js @@ -0,0 +1,39 @@ +/** + * Types of a format of a test result submitted to or received from aria-at-app. + * @namespace AriaATTestResult + */ + +/** + * @typedef {"MUST" + * | "SHOULD"} AriaATTestResult.AssertionPriorityJSON + */ + +/** + * @typedef {"INCORRECT_OUTPUT" + * | "NO_OUTPUT"} AriaATTestResult.AssertionFailedReasonJSON + */ + +/** + * @typedef AriaATTestResult.JSON + * @property {object} test + * @property {string} test.title + * @property {object} test.at + * @property {string} test.at.id + * @property {string} test.atMode + * @property {object[]} scenarioResults + * @property {object} scenarioResults[].scenario + * @property {object} scenarioResults[].scenario.command + * @property {string} scenarioResults[].scenario.command.id + * @property {string} scenarioResults[].output + * @property {object[]} scenarioResults[].assertionResults + * @property {object} scenarioResults[].assertionResults[].assertion + * @property {AriaATTestResult.AssertionPriorityJSON} scenarioResults[].assertionResults[].assertion.priority + * @property {string} scenarioResults[].assertionResults[].assertion.text + * @property {boolean} scenarioResults[].assertionResults[].passed + * @property {AriaATTestResult.AssertionFailedReasonJSON | null} [scenarioResults[].assertionResults[].failedReason] + * @property {object[]} scenarioResults[].unexpectedBehaviors + * @property {string} scenarioResults[].unexpectedBehaviors[].id + * @property {string} scenarioResults[].unexpectedBehaviors[].text + * @property {string} scenarioResults[].unexpectedBehaviors[].impact + * @property {string} scenarioResults[].unexpectedBehaviors[].details + */ diff --git a/client/resources/types/aria-at-test-run.js b/client/resources/types/aria-at-test-run.js new file mode 100644 index 000000000..cdeceb90e --- /dev/null +++ b/client/resources/types/aria-at-test-run.js @@ -0,0 +1,100 @@ +/** @namespace AriaATTestRun */ + +/** + * @typedef {"reading" + * | "interaction"} AriaATTestRun.ATMode + */ + +/** + * @typedef {"loadPage" + * | "openTestWindow" + * | "closeTestWindow" + * | "validateResults" + * | "changeText" + * | "changeSelection" + * | "showResults"} AriaATTestRun.UserActionName + */ + +/** + * @typedef {"focusUndesirable"} AriaATTestRun.UserActionObjectName + */ + +/** + * @typedef AriaATTestRun.UserActionFocusUnexpected + * @property {"focusUndesirable"} action + * @property {number} commandIndex + * @property {number} unexpectedIndex + */ + +/** + * @typedef {AriaATTestRun.UserActionName + * | AriaATTestRun.UserActionFocusUnexpected} AriaATTestRun.UserAction + */ + +/** + * @typedef {"notSet" + * | "pass" + * | "failMissing" + * | "failIncorrect"} AriaATTestRun.AssertionResult + */ + +/** + * @typedef {"notSet" + * | "pass" + * | "failSupport"} AriaATTestRun.AdditionalAssertionResult + */ + +/** + * @typedef {"notSet" + * | "hasUnexpected" + * | "doesNotHaveUnexpected"} AriaATTestRun.HasUnexpectedBehavior + */ + +/** + * @typedef AriaATTestRun.State + * This state contains all the serializable values that are needed to render any of the documents (InstructionDocument, + * ResultsTableDocument, and TestPageDocument) from the test-run module. + * + * @property {string[] | null} errors + * @property {object} info + * @property {string} info.description + * @property {string} info.task + * @property {AriaATTestRun.ATMode} info.mode + * @property {string} info.modeInstructions + * @property {string[]} info.userInstructions + * @property {string} info.setupScriptDescription + * @property {object} config + * @property {object} config.at + * @property {string} config.at.key + * @property {string} config.at.name + * @property {boolean} config.renderResultsAfterSubmit + * @property {boolean} config.displaySubmitButton + * @property {AriaATTestRun.UserAction} currentUserAction + * @property {object[]} commands + * @property {string} commands[].description + * @property {object} commands[].atOutput + * @property {boolean} commands[].atOutput.highlightRequired + * @property {string} commands[].atOutput.value + * @property {object[]} commands[].assertions + * @property {string} commands[].assertions[].description + * @property {boolean} commands[].assertions[].highlightRequired + * @property {number} commands[].assertions[].priority + * @property {AriaATTestRun.AssertionResult} commands[].assertions[].result + * @property {object[]} commands[].additionalAssertions + * @property {string} commands[].additionalAssertions[].description + * @property {boolean} commands[].additionalAssertions[].highlightRequired + * @property {number} commands[].additionalAssertions[].priority + * @property {AriaATTestRun.AdditionalAssertionResult} commands[].additionalAssertions[].result + * @property {object} commands[].unexpected + * @property {boolean} commands[].unexpected.highlightRequired + * @property {AriaATTestRun.HasUnexpectedBehavior} commands[].unexpected.hasUnexpected + * @property {number} commands[].unexpected.tabbedBehavior + * @property {object[]} commands[].unexpected.behaviors + * @property {string} commands[].unexpected.behaviors[].description + * @property {boolean} commands[].unexpected.behaviors[].checked + * @property {object} [commands[].unexpected.behaviors[].more] + * @property {boolean} commands[].unexpected.behaviors[].more.highlightRequired + * @property {string} commands[].unexpected.behaviors[].more.value + * @property {object} openTest + * @property {boolean} openTest.enabled + */ diff --git a/client/resources/vrender.mjs b/client/resources/vrender.mjs new file mode 100644 index 000000000..f21b6b2dc --- /dev/null +++ b/client/resources/vrender.mjs @@ -0,0 +1,987 @@ +const mounts = new WeakMap(); + +/** + * @param {HTMLElement} mount + * @param {NodeNode} newValue + */ +export function render(mount, newValue) { + let lastValue = mounts.get(mount); + if (!lastValue) { + lastValue = ElementType.init(newQueue(), null, null, element(mount.tagName)); + lastValue.ref = mount; + mounts.set(mount, lastValue); + } + const queue = newQueue(); + lastValue.type.diff(queue, lastValue, element(mount.tagName, newValue)); + runQueue(queue); + if (!newValue) { + mounts.delete(mount); + } +} + +/** + * @param {string} shape + * @param {...NodeNode} content + * @returns {ElementNode} + */ +export function element(shape, ...content) { + return { + type: ELEMENT_TYPE_NAME, + shape, + content: content.map(asNode), + }; +} + +/** + * @param {...NodeNode} content + * @returns {FragmentNode} + */ +export function fragment(...content) { + return { + type: FRAGMENT_TYPE_NAME, + shape: FRAGMENT_TYPE_NAME, + content: content.map(asNode), + }; +} + +/** + * @param {string} content + * @returns {TextNode} + */ +export function text(content) { + return { + type: TEXT_TYPE_NAME, + shape: TEXT_TYPE_NAME, + content, + }; +} + +/** + * @param {function} shape + * @param {...NodeNode} content + * @returns {ComponentNode} + */ +export function component(shape, ...content) { + return { + type: COMPONENT_TYPE_NAME, + shape, + content, + }; +} + +/** + * @param {{[key: string]: string}} styleMap + * @returns {MemberNode} + */ +export function style(styleMap) { + return attribute( + 'style', + Object.keys(styleMap) + .map(key => `${key}: ${styleMap[key]};`) + .join(' ') + ); +} + +/** + * @param {string[]} names + * @returns {MemberNode} + */ +export function className(names) { + return attribute('class', names.filter(Boolean).join(' ')); +} + +/** + * @param {string} name + * @param {string | boolean} value + * @returns {MemberNode} + */ +export function attribute(name, value) { + return { + type: ATTRIBUTE_TYPE_NAME, + name, + value, + }; +} + +/** + * @param {string} name + * @param {any} value + * @returns {MemberNode} + */ +export function property(name, value) { + return { + type: PROPERTY_TYPE_NAME, + name, + value, + }; +} + +/** + * @param {string} name + * @param {any} value + * @returns {MemberNode} + */ +export function meta(name, value) { + return { + type: META_TYPE_NAME, + name, + value, + }; +} + +const refMap = new WeakMap(); + +/** + * @param {{ref: HTMLElement | null}} value + * @returns {MemberNode} + */ +export function ref(value) { + let refHook = refMap.get(value); + if (!refHook) { + refHook = (/** @type {HTMLElement} */ element) => { + value.ref = element; + }; + refMap.set(value, refHook); + } + return { + type: REF_TYPE_NAME, + name: 'ref', + value: refHook, + }; +} + +const noop = function () {}; + +/** + * @param {boolean} shouldFocus + */ +export function focus(shouldFocus) { + return { + type: REF_TYPE_NAME, + name: 'focus', + value: shouldFocus ? element => element.focus() : noop, + }; +} + +function asNode(item) { + if (typeof item === 'string') { + return text(item); + } else if (Array.isArray(item)) { + return fragment(...item); + } else if (item === null || item === undefined) { + return fragment(); + } + return item; +} + +const ELEMENT_TYPE_NAME = 'element'; +const FRAGMENT_TYPE_NAME = 'fragment'; +const COMPONENT_TYPE_NAME = 'component'; +const TEXT_TYPE_NAME = 'text'; +const ATTRIBUTE_TYPE_NAME = 'attribute'; +const PROPERTY_TYPE_NAME = 'property'; +const REF_TYPE_NAME = 'ref'; +const META_TYPE_NAME = 'meta'; + +/** @type ElementStateType */ +const ElementType = { + name: ELEMENT_TYPE_NAME, + diffEntry: /** @type {DiffEntryFunction} */ (diffChildEntry), + init: /** @type {InitNodeFunction} */ ( + function (queue, parent, after, /** @type {ElementNode} */ node) { + const state = { + type: ElementType, + parent, + after, + shape: node.shape, + content: null, + ref: null, + refHooks: null, + rewriteChildIndex: 0, + children: null, + rewriteMemberIndex: 0, + members: null, + }; + enqueueChange(queue, addElement, state); + return state; + } + ), + diff: /** @type {DiffFunction} */ ( + function (queue, /** @type {ElementState} */ lastValue, /** @type {ElementNode} */ newValue) { + lastValue.rewriteMemberIndex = 0; + diffFragment(queue, lastValue, newValue); + if (lastValue.members !== null) { + const group = lastValue.members; + let index; + for (index = lastValue.rewriteMemberIndex; index < group.length; index++) { + const node = group[index]; + node.type.teardown(queue, node); + } + if (lastValue.rewriteMemberIndex === 0) { + lastValue.members = null; + } else { + group.length = lastValue.rewriteMemberIndex; + } + } + } + ), + teardown: /** @type {TeardownFunction} */ ( + function (queue, /** @type {ElementState} */ state) { + enqueueChange(queue, removeElement, state); + const { children } = state; + if (children !== null) { + for (let i = 0; i < children.length; i++) { + children[i].type.softTeardown(children[i]); + } + } + } + ), + softTeardown: /** @type {SoftTeardownFunction} */ (softTeardownElement), +}; + +/** @type {FragmentStateType} */ +const FragmentType = { + name: FRAGMENT_TYPE_NAME, + diffEntry: /** @type {DiffEntryFunction} */ (diffChildEntry), + init(queue, parent, after) { + return { + type: FragmentType, + parent, + after, + shape: FRAGMENT_TYPE_NAME, + content: null, + rewriteChildIndex: 0, + children: null, + }; + }, + diff: /** @type {DiffFunction} */ (diffFragment), + teardown: /** @type {TeardownFunction} */ ( + function (queue, /** @type {FragmentState} */ state) { + const { children } = state; + if (children !== null) { + for (let i = 0; i < children.length; i++) { + children[i].type.teardown(queue, children[i]); + } + } + } + ), + softTeardown: /** @type {SoftTeardownFunction} */ (softTeardownElement), +}; + +/** @type ComponentStateType */ +const ComponentType = { + name: COMPONENT_TYPE_NAME, + diffEntry: /** @type {DiffEntryFunction} */ (diffChildEntry), + init: /** @type {InitNodeFunction} */ ( + function (queue, parent, after, /** @type {ComponentNode} */ node) { + return { + type: ComponentType, + parent, + after, + shape: node.shape, + content: null, + rendered: null, + }; + } + ), + diff: /** @type {DiffFunction} */ ( + function (queue, /** @type {ComponentState} */ lastChild, /** @type {ComponentNode} */ node) { + /** @type {MemberNode} */ + const componentOptionsMeta = node.content.find(isOptions) || null; + const componentOptions = componentOptionsMeta ? componentOptionsMeta.value : null; + if (shallowEquals(lastChild.content, componentOptions) === false) { + lastChild.content = componentOptions; + queue.prepare.push(lastChild); + } + } + ), + teardown: /** @type {TeardownFunction} */ ( + function (queue, /** @type {ComponentState} */ state) { + if (state.rendered !== null) { + state.rendered.type.teardown(queue, state.rendered); + } + } + ), + softTeardown: /** @type {SoftTeardownFunction} */ ( + function (/** @type {ComponentState} */ state) { + if (state.rendered !== null) { + state.rendered.type.softTeardown(state.rendered); + } + } + ), +}; + +/** @type TextStateType */ +const TextType = { + name: TEXT_TYPE_NAME, + diffEntry: /** @type {DiffEntryFunction} */ (diffChildEntry), + init: /** @type {InitNodeFunction} */ ( + function (queue, parent, after, /** @type {TextNode} */ node) { + /** @type {TextState} */ + const state = { + type: TextType, + parent, + after, + shape: TEXT_TYPE_NAME, + content: node.content, + ref: null, + }; + enqueueChange(queue, addText, state); + return state; + } + ), + diff: /** @type {DiffFunction} */ ( + function (queue, /** @type {TextState} */ lastChild, /** @type {TextNode} */ node) { + if (lastChild.content !== node.content) { + lastChild.content = node.content; + enqueueChange(queue, changeText, lastChild); + } + } + ), + teardown: /** @type {TeardownFunction} */ ( + function (queue, /** @type {TextState} */ state) { + enqueueChange(queue, removeText, state); + } + ), + softTeardown() {}, +}; + +/** @type {MemberStateType} */ +const AttributeType = { + name: ATTRIBUTE_TYPE_NAME, + diffEntry: /** @type {DiffEntryFunction} */ (diffMemberEntry), + init(parent, name) { + return { + type: AttributeType, + parent, + name, + value: null, + }; + }, + diff: /** @type {DiffFunction} */ ( + function (queue, /** @type {MemberState} */ old, /** @type {MemberNode} */ memberNode) { + if (old.value !== memberNode.value) { + old.value = memberNode.value; + if (old.value === false) { + enqueueChange(queue, removeAttribute, old); + } else { + enqueueChange(queue, changeAttribute, old); + } + } + } + ), + teardown(queue, state) { + enqueueChange(queue, removeAttribute, state); + }, +}; + +/** @type {MemberStateType} */ +const PropertyType = { + name: PROPERTY_TYPE_NAME, + diffEntry: /** @type {DiffEntryFunction} */ (diffMemberEntry), + init(parent, name) { + return { + type: PropertyType, + parent, + name, + value: null, + }; + }, + diff: /** @type {DiffFunction} */ ( + function (queue, /** @type {MemberState} */ old, /** @type {MemberNode} */ memberNode) { + if (old.value !== memberNode.value) { + old.value = memberNode.value; + enqueueChange(queue, changeProperty, old); + } + } + ), + teardown(queue, state) { + state.value = undefined; + enqueueChange(queue, changeProperty, state); + }, +}; + +/** @type {MemberStateType} */ +const RefType = { + name: REF_TYPE_NAME, + diffEntry: /** @type {DiffEntryFunction} */ (diffMemberEntry), + init(parent, name) { + return { + type: RefType, + parent, + name, + value: null, + }; + }, + diff: /** @type {DiffFunction} */ ( + function (queue, /** @type {MemberState} */ state, /** @type {MemberNode} */ node) { + if (state.value !== node.value) { + state.value = node.value; + if (state.parent.refHooks === null) { + state.parent.refHooks = []; + } + if (state.parent.refHooks.indexOf(state) === -1) { + state.parent.refHooks.push(state); + } + enqueuePost(queue, updateRef, state); + } + } + ), + teardown(queue, state) { + const index = state.parent.refHooks.indexOf(state); + state.parent.refHooks.splice(index, 1); + if (state.parent.refHooks.length === 0) { + state.parent.refHooks = null; + } + enqueuePost(queue, unsetRef, state); + }, +}; + +const typeMap = { + [ELEMENT_TYPE_NAME]: ElementType, + [FRAGMENT_TYPE_NAME]: FragmentType, + [COMPONENT_TYPE_NAME]: ComponentType, + [TEXT_TYPE_NAME]: TextType, + [ATTRIBUTE_TYPE_NAME]: AttributeType, + [PROPERTY_TYPE_NAME]: PropertyType, + [REF_TYPE_NAME]: RefType, +}; + +/** @type DiffEntryFunction */ +function diffChildEntry( + queue, + parent, + /** @type NodeStateType */ metaType, + /** @type NodeNode */ element +) { + if (!parent.children) { + parent.children = []; + } + const index = parent.rewriteChildIndex++; + let state = parent.children[index]; + if (!state || state.shape !== element.shape) { + if (state) { + state.type.teardown(queue, state); + } + state = /** @type {NodeState} */ ( + metaType.init(queue, parent, parent.children[index - 1] || null, element) + ); + parent.children[index] = state; + + const sibling = parent.children[index + 1]; + if (sibling) { + sibling.after = state; + } + } + state.type.diff(queue, state, element); +} + +/** @type {DiffFunction} */ +function diffFragment( + queue, + /** @type {ElementState | FragmentState} */ lastValue, + /** @type {ElementNode | FragmentNode} */ newValue +) { + lastValue.rewriteChildIndex = 0; + const { content } = newValue; + for (let i = 0; i < content.length; i++) { + const node = content[i]; + const metaType = typeMap[node.type]; + metaType.diffEntry(queue, lastValue, metaType, node); + } + const children = lastValue.children; + if (children !== null) { + const childIndex = lastValue.rewriteChildIndex; + for (let i = childIndex; i < children.length; i++) { + const node = children[i]; + node.type.teardown(queue, node); + } + if (childIndex === 0) { + lastValue.children = null; + } else { + children.length = childIndex; + } + } +} + +/** @type {DiffEntryFunction} */ +function diffMemberEntry( + queue, + /** @type {ElementState} */ element, + /** @type {MemberStateType} */ nodeType, + /** @type {MemberNode} */ node +) { + if (element.members === null) { + element.members = []; + } + const group = element.members; + + const writeIndex = element.rewriteMemberIndex++; + let index; + for (index = writeIndex; index < group.length; index++) { + const item = group[index]; + if (item.type.name === node.type && item.name === node.name) { + break; + } + } + + let old = group[index]; + if (index !== writeIndex) { + group[index] = group[writeIndex]; + } + if (!old) { + old = nodeType.init(element, node.name); + group[writeIndex] = old; + } + old.type.diff(queue, old, node); +} + +/** @type {SoftTeardownFunction} */ +function softTeardownElement(/** @type {ElementState} */ state) { + const { children } = state; + if (children !== null) { + for (let i = 0; i < children.length; i++) { + children[i].type.softTeardown(children[i]); + } + } +} + +/** + * @param {Data} node + * @returns {node is MemberNode} + */ +function isOptions(node) { + return node.type === META_TYPE_NAME && node.name === 'options'; +} + +/** + * @param {Queue} queue + */ +function runQueue(queue) { + const { prepare, changes: apply, post } = queue; + for (let i = 0; i < prepare.length; i++) { + changeViewRender(prepare[i], queue); + } + for (let i = 0; i < apply.length; i += 2) { + apply[i](apply[i + 1]); + } + for (let i = 0; i < post.length; i += 2) { + post[i](post[i + 1]); + } +} + +/** + * @param {ComponentState} componentState + * @param {Queue} queue + */ +function changeViewRender(componentState, queue) { + const newRender = componentState.shape(componentState.content) || null; + if (newRender) { + const metaType = typeMap[newRender.type]; + let lastRender = componentState.rendered; + if (!lastRender || lastRender.shape !== newRender.shape) { + if (lastRender) { + lastRender.type.teardown(queue, lastRender); + } + lastRender = metaType.init(queue, componentState, null, newRender); + componentState.rendered = lastRender; + } + lastRender.type.diff(queue, lastRender, newRender); + } else if (componentState.rendered) { + componentState.rendered.type.teardown(queue, componentState.rendered); + componentState.rendered = null; + } +} + +/** + * @param {MemberState} state + */ +function changeProperty(state) { + state.parent.ref[state.name] = state.value; +} + +/** + * @param {MemberState} state + */ +function removeAttribute(state) { + state.parent.ref.removeAttribute(state.name); +} + +/** + * @param {MemberState} state + */ +function changeAttribute(state) { + state.parent.ref.setAttribute(state.name, state.value); +} + +/** + * @param {TextState} state + */ +function removeText(state) { + state.ref.parentNode.removeChild(state.ref); +} + +/** + * @param {TextState} state + */ +function changeText(state) { + state.ref.textContent = state.content; +} + +/** + * @param {TextState} state + */ +function addText(state) { + state.ref = document.createTextNode(state.content); + state.ref.textContent = state.content; + insertAfterSibling(state); +} + +/** + * @param {NodeState} after + * @returns {NodeState} + */ +function deepestDescendant(after) { + if (after === null) { + return after; + } else if (after.type === FragmentType) { + const { children } = /** @type {FragmentState} */ (after); + if (children !== null) { + return deepestDescendant(children[children.length - 1]); + } + } else if (after.type === ComponentType) { + const { rendered } = /** @type {ComponentState} */ (after); + if (rendered !== null) { + return deepestDescendant(rendered); + } + } + return after; +} + +/** + * @param {ElementState | TextState} element + */ +function insertAfterSibling(element) { + /** @type {NodeState} */ + let state = element; + let after = deepestDescendant(state.after); + do { + if (after === null) { + if (state.parent.type === ElementType) { + break; + } + state = state.parent; + after = state; + } + if (after.type !== ElementType && after.type !== TextType) { + after = deepestDescendant(after.after); + } + } while (after === null || (after.type !== ElementType && after.type !== TextType)); + + if (after !== null) { + const { ref } = /** @type {ElementState | TextState} */ (after); + ref.parentNode.insertBefore(element.ref, ref.nextSibling); + } else { + const { ref } = /** @type {ElementState} */ (state.parent); + if (ref.childNodes.length) { + ref.insertBefore(element.ref, ref.childNodes[0]); + } else { + ref.appendChild(element.ref); + } + } +} + +/** + * @param {ElementState} state + */ +function removeElement(state) { + state.ref.parentNode.removeChild(state.ref); + state.ref = null; +} + +/** + * @param {ElementState} state + */ +function addElement(state) { + state.ref = document.createElement(state.shape); + insertAfterSibling(state); +} + +/** + * @param {MemberState} state + */ +function updateRef(state) { + state.value(state.parent.ref); +} + +/** + * @param {MemberState} state + */ +function unsetRef(state) { + state.value(null); +} + +function shallowEquals(a, b) { + if (a === null || b === null) { + return a === b; + } + for (const key of Object.keys(a)) { + if (key in b) { + if (a[key] !== b[key]) { + return false; + } + } else { + return false; + } + } + for (const key of Object.keys(b)) { + if (!(key in a)) { + return false; + } + } + return true; +} + +/** + * @callback StateAction + * @param {S} state + * @template {State} S + */ + +/** + * @returns {Queue} + */ +function newQueue() { + return { prepare: [], changes: [], post: [] }; +} + +/** + * @param {Queue} queue + * @param {StateAction} fn + * @param {S} state + * @template {State} S + */ +function enqueueChange(queue, fn, state) { + queue.changes.push(fn, state); +} + +/** + * @param {Queue} queue + * @param {StateAction} fn + * @param {S} state + * @template {State} S + */ +function enqueuePost(queue, fn, state) { + queue.post.push(fn, state); +} + +/** + * @typedef Queue + * @property {Array} prepare + * @property {Array} changes + * @property {Array} post + */ + +/** + * @callback DiffEntryFunction + * @param {Queue} queue + * @param {ElementState | FragmentState} parent + * @param {StateType} nodeType + * @param {Data} node + * @returns {void} + */ + +/** + * @callback InitNodeFunction + * @param {Queue} queue + * @param {NodeState} parent + * @param {NodeState | null} after + * @param {NodeNode} node + * @returns {NodeState} + */ + +/** + * @callback DiffFunction + * @param {Queue} queue + * @param {State} state + * @param {Data} node + * @returns {void} + */ + +/** + * @callback TeardownFunction + * @param {Queue} queue + * @param {NodeState} state + * @returns {void} + */ + +/** + * @callback SoftTeardownFunction + * @param {NodeState} state + * @returns {void} + */ + +/** + * @typedef NodeStateTypeGeneric + * @property {Name} name + * @property {DiffEntryFunction} diffEntry + * @property {DiffFunction} diff + * @property {InitNodeFunction} init + * @property {TeardownFunction} teardown + * @property {SoftTeardownFunction} softTeardown + * @template {string | symbol} Name + */ + +/** + * @typedef NodeStateTypeCreate + * @property {Name} name + * @property {(queue: Queue, parent: ElementState | FragmentState, meta: StateType, node: Node) => void} diffEntry + * @property {(queue: Queue, state: S, node: Node) => void} diff + * @property {(queue: Queue, parent: ElementState | FragmentState, after: NodeState, node: Node) => void} init + * @property {(queue: Queue, state: S) => void} teardown + * @property {(state: S) => void} softTeardown + * @template {string | symbol} Name + * @template {State} S + * @template {Data} Node + */ + +/** @typedef {NodeStateTypeGeneric} ElementStateType */ +/** @typedef {NodeStateTypeGeneric} FragmentStateType */ +/** @typedef {NodeStateTypeGeneric} ComponentStateType */ +/** @typedef {NodeStateTypeGeneric} TextStateType */ + +/** @typedef {ElementStateType | FragmentStateType| ComponentStateType | TextStateType} NodeStateType */ + +/** + * @callback InitMemberFunction + * @param {ElementState} parent + * @param {string} name + * @returns {MemberState} + */ + +/** + * @callback TeardownMemberFunction + * @param {Queue} queue + * @param {MemberState} member + * @returns {void} + */ + +/** + * @typedef MemberStateType + * @property {string | symbol} name + * @property {DiffEntryFunction} diffEntry + * @property {DiffFunction} diff + * @property {InitMemberFunction} init + * @property {TeardownMemberFunction} teardown + */ + +/** + * @typedef {NodeStateType | MemberStateType} StateType + */ + +/** + * @typedef ElementState + * @property {ElementStateType} type + * @property {NodeState} parent + * @property {NodeState | null} after + * @property {string} shape + * @property {null} content + * @property {HTMLElement | null} ref + * @property {MemberState[] | null} refHooks + * @property {number} rewriteChildIndex + * @property {NodeState[] | null} children + * @property {number} rewriteMemberIndex + * @property {MemberState[] | null} members + */ + +/** + * @typedef FragmentState + * @property {FragmentStateType} type + * @property {NodeState} parent + * @property {NodeState | null} after + * @property {typeof FRAGMENT_TYPE_NAME} shape + * @property {null} content + * @property {number} rewriteChildIndex + * @property {NodeState[] | null} children + */ + +/** @typedef {ElementState | FragmentState} ParentState */ + +/** + * @typedef ComponentState + * @property {ComponentStateType} type + * @property {NodeState} parent + * @property {NodeState | null} after + * @property {function} shape + * @property {object} content + * @property {NodeState | null} rendered + */ + +/** + * @typedef TextState + * @property {TextStateType} type + * @property {NodeState} parent + * @property {NodeState | null} after + * @property {typeof TEXT_TYPE_NAME} shape + * @property {string} content + * @property {Text | null} ref + */ + +/** + * @typedef {ElementState | FragmentState | ComponentState | TextState} NodeState + */ + +/** + * @typedef MemberState + * @property {MemberStateType} type + * @property {ElementState} parent + * @property {string} name + * @property {*} value + */ + +/** @typedef {NodeState | MemberState} State */ + +/** @typedef {typeof ELEMENT_TYPE_NAME | typeof FRAGMENT_TYPE_NAME | typeof COMPONENT_TYPE_NAME | typeof TEXT_TYPE_NAME} NodeNodeType */ + +/** @typedef {typeof ATTRIBUTE_TYPE_NAME | typeof PROPERTY_TYPE_NAME | typeof REF_TYPE_NAME | typeof META_TYPE_NAME} MemberNodeType */ + +/** + * @typedef ElementNode + * @property {typeof ELEMENT_TYPE_NAME} type + * @property {string} shape + * @property {Data[]} content + */ + +/** + * @typedef FragmentNode + * @property {typeof FRAGMENT_TYPE_NAME} type + * @property {typeof FRAGMENT_TYPE_NAME} shape + * @property {Data[]} content + */ + +/** + * @typedef TextNode + * @property {typeof TEXT_TYPE_NAME} type + * @property {typeof TEXT_TYPE_NAME} shape + * @property {string} content + */ + +/** + * @typedef ComponentNode + * @property {typeof COMPONENT_TYPE_NAME} type + * @property {function} shape + * @property {Data[]} content + */ + +/** + * @typedef {ElementNode | FragmentNode | TextNode | ComponentNode} NodeNode + */ + +/** + * @typedef MemberNode + * @property {MemberNodeType} type + * @property {string} name + * @property {*} value + */ + +/** @typedef {NodeNode | MemberNode} Data */ diff --git a/docs/local-development.md b/docs/local-development.md index 5b494a9d5..33fb84e45 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -17,8 +17,6 @@ yarn install ``` 2. Set up local database using the instructions provided in [database.md](database.md). - - Note: You must run `yarn db-import-tests:dev` after setting up your database to import the latest test harness into - your project. 3. Run the server ``` yarn dev diff --git a/server/migrations/20211116172219-commandSequences.js b/server/migrations/20211116172219-commandSequences.js index 610924c71..85d6243ac 100644 --- a/server/migrations/20211116172219-commandSequences.js +++ b/server/migrations/20211116172219-commandSequences.js @@ -1,6 +1,6 @@ const { omit } = require('lodash'); const { TestPlanVersion } = require('../models'); -const commandList = require('../resources/commandsV1.json'); +const commandList = require('../resources/commands.json'); module.exports = { up: async queryInterface => { diff --git a/server/resolvers/helpers/retrieveCommands.js b/server/resolvers/helpers/retrieveCommands.js index 846a404de..a7c42515b 100644 --- a/server/resolvers/helpers/retrieveCommands.js +++ b/server/resolvers/helpers/retrieveCommands.js @@ -1,4 +1,4 @@ -const commandsV1 = require('../../resources/commandsV1.json'); +const commands = require('../../resources/commands.json'); const commandsV2 = require('../../resources/commandsV2.json'); function findValueByKey(keyMappings, keyToFindText) { @@ -110,7 +110,7 @@ function findValuesByKeys(commandsMapping, keysToFind = []) { } const getCommandV1 = commandId => { - return commandsV1.find(command => command.id === commandId); + return commands.find(command => command.id === commandId); }; const getCommandV2 = commandId => { diff --git a/server/resources/commandsV1.json b/server/resources/commands.json similarity index 100% rename from server/resources/commandsV1.json rename to server/resources/commands.json diff --git a/server/scripts/import-tests/index.js b/server/scripts/import-tests/index.js index b432916ba..17860e9c3 100644 --- a/server/scripts/import-tests/index.js +++ b/server/scripts/import-tests/index.js @@ -74,8 +74,6 @@ const importTestPlanVersions = async transaction => { }); console.log('`npm run build` output', buildOutput.stdout.toString()); - importHarness(); - const { support } = await updateJsons(); const ats = await At.findAll(); @@ -253,41 +251,6 @@ const readDirectoryGitInfo = directoryPath => { return { gitSha, gitMessage, gitCommitDate }; }; -const importHarness = () => { - const sourceFolder = path.resolve(`${testsDirectory}/resources`); - const targetFolder = path.resolve('../', 'client/resources'); - console.info(`Updating harness directory, ${targetFolder} ...`); - fse.rmSync(targetFolder, { recursive: true, force: true }); - - // Copy source folder - console.info('Importing latest harness files ...'); - fse.copySync(sourceFolder, targetFolder, { - filter: src => { - if (fse.lstatSync(src).isDirectory()) { - return true; - } - if (!src.includes('.html')) { - return true; - } - } - }); - - // Copy files - const commandsJson = 'commands.json'; - const supportJson = 'support.json'; - if (fse.existsSync(`${testsDirectory}/${commandsJson}`)) { - fse.copyFileSync( - `${testsDirectory}/${commandsJson}`, - `${targetFolder}/${commandsJson}` - ); - } - fse.copyFileSync( - `${testsDirectory}/${supportJson}`, - `${targetFolder}/${supportJson}` - ); - console.info('Harness files update complete.'); -}; - const getAppUrl = (directoryRelativePath, { gitSha, directoryPath }) => { return path.join( '/', @@ -355,7 +318,7 @@ const updateJsons = async () => { // Write commands for v1 format await fse.writeFile( - path.resolve(__dirname, '../../resources/commandsV1.json'), + path.resolve(__dirname, '../../resources/commands.json'), JSON.stringify(commands, null, 4) );