diff --git a/tests/resources/aria-at-harness.mjs b/tests/resources/aria-at-harness.mjs
index a47e1a1d0..2bb3cb44a 100644
--- a/tests/resources/aria-at-harness.mjs
+++ b/tests/resources/aria-at-harness.mjs
@@ -1,20 +1,25 @@
-import {commandsAPI} from './at-commands.mjs';
-
-const UNDESIRABLES = [
+import {commandsAPI} from "./at-commands.mjs";
+import {element, fragment, property, attribute, className, style, focus, render} from "./vrender.mjs";
+import {
+ AssertionResultMap,
+ CommonResultMap,
+ createEnumMap,
+ HasUnexpectedBehaviorMap,
+ TestRun,
+ UserActionMap,
+ userCloseWindow,
+ userOpenWindow,
+ WhitespaceStyleMap,
+} from "./aria-at-test-run.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"
+ "Browser crashed",
];
-const TEST_HTML_OUTLINE = `
-
-
Test cannot be performed due to error(s)!
-
-
-
-`;
const PAGE_STYLES = `
table {
border-collapse: collapse;
@@ -72,362 +77,171 @@ const PAGE_STYLES = `
}
`;
-let behavior;
-let behaviorResults;
-let overallStatus;
+/** @type {string[]} */
const errors = [];
-let testPageUri;
-let testPageWindow;
let showResults = true;
let showSubmitButton = true;
+/** @type {AT} */
let at;
-let commandsData;
+/** @type {CommandsAPI} */
let commapi;
-let support;
+/** @type {Behavior} */
+let behavior;
+/** @type {TestRunState} */
+let firstState;
+/**
+ * @param {Support} newSupport
+ * @param {Commands} newCommandsData
+ */
export function initialize(newSupport, newCommandsData) {
- support = newSupport;
- commandsData = newCommandsData;
- commapi = new commandsAPI(commandsData, support);
+ commapi = new commandsAPI(newCommandsData, newSupport);
// Get the AT under test from the URL search params
// set the showResults flag from the URL search params
- let params = (new URL(document.location)).searchParams;
- at = support.ats[0];
+ let params = new URL(document.location).searchParams;
+ at = newSupport.ats[0];
for (const [key, value] of params) {
- if (key === 'at') {
+ if (key === "at") {
let requestedAT = value;
if (commapi.isKnownAT(requestedAT)) {
at = commapi.isKnownAT(requestedAT);
- }
- 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 {
+ 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.`
+ );
}
}
- if (key === 'showResults') {
- if (value === 'true') {
+ if (key === "showResults") {
+ if (value === "true") {
showResults = true;
- } else if (value === 'false') {
+ } else if (value === "false") {
showResults = false;
}
}
- if (key === 'showSubmitButton') {
- if (value === 'true') {
+ if (key === "showSubmitButton") {
+ if (value === "true") {
showSubmitButton = true;
- } else if (value === 'false') {
+ } else if (value === "false") {
showSubmitButton = false;
}
}
}
}
-function openTestPagePopup() {
- testPageWindow = window.open(testPageUri, '_blank', 'toolbar=0,location=0,menubar=0,width=400,height=400');
-
- document.getElementById('open-test-page').disabled = true;
-
- // If the window is closed, re-enable open popup button
- testPageWindow.onunload = function(event) {
- window.setTimeout(() => {
- if (testPageWindow.closed) {
- testPageWindow = undefined;
- document.getElementById('open-test-page').disabled = false;
- }
- }, 100);
-
- };
-
- executeScriptInTestPage();
-}
-
-function putTestPageWindowIntoCorrectState() {
- // testPageWindow.location.reload(); // TODO: Address the race condition this causes with script execution.
- executeScriptInTestPage();
-}
-
-function executeScriptInTestPage() {
- let setupTestPage = behavior.setupTestPage;
- if (setupTestPage) {
- if (testPageWindow.location.origin !== window.location.origin // make sure the origin is the same, and prevent this from firing on an 'about' page
- || testPageWindow.document.readyState !== 'complete'
- ) {
- window.setTimeout(() => {
- executeScriptInTestPage();
- }, 100);
- return;
- }
-
- scripts[behavior.setupTestPage](testPageWindow.document);
- }
-}
-
+/**
+ * @param {BehaviorJSON} atBehavior
+ */
export function verifyATBehavior(atBehavior) {
// This is temporary until transition is complete from multiple modes to one mode
- let mode = typeof atBehavior.mode === 'string' ? atBehavior.mode : atBehavior.mode[0];
-
- let newBehavior = Object.assign({}, atBehavior, { mode: mode });
- newBehavior.commands = commapi.getATCommands(mode, atBehavior.task, at);
+ let mode = typeof atBehavior.mode === "string" ? atBehavior.mode : atBehavior.mode[0];
+
+ /** @type {Behavior} */
+ behavior = {
+ description: document.title,
+ task: atBehavior.task,
+ mode,
+ modeInstructions: commapi.getModeInstructions(mode, at),
+ appliesTo: atBehavior.applies_to,
+ specificUserInstruction: atBehavior.specific_user_instruction,
+ setupScriptDescription: atBehavior.setup_script_description,
+ setupTestPage: atBehavior.setupTestPage,
+ commands: commapi.getATCommands(mode, atBehavior.task, at),
+ outputAssertions: atBehavior.output_assertions ? atBehavior.output_assertions : [],
+ additionalAssertions: atBehavior.additional_assertions ? atBehavior.additional_assertions[at.key] || [] : [],
+ unexpectedBehaviors: [
+ ...UNEXPECTED_BEHAVIORS.map(content => ({content})),
+ {content: "Other", requireExplanation: true},
+ ],
+ };
- newBehavior.output_assertions = newBehavior.output_assertions ? newBehavior.output_assertions : [];
- newBehavior.additional_assertions = newBehavior.additional_assertions
- ? atBehavior.additional_assertions[at.key] || []
- : [];
- if (!behavior && newBehavior.commands.length) {
- behavior = newBehavior;
+ if (!firstState && behavior.commands.length) {
+ firstState = initializeTestRunState({
+ errors: errors.length ? errors : null,
+ test: behavior,
+ config: {at, displaySubmitButton: showSubmitButton, renderResultsAfterSubmit: showResults},
+ });
} else {
- throw new Error('Test files should only contain one verifyATBehavior call.');
+ throw new Error("Test files should only contain one verifyATBehavior call.");
}
}
export function displayTestPageAndInstructions(testPage) {
- testPageUri = testPage;
-
- if (document.readyState !== 'complete') {
+ if (document.readyState !== "complete") {
window.setTimeout(() => {
displayTestPageAndInstructions(testPage);
}, 100);
return;
}
- document.querySelector('html').setAttribute('lang', 'en');
- document.body.innerHTML = (TEST_HTML_OUTLINE);
- var style = document.createElement('style');
+ document.querySelector("html").setAttribute("lang", "en");
+ var style = document.createElement("style");
style.innerHTML = PAGE_STYLES;
document.head.appendChild(style);
- showUserError();
-
- displayInstructionsForBehaviorTest();
+ displayInstructionsForBehaviorTest(testPage, behavior);
}
-function displayInstructionsForBehaviorTest() {
-
- function getSetupInstructions() {
- let html = '';
- for (let i = 0; i < (userInstructions.length - 1); i++) {
- html += `${userInstructions[i]}`;
- }
- return html;
- }
+/**
+ * @param {string} testPage
+ * @param {Behavior} behavior
+ */
+function displayInstructionsForBehaviorTest(testPage, behavior) {
+ const windowManager = new TestWindow({
+ pageUri: testPage,
+ setupScriptName: behavior.setupTestPage,
+ scripts: typeof scripts === "object" ? scripts : {},
+ 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
- if (testPageWindow) {
- putTestPageWindowIntoCorrectState();
- }
-
- const mode = behavior.mode;
- const modeInstructions = commapi.getModeInstructions(mode, at);
- const userInstructions = behavior.specific_user_instruction.split('|');
- const lastInstruction = userInstructions[userInstructions.length-1];
- const commands = behavior.commands;
- const assertions = behavior.output_assertions.map((a) => a[1]);
- const additionalBehaviorAssertions = behavior.additional_assertions;
- const setupScriptDescription = behavior.setup_script_description ? ` and runs a script that ${behavior.setup_script_description}.` : behavior.setup_script_description;
- // As a hack, special case mode instructions for VoiceOver for macOS until we support modeless tests.
- // ToDo: remove this when resolving issue #194
- const modePhrase = at.name === "VoiceOver for macOS" ? "Describe " : `With ${at.name} in ${mode} mode, describe `;
-
- let instructionsEl = document.getElementById('instructions');
- instructionsEl.innerHTML = `
-
-${modePhrase} how ${at.name} behaves when performing task "${lastInstruction}"
-Test instructions
-
- - Restore default settings for ${at.name}. For help, read Configuring Screen Readers for Testing.
- - Activate the "Open test page" button below, which opens the example to test in a new window${setupScriptDescription}
- - ${modeInstructions}
- ${getSetupInstructions()}
- - Using the following commands, ${lastInstruction}
-
-
-
-Success Criteria
-To pass this test, ${at.name} needs to meet all the following assertions when each specified command is executed:
-
-`;
-
- // Hack to remove mode instructions for VoiceOver for macOS to get us by until we support modeless screen readers.
- // ToDo: remove this when resolving issue #194
- if (at.name === "VoiceOver for macOS") {
- let modeInstructionsEl= document.getElementById('mode-instructions-li');
- modeInstructionsEl.parentNode.removeChild(modeInstructionsEl);
- }
-
- for (let command of commands) {
- let commandEl = document.createElement('li');
- commandEl.innerHTML = `${command}`;
- document.getElementById('at_controls').append(commandEl);
- }
-
- for (let assertion of assertions) {
- let el = document.createElement('li');
- el.innerHTML = `${assertion}`;
- document.getElementById('assertions').append(el);
- }
-
- for (let additional of additionalBehaviorAssertions) {
- let el = document.createElement('li');
- el.innerHTML = `${additional[1]}`;
- document.getElementById('assertions').append(el);
- }
-
- let openButton = document.createElement('button');
- openButton.id = 'open-test-page';
- openButton.innerText = "Open Test Page";
- openButton.addEventListener('click', openTestPagePopup);
- if (testPageWindow) {
- openButton.disabled = true;
- }
- document.getElementById('instructions').append(openButton);
-
- let recordResults = `Record Results
${document.title}
`;
-
- for (let c = 0; c < commands.length; c++) {
- recordResults += ``;
- }
-
- let recordEl = document.getElementById('record-results');
- recordEl.innerHTML = recordResults;
-
- let radios = document.querySelectorAll('input[type="radio"]');
- for (let radio of radios) {
- radio.onclick = handleRadioClick;
- }
-
- let checkboxes = document.querySelectorAll('input[type=checkbox]');
- for (let checkbox of checkboxes) {
- checkbox.onchange = handleUndesirableSelect;
- checkbox.addEventListener('keydown', handleUndesirableKeydown);
- }
-
- let otherUndesirableInput = document.querySelectorAll('.undesirable-other-input');
- for (let otherInput of otherUndesirableInput) {
- otherInput.addEventListener('change', handleOtherUndesirableInput);
- }
-
- if (showSubmitButton) {
- // Submit button
- let el = document.createElement('button');
- el.id = 'submit-results';
- el.innerText = 'Submit Results';
- el.addEventListener('click', submitResult);
- recordEl.append(el);
- }
-
- document.querySelector('#behavior-header').focus();
+ windowManager.prepare();
+
+ const app = new TestRunExport({
+ behavior,
+ hooks: {
+ openTestPage() {
+ windowManager.open();
+ },
+ closeTestPage() {
+ windowManager.close();
+ },
+ postResults: () => postResults(app),
+ },
+ state: firstState,
+ });
+ 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;
- submitResult();
+ 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: testPageUri
- }
- }, '*');
+ window.parent.postMessage(
+ {
+ type: "loaded",
+ data: {
+ testPageUri: windowManager.pageUri,
+ },
+ },
+ "*"
+ );
}
}
@@ -435,7 +249,7 @@ function validateMessage(message, type) {
if (window.location.origin !== message.origin) {
return false;
}
- if (!message.data || typeof message.data !== 'object') {
+ if (!message.data || typeof message.data !== "object") {
return false;
}
if (message.data.type !== type) {
@@ -444,417 +258,850 @@ function validateMessage(message, type) {
return true;
}
-function handleUndesirableSelect(event) {
- let radioId = event.target.id;
- let cmdId, otherSelected;
- if (radioId) {
- cmdId = Number(radioId.split('-')[1]);
- otherSelected = document.querySelector(`#undesirable-${cmdId}-other`);
- if (otherSelected && otherSelected.checked == true) {
- document.querySelector(`#undesirable-${cmdId}-other-input`).disabled = false;
- } else {
- document.querySelector(`#undesirable-${cmdId}-other-input`).disabled = true;
- document.querySelector(`#undesirable-${cmdId}-other-input`).value = '';
- }
- }
-
- // Handle any checkbox selected
- let radioName = event.target.name;
- if (radioName) {
- cmdId = Number(radioName.split('-')[1]);
- document.querySelector(`#problem-${cmdId}-true`).checked = true;
+/**
+ * @param {TestRunExport} app
+ */
+function postResults(app) {
+ // send message to parent if test is loaded in iFrame
+ if (window.parent && window.parent.postMessage) {
+ window.parent.postMessage(
+ {
+ type: "results",
+ data: app.resultsJSON(),
+ },
+ "*"
+ );
}
}
-function handleOtherUndesirableInput(event) {
- let inputId = event.target.id;
- let cmd = inputId.split('-')[1];
+/** @typedef {ConstructorParameters[0]} TestRunOptions */
+/**
+ * @typedef TestRunExportOptions
+ * @property {Behavior} behavior
+ */
- let otherCheckbox = document.querySelector(`#undesirable-${cmd}-other`);
- if (event.target.value) {
- otherCheckbox.checked = true;
- }
- else {
- otherCheckbox.checked = false;
- }
-}
+class TestRunExport extends TestRun {
+ /**
+ * @param {TestRunOptions & TestRunExportOptions} options
+ */
+ constructor({behavior, ...parentOptions}) {
+ super(parentOptions);
-function handleRadioClick(event) {
- let radioId = event.target.id;
- let cmdId = Number(radioId.split('-')[1]);
-
- let markedAs = radioId.split('-')[2];
- let checkboxes = document.querySelectorAll(`.undesirable-${cmdId}`);
- let otherInput = document.querySelector(`#undesirable-${cmdId}-other-input`);
- if (markedAs === 'true') {
- checkboxes[0].tabIndex = 0;
- for (let checkbox of checkboxes) {
- checkbox.disabled = false;
- }
- otherInput.disabled = false;
- } else {
- for (let checkbox of checkboxes) {
- checkbox.disabled = true;
- checkbox.checked = false;
- }
- otherInput.disabled = true;
- otherInput.value = '';
+ this.behavior = behavior;
}
-}
-
-function handleUndesirableKeydown(event) {
- var checkbox = event.currentTarget,
- flag = false;
-
- switch (event.key) {
- case 'Up':
- case 'ArrowUp':
- case 'Left':
- case 'ArrowLeft':
- setFocusToPreviousItem(checkbox);
- flag = true;
- break;
- case 'Down':
- case 'ArrowDown':
- case 'Right':
- case 'ArrowRight':
- setFocusToNextItem(checkbox);
- flag = true;
- break;
-
- default:
- break;
- }
-
- if (flag) {
- event.stopPropagation();
- event.preventDefault();
+ testPageAndResults() {
+ const testPage = this.testPage();
+ if ("results" in testPage) {
+ return {
+ ...testPage,
+ resultsJSON: this.resultsJSON(),
+ };
+ }
+ return {
+ ...testPage,
+ resultsJSON: this.state.currentUserAction === UserActionMap.CLOSE_TEST_WINDOW ? this.resultsJSON() : null,
+ };
}
-}
-
-function setFocusToPreviousItem(checkbox) {
- let cmd = checkbox.parentElement.id.split('-')[1];
- let checkboxNodes = document.querySelectorAll(`#cmd-${cmd}-problem input[type=checkbox]`);
- let checkboxes = Array.from(checkboxNodes);
-
- let checkboxIds = checkboxes.map(c => c.id);
- let index = checkboxIds.indexOf(checkbox.id);
- checkboxNodes[index].tabIndex = -1;
-
- if (index === 0) {
- checkboxNodes[checkboxes.length - 1].tabIndex = 0;
- checkboxNodes[checkboxes.length - 1].focus();
- }
- else {
- checkboxNodes[index - 1].tabIndex = 0;
- checkboxNodes[index - 1].focus();
+ resultsJSON() {
+ return toSubmitResultJSON(this.state, this.behavior);
}
}
-function setFocusToNextItem(checkbox) {
- let cmd = checkbox.parentElement.id.split('-')[1];
- let checkboxNodes = document.querySelectorAll(`#cmd-${cmd}-problem input[type=checkbox]`);
- let checkboxes = Array.from(checkboxNodes);
-
- let checkboxIds = checkboxes.map(c => c.id);
- let index = checkboxIds.indexOf(checkbox.id);
-
- checkboxNodes[index].tabIndex = -1;
- index++;
-
- if (index === checkboxes.length) {
- checkboxNodes[0].tabIndex = 0;
- checkboxNodes[0].focus();
+class TestWindow {
+ /**
+ * @param {object} options
+ * @param {Window | null} [options.window]
+ * @param {string} options.pageUri
+ * @param {string} [options.setupScriptName]
+ * @param {TestWindowHooks} [options.hooks]
+ * @param {SetupScripts} [options.scripts]
+ */
+ constructor({window = null, pageUri, setupScriptName, hooks, scripts = {}}) {
+ /** @type {Window | null} */
+ this.window = window;
+
+ /** @type {string} */
+ this.pageUri = pageUri;
+
+ /** @type {string} */
+ this.setupScriptName = setupScriptName;
+
+ /** @type {TestWindowHooks} */
+ this.hooks = {
+ windowOpened: () => {},
+ windowClosed: () => {},
+ ...hooks,
+ };
+
+ /** @type {SetupScripts} */
+ this.scripts = scripts;
}
- else {
- checkboxNodes[index].tabIndex = 0;
- checkboxNodes[index].focus();
- }
-}
-function validateResults() {
+ open() {
+ this.window = window.open(this.pageUri, "_blank", "toolbar=0,location=0,menubar=0,width=400,height=400");
- let focusEl;
- for (let c = 0; c < behavior.commands.length; c++) {
+ this.hooks.windowOpened();
- // If there is no output recorded, mark the screen reader output as required
- let outputParagraph = document.getElementById(`cmd-${c}-output`);
- let cmdInput = outputParagraph.querySelector('textarea');
- if (!cmdInput.value) {
- focusEl = focusEl || cmdInput;
- outputParagraph.querySelector('.required').classList.add('highlight-required');
- } else {
- outputParagraph.querySelector('.required').classList.remove('highlight-required');
- }
+ // If the window is closed, re-enable open popup button
+ this.window.onunload = () => {
+ window.setTimeout(() => {
+ if (this.window.closed) {
+ this.window = undefined;
- // If "all pass" is selected, remove "required" mark any remaining assertions (because they will
- // all have been marked as passing, now) and move to the next command
+ this.hooks.windowClosed();
+ }
+ }, 100);
+ };
- let numAssertions = document.getElementById(`cmd-${c}`).rows.length - 1;
- let undesirableFieldset = document.getElementById(`cmd-${c}-problem`);
+ this.prepare();
+ }
- // Otherwise, we must go though each assertion and add or remove the "required" mark
- for (let a = 0; a < numAssertions; a++) {
- let selectedRadio = document.querySelector(`input[name="result-${c}-${a}"]:checked`);
- if (!selectedRadio) {
- document.querySelector(`#assertion-${c}-${a} .required`).classList.add('highlight-required');
- focusEl = focusEl || document.getElementById(`pass-${c}-${a}`);
- }
- else {
- document.querySelector(`#assertion-${c}-${a} .required`).classList.remove('highlight-required');
- }
+ prepare() {
+ if (!this.window) {
+ return;
}
-
- // Check that the "unexpected/additional problems" fieldset is filled out
- let problemRadio = document.querySelector(`input[name="problem-${c}"]:checked`);
- let problemSelected = document.querySelectorAll(`.undesirable-${c}:checked`);
- let otherSelected = document.querySelector(`#undesirable-${c}-other:checked`);
- let otherText = document.querySelector(`#undesirable-${c}-other-input`).value;
- if (!problemRadio || (problemRadio.classList.contains('fail') && problemSelected.length === 0 && !otherSelected)) {
- undesirableFieldset.classList.add('highlight-required');
- }
- if (!problemRadio || (problemRadio.classList.contains('fail') && problemSelected.length === 0 && !otherSelected)) {
- document.querySelector(`#cmd-${c}-problem legend .required`).classList.add('highlight-required');
- focusEl = focusEl || document.querySelector(`#cmd-${c}-problem input[type="checkbox"]`);
+ let setupScriptName = this.setupScriptName;
+ if (!setupScriptName) {
+ return;
}
- else if (document.querySelector(`input#problem-${c}-false:checked`) || (problemRadio && problemSelected.length > 0) || (otherSelected && otherText)) {
- document.querySelector(`#cmd-${c}-problem legend .required`).classList.remove('highlight-required');
- undesirableFieldset.classList.remove('highlight-required');
+ 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 (otherSelected) {
- if (!otherText) {
- document.querySelector(`#cmd-${c}-problem .required-other`).classList.add('highlight-required');
- undesirableFieldset.classList.add('highlight-required');
- focusEl = focusEl || document.querySelector(`#undesirable-${c}-other-input`);
- }
- else {
- document.querySelector(`#cmd-${c}-problem .required-other`).classList.remove('highlight-required');
- undesirableFieldset.classList.remove('highlight-required');
- }
- }
+ this.scripts[setupScriptName](this.window.document);
}
- if (focusEl) {
- focusEl.focus();
- return false;
+ close() {
+ if (this.window) {
+ this.window.close();
+ }
}
- return true;
}
+function bind(fn, ...args) {
+ return (...moreArgs) => fn(...args, ...moreArgs);
+}
-function submitResult(event) {
- if (!validateResults()) {
- return;
- }
+/**
+ * @param {object} options
+ * @param {string[] | null} [options.errors]
+ * @param {Behavior} options.test
+ * @param {object} options.config
+ * @param {AT} options.config.at
+ * @param {boolean} [options.config.displaySubmitButton]
+ * @param {boolean} [options.config.renderResultsAfterSubmit]
+ * @returns {TestRunState}
+ */
+function initializeTestRunState({
+ errors = null,
+ test,
+ config: {at, displaySubmitButton = true, renderResultsAfterSubmit = true},
+}) {
+ return {
+ errors,
+ info: {
+ description: test.description,
+ task: test.task,
+ mode: test.mode,
+ modeInstructions: test.modeInstructions,
+ userInstructions: test.specificUserInstruction.split("|"),
+ setupScriptDescription: test.setupScriptDescription,
+ },
+ config: {
+ at,
+ displaySubmitButton,
+ renderResultsAfterSubmit,
+ },
+ currentUserAction: UserActionMap.LOAD_PAGE,
+ openTest: {
+ enabled: true,
+ },
+ commands: test.commands.map(
+ command =>
+ /** @type {TestRunCommand} */ ({
+ description: command,
+ atOutput: {
+ highlightRequired: false,
+ value: "",
+ },
+ assertions: test.outputAssertions.map(assertion => ({
+ description: assertion[1],
+ highlightRequired: false,
+ priority: Number(assertion[0]),
+ result: CommonResultMap.NOT_SET,
+ })),
+ additionalAssertions: test.additionalAssertions.map(assertion => ({
+ description: assertion[1],
+ highlightRequired: false,
+ priority: Number(assertion[0]),
+ result: CommonResultMap.NOT_SET,
+ })),
+ unexpected: {
+ highlightRequired: false,
+ hasUnexpected: HasUnexpectedBehaviorMap.NOT_SET,
+ tabbedBehavior: 0,
+ behaviors: test.unexpectedBehaviors.map(({content: description, requireExplanation}) => ({
+ description,
+ checked: false,
+ more: requireExplanation ? {highlightRequired: false, value: ""} : null,
+ })),
+ },
+ })
+ ),
+ };
+}
- const assertionPriority = {};
- for (let a = 0; a < behavior.output_assertions.length; a++) {
- const assertion = behavior.output_assertions[a];
- assertionPriority[assertion[1]] = assertion[0];
+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 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 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 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 ("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)
+ );
}
+}
- for (let a = 0; a < behavior.additional_assertions.length; a++) {
- const assertion = behavior.additional_assertions[a];
- assertionPriority[assertion[1]] = assertion[0];
- }
+/**
+ * @param {TestPageAndResultsDocument} doc
+ */
+function renderVirtualTestPage(doc) {
+ return fragment(
+ "instructions" in doc
+ ? div(
+ section(
+ id("errors"),
+ style({display: doc.errors ? "block" : "none"}),
+ h2("Test cannot be performed due to error(s)!"),
+ ul(...(doc.errors ? doc.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
+ );
+}
- const summary = {
- 1: {pass: 0, fail: 0},
- 2: {pass: 0, fail: 0},
- unexpectedCount: 0
- };
+/**
+ * @param doc {InstructionDocument}
+ */
+function renderVirtualInstructionDocument(doc) {
+ function compose(...fns) {
+ return around => fns.reduceRight((carry, fn) => fn(carry), around);
+ }
- overallStatus = 'PASS';
+ const map = (ary, el) => ary.map(item => el(item));
- const commandResults = [];
+ return div(
+ instructionHeader(doc.instructions),
- for (let c = 0; c < behavior.commands.length; c++) {
+ instructCommands(doc.instructions.instructions),
- let assertions = [];
- let support = 'FULL';
- let totalAssertions = document.querySelectorAll(`#cmd-${c} tr`).length - 1;
+ instructAssertions(doc.instructions.assertions),
- for (let a = 0; a < totalAssertions; a++) {
- const assertion = document.querySelector(`#assertion-${c}-${a} .assertion`).innerHTML;
- const resultEl = document.querySelector(`input[name="result-${c}-${a}"]:checked`);
- const resultId = resultEl.id;
- const pass = resultEl.classList.contains('pass');
- const result = document.querySelector(`#${resultId}-label`).innerHTML.split('1 assertion fails, then this test meets the all required pass case
- else if (support !== 'FAILING') {
- support = 'ALL REQUIRED';
- }
- }
+ section(...doc.results.commands.map(commandResult)),
- assertions.push(assertionResult);
- }
+ doc.submit ? button(onclick(doc.submit.click), rich(doc.submit.button)) : null
+ );
- const unexpected = [];
- for (let problemEl of document.querySelectorAll(`#cmd-${c}-problem fieldset input:checked`)) {
- support = 'FAILING';
- overallStatus = 'FAIL';
- summary.unexpectedCount++;
- if (problemEl.value === 'Other') {
- unexpected.push(document.querySelector(`#undesirable-${c}-other-input`).value);
- }
- else {
- unexpected.push(problemEl.value);
- }
- }
+ /**
+ * @param {InstructionDocumentResultsHeader} param0
+ */
+ function resultHeader({header, description}) {
+ return fragment(h2(rich(header)), p(rich(description)));
+ }
- commandResults.push({
- command: behavior.commands[c],
- output: document.querySelector(`#speechoutput-${c}`).value,
- unexpected_behaviors: unexpected,
- support,
- assertions
- });
+ /**
+ * @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))
+ )
+ ),
+ table(
+ tr(
+ th(rich(command.assertionsHeader.descriptionHeader)),
+ th(rich(command.assertionsHeader.passHeader)),
+ th(rich(command.assertionsHeader.failHeader))
+ ),
+ ...command.assertions.map(bind(commandResultAssertion, commandIndex))
+ ),
+ ...[command.unexpectedBehaviors].map(bind(commandResultUnexpectedBehavior, commandIndex))
+ );
}
- behaviorResults = {
- name: document.title,
- specific_user_instruction: behavior.specific_user_instruction,
- task: behavior.task,
- commands: commandResults,
- summary
- };
+ /**
+ * @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 =>
+ fragment(
+ input(
+ type("checkbox"),
+ value(failOption.description),
+ id(`${failOption.description}-${commandIndex}`),
+ className([`undesirable-${commandIndex}`]),
+ tabIndex(failOption.tabbable ? "0" : "-1"),
+ 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(forInput(`${failOption.description}-${commandIndex}`), rich(failOption.description)),
+ br(),
+ failOption.more
+ ? div(
+ label(forInput(`${failOption.description}-${commandIndex}-input`), rich(failOption.more.description)),
+ input(
+ type("text"),
+ id(`${failOption.description}-${commandIndex}-input`),
+ name(`${failOption.description}-${commandIndex}-input`),
+ className(["undesirable-other-input"]),
+ disabled(!failOption.more.enabled),
+ value(failOption.more.value),
+ onchange(ev => failOption.more.change(/** @type {HTMLInputElement} */ (ev.currentTarget).value))
+ )
+ )
+ : fragment()
+ )
+ )
+ )
+ );
+ }
- let data = {
- test: document.title,
- details: behaviorResults,
- status: overallStatus
- };
+ /**
+ * @param {number} commandIndex
+ * @param {InstructionDocumentResultsCommandsAssertion} assertion
+ * @param {number} assertionIndex
+ */
+ function commandResultAssertion(commandIndex, assertion, assertionIndex) {
+ return tr(
+ td(rich(assertion.description)),
+ td(
+ ...[assertion.passChoice].map(choice =>
+ radioChoice(`pass-${commandIndex}-${assertionIndex}`, `result-${commandIndex}-${assertionIndex}`, choice)
+ )
+ ),
+ td(
+ ...assertion.failChoices.map((choice, failIndex) =>
+ radioChoice(
+ `${failIndex === 0 ? "missing" : "fail"}-${commandIndex}-${assertionIndex}`,
+ `result-${commandIndex}-${assertionIndex}`,
+ choice
+ )
+ )
+ )
+ );
+ }
- // send message to parent if test is loaded in iFrame
- if (window.parent && window.parent.postMessage) {
- window.parent.postMessage({
- type: 'results',
- data: data
- }, '*');
+ /**
+ * @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))
+ );
}
- endTest();
+ /**
+ * @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))))
+ )
+ );
+ }
- if (showResults) {
- showResultsTable();
+ /**
+ * @param {InstructionDocumentInstructions} param0
+ */
+ function instructionHeader({header, description}) {
+ return fragment(
+ h1(id("behavior-header"), tabIndex("0"), focus(header.focus), rich(header.header)),
+ p(rich(description))
+ );
}
- appendJSONResults(data);
+ /**
+ * @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)))));
+ }
+}
-function showResultsTable() {
- let resulthtml = `${document.title}
`;
-
- resulthtml += `
-
- Command |
- Support |
- Details |
-
- `;
-
- for (let command of behaviorResults.commands) {
-
- let passingAssertions = '';
- let failingAssertions = '';
- for (let assertion of command.assertions) {
- if (assertion.pass) {
- passingAssertions += `${assertion.assertion}`;
- }
- if (assertion.fail) {
- failingAssertions += `${assertion.assertion}`;
- }
- }
- let unexpectedBehaviors = '';
- for (let unexpected of command.unexpected_behaviors) {
- unexpectedBehaviors += `${unexpected}`;
- }
- passingAssertions = passingAssertions === '' ? 'No passing assertions.' : passingAssertions;
- failingAssertions = failingAssertions === '' ? 'No failing assertions.' : failingAssertions;
- unexpectedBehaviors = unexpectedBehaviors === '' ? 'No unexpect behaviors.' : unexpectedBehaviors;
-
-
- resulthtml+= `
-
- ${command.command} |
- ${command.support} |
-
- ${at.name} output: "${command.output.replace(/(?:\r\n|\r|\n)/g, ' ')}"
-
-
- Unexpected Behavior:
-
- ${unexpectedBehaviors}
-
-
- |
-
-`;
-
- }
+/**
+ * @typedef SubmitResultDetailsCommandsAssertionsPass
+ * @property {string} assertion
+ * @property {string} priority
+ * @property {EnumValues} pass
+ */
+
+const AssertionPassJSONMap = createEnumMap({
+ GOOD_OUTPUT: "Good Output",
+});
+
+/**
+ * @typedef SubmitResultDetailsCommandsAssertionsFail
+ * @property {string} assertion
+ * @property {string} priority
+ * @property {EnumValues} fail
+ */
+
+const AssertionFailJSONMap = createEnumMap({
+ NO_OUTPUT: "No Output",
+ INCORRECT_OUTPUT: "Incorrect Output",
+ NO_SUPPORT: "No Support",
+});
+
+/** @typedef {SubmitResultDetailsCommandsAssertionsPass | SubmitResultDetailsCommandsAssertionsFail} SubmitResultAssertionsJSON */
+
+/** @typedef {EnumValues} CommandSupportJSON */
+
+const CommandSupportJSONMap = createEnumMap({
+ FULL: "FULL",
+ FAILING: "FAILING",
+ ALL_REQUIRED: "ALL REQUIRED",
+});
+
+/**
+ * @typedef {EnumValues} SubmitResultStatusJSON
+ */
+
+const StatusJSONMap = createEnumMap({
+ PASS: "PASS",
+ FAIL: "FAIL",
+});
+
+/**
+ * @param {TestRunState} state
+ * @param {Behavior} behavior
+ * @returns {SubmitResultJSON}
+ */
+function toSubmitResultJSON(state, 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,
+ };
- resulthtml += `
`;
+ 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;
+ }
- document.body.innerHTML = resulthtml;
- document.querySelector('#overallstatus').innerHTML = `Test result: ${overallStatus}`;
-}
+ /**
+ * @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
+ );
+ }
-function endTest() {
- if (typeof testPageWindow !== 'undefined') {
- testPageWindow.close();
+ /**
+ * @param {(behavior: TestRunUnexpected) => boolean} filter
+ * @returns {number}
+ */
+ function countUnexpectedBehaviors(filter) {
+ return state.commands.reduce((carry, command) => carry + command.unexpected.behaviors.filter(filter).length, 0);
}
-}
-function showUserError() {
- if (errors.length) {
- document.getElementById('errors').style.display = "block";
- let errorListEl = document.querySelector('#errors ul');
- for (let error of errors) {
- let errorMsgEl = document.createElement('li');
- errorMsgEl.innerText = error;
- errorListEl.append(errorMsgEl);
- }
+ /**
+ * @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,
+ };
}
}
-function appendJSONResults(data) {
- var results_element = document.createElement("script");
- results_element.type = "text/json";
- results_element.id = "__ariaatharness__results__";
- results_element.textContent = JSON.stringify(data);
-
- document.body.appendChild(results_element);
-}
+/**
+ * @typedef AT
+ * @property {string} name
+ * @property {string} key
+ */
+
+/**
+ * @typedef Support
+ * @property {AT[]} ats
+ * @property {{system: string[]}} applies_to
+ * @property {{directory: string, name: string}[]} examples
+ */
+
+/**
+ * @typedef {{[mode in ATMode]: {[atName: string]: string;};}} CommandsAPI_ModeInstructions
+ */
+
+/**
+ * @typedef {([string] | [string, string])[]} CommandAT
+ */
+
+/**
+ * @typedef {{[atMode: string]: CommandAT}} CommandMode
+ */
+
+/**
+ * @typedef Command
+ * @property {CommandMode} [reading]
+ * @property {CommandMode} [interaction]
+ */
+
+/**
+ * @typedef {{[commandDescription: string]: Command}} Commands
+ */
+
+/**
+ * @callback CommandsAPI_getATCommands
+ * @param {ATMode} mode
+ * @param {string} task
+ * @param {AT} assistiveTech
+ * @returns {string[]}
+ */
+
+/** @typedef {"reading" | "interaction"} ATMode */
+
+/**
+ * @callback CommandsAPI_getModeInstructions
+ * @param {ATMode} mode
+ * @param {AT} assistiveTech
+ * @returns {string}
+ */
+
+/**
+ * @callback CommandsAPI_isKnownAT
+ * @param {string} assistiveTech
+ * @returns {AT}
+ */
+
+/**
+ * @typedef CommandsAPI
+ * @property {Commands} AT_COMMAND_MAP
+ * @property {CommandsAPI_ModeInstructions} MODE_INSTRUCTIONS
+ * @property {Support} support
+ * @property {CommandsAPI_getATCommands} getATCommands
+ * @property {CommandsAPI_getModeInstructions} getModeInstructions
+ * @property {CommandsAPI_isKnownAT} isKnownAT
+ */
+
+/**
+ * @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][]} [output_assertions]
+ * @property {{[atKey: string]: [number, string][]}} [additional_assertions]
+ */
+
+/**
+ * @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 {[string, string][]} outputAssertions
+ * @property {[number, string][]} additionalAssertions
+ * @property {object[]} unexpectedBehaviors
+ * @property {string} unexpectedBehaviors[].content
+ * @property {boolean} [unexpectedBehaviors[].requireExplanation]
+ */
+
+/**
+ * @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 TestWindowHooks
+ * @property {() => void} windowOpened
+ * @property {() => void} windowClosed
+ */
+
+/** @typedef {{[key: string]: (document: Document) => void}} SetupScripts */
+
+/**
+ * @typedef ResultJSONDocument
+ * @property {SubmitResultJSON | null} resultsJSON
+ */
+
+/**
+ * @typedef {TestPageDocument & ResultJSONDocument} TestPageAndResultsDocument
+ */
+
+/**
+ * @typedef {import('./aria-at-test-run.js').EnumValues} EnumValues
+ * @template T
+ */
+
+/** @typedef {import('./aria-at-test-run.js').TestRunState} TestRunState */
+/** @typedef {import('./aria-at-test-run.js').TestRunAssertion} TestRunAssertion */
+/** @typedef {import('./aria-at-test-run.js').TestRunAdditionalAssertion} TestRunAdditionalAssertion */
+/** @typedef {import('./aria-at-test-run.js').TestRunCommand} TestRunCommand */
+/** @typedef {import("./aria-at-test-run.js").TestRunUnexpectedBehavior} TestRunUnexpected */
+
+/** @typedef {import('./aria-at-test-run.js').Description} Description */
+
+/** @typedef {import('./aria-at-test-run.js').TestPageDocument} TestPageDocument */
+
+/** @typedef {import('./aria-at-test-run.js').InstructionDocument} InstructionDocument */
+/** @typedef {import('./aria-at-test-run.js').InstructionDocumentInstructions} InstructionDocumentInstructions */
+/** @typedef {import('./aria-at-test-run.js').InstructionDocumentInstructionsAssertions} InstructionDocumentInstructionsAssertions */
+/** @typedef {import('./aria-at-test-run.js').InstructionDocumentResultsHeader} InstructionDocumentResultsHeader */
+/** @typedef {import('./aria-at-test-run.js').InstructionDocumentResultsCommand} InstructionDocumentResultsCommand */
+/** @typedef {import('./aria-at-test-run.js').InstructionDocumentResultsCommandsUnexpected} InstructionDocumentResultsCommandsUnexpected */
+/** @typedef {import("./aria-at-test-run.js").InstructionDocumentResultsCommandsAssertion} InstructionDocumentResultsCommandsAssertion */
+/** @typedef {import("./aria-at-test-run.js").InstructionDocumentAssertionChoice} InstructionDocumentAssertionChoice */
+/** @typedef {import("./aria-at-test-run.js").InstructionDocumentInstructionsInstructions} InstructionDocumentInstructionsInstructions */
+
+/** @typedef {import('./aria-at-test-run.js').ResultsTableDocument} ResultsTableDocument */
diff --git a/tests/resources/aria-at-test-run.mjs b/tests/resources/aria-at-test-run.mjs
new file mode 100644
index 000000000..44624864d
--- /dev/null
+++ b/tests/resources/aria-at-test-run.mjs
@@ -0,0 +1,1252 @@
+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),
+ 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. ToDo: remove this when resolving issue #194
+ const modePhrase =
+ resultState.config.at.name === "VoiceOver for macOS"
+ ? "Describe "
+ : `With ${resultState.config.at.name} in ${mode} mode, describe `;
+
+ const commands = resultState.commands.map(({description}) => description);
+ 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;
+ }
+
+ return {
+ errors: {
+ visible: false,
+ header: "",
+ 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: [modeInstructions, ...userInstructions],
+ commands: {
+ description: `Using the following commands, ${lastInstruction}`,
+ commands,
+ },
+ },
+ 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 {T} resultAssertion
+ * @param {T["result"]} resultValue
+ * @param {Omit} partialChoice
+ * @returns {InstructionDocumentAssertionChoice}
+ * @template {TestRunAssertion | TestRunAdditionalAssertion} T
+ */
+ function assertionChoice(resultAssertion, resultValue, partialChoice) {
+ return {
+ ...partialChoice,
+ checked: resultAssertion.result === resultValue,
+ focus:
+ resultState.currentUserAction === "validateResults" &&
+ resultAssertion.highlightRequired &&
+ focusFirstRequired(),
+ };
+ }
+
+ /**
+ * @param {string} command
+ * @param {number} commandIndex
+ * @returns {InstructionDocumentResultsCommand}
+ */
+ function commandResult(command, commandIndex) {
+ const resultStateCommand = resultState.commands[commandIndex];
+ const resultUnexpectedBehavior = resultStateCommand.unexpected;
+ return {
+ header: `After '${command}'`,
+ 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: "",
+ passHeader: "",
+ failHeader: "",
+ },
+ assertions: [
+ ...assertions.map(bind(assertionResult, commandIndex)),
+ ...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,
+ 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}),
+ keydown: key => {
+ const increment = keyToFocusIncrement(key);
+ if (increment) {
+ hooks.focusCommandUnexpectedBehavior({commandIndex, unexpectedIndex, increment});
+ return true;
+ }
+ return false;
+ },
+ more: behavior.more
+ ? {
+ description: /** @type {Description[]} */ ([
+ `If "other" selected, explain`,
+ {
+ 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}),
+ }
+ : null,
+ };
+ }),
+ },
+ },
+ },
+ };
+ }
+
+ /**
+ * @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,
+ {
+ required: true,
+ highlightRequired: resultAssertion.highlightRequired,
+ description: "(required: mark output)",
+ },
+ ],
+ passChoice: assertionChoice(resultAssertion, CommonResultMap.PASS, {
+ label: [
+ `Good Output `,
+ {
+ offScreen: true,
+ description: "for assertion",
+ },
+ ],
+ click: () => hooks.setCommandAssertion({commandIndex, assertionIndex, result: CommonResultMap.PASS}),
+ }),
+ failChoices: [
+ assertionChoice(resultAssertion, AssertionResultMap.FAIL_MISSING, {
+ label: [
+ `No Output `,
+ {
+ offScreen: true,
+ description: "for assertion",
+ },
+ ],
+ click: () =>
+ hooks.setCommandAssertion({commandIndex, assertionIndex, result: AssertionResultMap.FAIL_MISSING}),
+ }),
+ assertionChoice(resultAssertion, AssertionResultMap.FAIL_INCORRECT, {
+ label: [
+ `Incorrect Output `,
+ {
+ offScreen: true,
+ description: "for assertion",
+ },
+ ],
+ click: () =>
+ hooks.setCommandAssertion({commandIndex, assertionIndex, result: AssertionResultMap.FAIL_MISSING}),
+ }),
+ ],
+ });
+ }
+
+ /**
+ * @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,
+ {
+ required: true,
+ highlightRequired: resultAdditionalAssertion.highlightRequired,
+ description: "(required: mark support)",
+ },
+ ],
+ passChoice: assertionChoice(resultAdditionalAssertion, AdditionalAssertionResultMap.PASS, {
+ label: ["Good Support ", {offScreen: true, description: "for assertion"}],
+ click: () =>
+ hooks.setCommandAdditionalAssertion({
+ commandIndex,
+ additionalAssertionIndex: assertionIndex,
+ result: AdditionalAssertionResultMap.PASS,
+ }),
+ }),
+ failChoices: [
+ assertionChoice(resultAdditionalAssertion, AdditionalAssertionResultMap.FAIL_SUPPORT, {
+ label: ["No Support ", {offScreen: true, description: "for assertion"}],
+ click: () =>
+ hooks.setCommandAdditionalAssertion({
+ commandIndex,
+ additionalAssertionIndex: assertionIndex,
+ result: AdditionalAssertionResultMap.FAIL_SUPPORT,
+ }),
+ }),
+ ],
+ });
+ }
+}
+
+/**
+ * @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",
+});
+
+/**
+ * @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.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),
+ };
+ }
+ return {
+ errors: null,
+ instructions: instructionDocument(state, hooks),
+ };
+}
+
+/**
+ * @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.assertions.some(assertion => assertion.result === CommonResultMap.NOT_SET) ||
+ command.additionalAssertions.some(assertion => assertion.result === CommonResultMap.NOT_SET) ||
+ 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}) =>
+ [...assertions, ...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 allAssertions = [...command.assertions, ...command.additionalAssertions];
+
+ let passingAssertions = ["No passing assertions."];
+ let failingAssertions = ["No failing assertions."];
+ let unexpectedBehaviors = ["No unexpect behaviors."];
+
+ 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}) => (more ? more.value : description));
+ }
+
+ 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: "Unexpected Behavior",
+ 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(),
+ },
+ assertions: command.assertions.map(assertion => ({
+ ...assertion,
+ highlightRequired: assertion.result === CommonResultMap.NOT_SET,
+ })),
+ additionalAssertions: command.additionalAssertions.map(assertion => ({
+ ...assertion,
+ highlightRequired: assertion.result === CommonResultMap.NOT_SET,
+ })),
+ 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 {InstructionDocumentAssertionChoice} passChoice
+ * @property {InstructionDocumentAssertionChoice[]} failChoices
+ */
+
+/**
+ * @typedef InstructionDocumentResultsCommandsAssertionsHeader
+ * @property {Description} descriptionHeader
+ * @property {Description} passHeader
+ * @property {Description} failHeader
+ */
+
+/**
+ * @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, 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
+ */
+
+/**
+ * @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
+ * TestPageDocuement) 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/tests/resources/vrender.mjs b/tests/resources/vrender.mjs
new file mode 100644
index 000000000..270c44983
--- /dev/null
+++ b/tests/resources/vrender.mjs
@@ -0,0 +1,980 @@
+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, node) {
+ 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 */