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)
);