Process Test Directory v1 DeDupe PR

Created Diff never expires
493 removals
Words removed1277
Total words2467
Words removed (%)51.76
811 lines
405 additions
Words added1096
Total words2286
Words added (%)47.94
732 lines
/// <reference path="../../types/aria-at-csv.js" />
/// <reference path="../../../types/aria-at-csv.js" />
/// <reference path="../../types/aria-at-parsed.js" />
/// <reference path="../../../types/aria-at-parsed.js" />
/// <reference path="../../types/aria-at-validated.js" />
/// <reference path="../../../types/aria-at-validated.js" />
/// <reference path="../../types/aria-at-file.js" />
/// <reference path="../../../types/aria-at-file.js" />
/// <reference path="../util/file-record-types.js" />
/// <reference path="../../util/file-record-types.js" />


'use strict';
'use strict';


const path = require('path');
const path = require('path');
const { Readable } = require('stream');
const {
types: { isArrayBufferView, isArrayBuffer },
} = require('util');

const csv = require('csv-parser');
const beautify = require('json-beautify');
const beautify = require('json-beautify');


const { validate } = require('../util/error');
const { validate } = require('../../util/error');
const { reindent } = require('../util/lines');
const { Queryable } = require('../../util/queryable');
const { Queryable } = require('../util/queryable');
const { FileRecordChain } = require('../../util/file-record-chain');
const { FileRecordChain } = require('../util/file-record-chain');

const { parseSupport } = require('./parse-support');
const { parseTestCSVRow } = require('./parse-test-csv-row');
const { parseCommandCSVRow } = require('./parse-command-csv-row');
const { createCommandTuplesATModeTaskLookup } = require('./command-tuples-at-mode-task-lookup');


const { renderHTML: renderCollectedTestHtml } = require('./templates/collected-test.html');
const { parseSupport } = require('../parse-support');
const { createExampleScriptsTemplate } = require('./example-scripts-template');
const { parseTestCSVRow } = require('../parse-test-csv-row');
const { parseCommandCSVRow } = require('../parse-command-csv-row');
const { createCommandTuplesATModeTaskLookup } = require('../command-tuples-at-mode-task-lookup');
const { createExampleScriptsTemplate } = require('../example-scripts-template');
const {
Utils,
createExampleScriptedFile,
createScriptFiles,
getSetupScriptDescription,
loadScriptsSource,
mapDefined,
toBuffer,
} = require('./utils');


/**
/**
* @param {object} config
* @param {object} config
* @param {string} config.directory - path to directory of data to be used to generate test
* @param {string} config.directory - path to directory of data to be used to generate test
* @param {string} config.testsDirectory - path to tests directory. Defaults to '<root>/tests'
* @param {string} config.testsDirectory - path to tests directory. Defaults to '<root>/tests'
* @param {string} config.buildOutputDirectory - path to build directory. Defaults to '<root>/build'
* @param {string} config.buildOutputDirectory - path to build directory. Defaults to '<root>/build'
* @param {object} config.args
* @param {object} config.args
*/
*/
const processTestDirectory = async config => {
const processTestDirectory = async config => {
const directory = config?.directory;
const directory = config?.directory;
const args = config?.args || {};
const args = config?.args || {};

const TEST_MODE = !!args.testMode;
let VERBOSE_CHECK = false;
let VALIDATE_CHECK = false;
let TEST_MODE = false;

let suppressedMessages = 0;

/**
* @param {string} message - message to be logged
* @param {object} [options]
* @param {boolean} [options.severe=false] - indicates whether the message should be viewed as an error or not
* @param {boolean} [options.force=false] - indicates whether this message should be forced to be outputted regardless of verbosity level
*/
const log = (message, { severe = false, force = false } = {}) => {
if (VERBOSE_CHECK || force) {
if (severe) console.error(message);
else console.log(message);
} else {
// Output no logs
suppressedMessages += 1; // counter to indicate how many messages were hidden
}
};

/**
* @param {string} message - error message
*/
log.warning = message => log(message, { severe: true, force: true });

/**
* Log error then exit the process.
* @param {string} message - error message
*/
log.error = message => {
log.warning(message);
process.exit(1);
};


// setup from arguments passed to npm script or test runner
// setup from arguments passed to npm script or test runner
VERBOSE_CHECK = !!args.verbose;
const utils = new Utils({
VALIDATE_CHECK = !!args.validate;
testFormatVersion: 1,
TEST_MODE = !!args.testMode;
isVerbose: !!args.verbose,
isValidate: !!args.validate,
});


const validModes = ['reading', 'interaction', 'item'];
const { log, VALIDATE_CHECK } = utils.logger;


/** Name of the test plan. */
/** Name of the test plan. */
const testPlanName = path.basename(directory);
const testPlanName = path.basename(directory);


// @param rootDirectory is dependent on this file not moving from the `lib/data` folder
// @param rootDirectory is dependent on this file not moving from the `lib/data` folder
const cwd = path.dirname(__filename);
const cwd = path.dirname(__filename);
const rootDirectory = path.join(cwd, '../..');
const rootDirectory = path.join(cwd, '../../..');


const testsDirectory = config?.testsDirectory ?? path.join(rootDirectory, 'tests');
const testsDirectory = config?.testsDirectory ?? path.join(rootDirectory, 'tests');
const testPlanDirectory = path.join(testsDirectory, testPlanName);
const testPlanDirectory = path.join(testsDirectory, testPlanName);


const resourcesDirectory = path.join(rootDirectory, 'tests', 'resources');
const resourcesDirectory = path.join(rootDirectory, 'tests', 'resources');
const supportFilePath = path.join(rootDirectory, 'tests', 'support.json');
const supportJsonFilePath = path.join(rootDirectory, 'tests', 'support.json');
const atCommandsCsvFilePath = path.join(testPlanDirectory, 'data', 'commands.csv');
const atCommandsCsvFilePath = path.join(testPlanDirectory, 'data', 'commands.csv');
const testsCsvFilePath = path.join(testPlanDirectory, 'data', 'testsV1.csv');
const testsCsvFilePath = path.join(testPlanDirectory, 'data', 'testsV1.csv');
const referencesCsvFilePath = path.join(testPlanDirectory, 'data', 'referencesV1.csv');
const referencesCsvFilePath = path.join(testPlanDirectory, 'data', 'referencesV1.csv');


// build output folders and file paths setup
// build output folders and file paths setup
const buildDirectory = config?.buildOutputDirectory ?? path.join(rootDirectory, 'build');
const buildDirectory = config?.buildOutputDirectory ?? path.join(rootDirectory, 'build');
const buildTestsDirectory = path.join(buildDirectory, 'tests');
const buildTestsDirectory = path.join(buildDirectory, 'tests');
const testPlanBuildDirectory = path.join(buildTestsDirectory, testPlanName);
const testPlanBuildDirectory = path.join(buildTestsDirectory, testPlanName);
const indexFileBuildOutputPath = path.join(testPlanBuildDirectory, 'index.html');
const indexFileBuildOutputPath = path.join(testPlanBuildDirectory, 'index.html');


let backupTestsCsvFile, backupReferencesCsvFile;
utils.testPlanName = testPlanName;
utils.testPlanDirectory = testPlanDirectory;


const existingBuildPromise = FileRecordChain.read(buildDirectory, {
const existingBuildPromise = FileRecordChain.read(buildDirectory, {
glob: [
glob: [
'',
'',
'tests',
'tests',
`tests/${testPlanName}`,
`tests/${testPlanName}`,
`tests/${testPlanName}/**`,
`tests/${testPlanName}/**`,
'tests/resources',
'tests/resources',
'tests/resources/*',
'tests/resources/*',
'tests/support.json',
'tests/support.json',
].join(','),
].join(','),
});
});


const [testPlanRecord, resourcesOriginalRecord, supportRecord] = await Promise.all(
const [testPlanRecord, resourcesOriginalRecord, supportJsonRecord] = await Promise.all(
[testPlanDirectory, resourcesDirectory, supportFilePath].map(filepath =>
[testPlanDirectory, resourcesDirectory, supportJsonFilePath].map(filepath =>
FileRecordChain.read(filepath)
FileRecordChain.read(filepath)
)
)
);
);

const scriptsRecord = testPlanRecord.find('data/js');
const scriptsRecord = testPlanRecord.find('data/js');
const resourcesRecord = resourcesOriginalRecord.filter({ glob: '{aria-at-*,keys,vrender}.mjs' });
const resourcesRecord = resourcesOriginalRecord.filter({ glob: '{aria-at-*,keys,vrender}.mjs' });


utils.testPlanRecord = testPlanRecord;
utils.resourcesRecord = resourcesRecord;
utils.scriptsRecord = scriptsRecord;
utils.supportJsonRecord = supportJsonRecord;

// Filter out reference html files with inline scripts. Files that are not
// Filter out reference html files with inline scripts. Files that are not
// regenerated will be removed from the filesystem.
// regenerated will be removed from the filesystem.
const testPlanUpdate = await testPlanRecord.walk(record => {
const testPlanUpdate = await utils.getTestPlanUpdateRecord();
if (record.entries) {
const newBuild = utils.getNewBuild(testPlanUpdate);
return {
...record,
entries: record.entries.filter(record => !isScriptedReferenceRecord(record)),
};
}
return record;
});

const newBuild = new FileRecordChain({
entries: [
{
name: 'tests',
entries: [
{
name: testPlanName,
entries: testPlanUpdate.filter({ glob: 'reference{,/**}' }).record.entries,
},
{ name: 'resources', ...resourcesRecord.record },
{ name: 'support.json', ...supportRecord.record },
],
},
],
});

const keyDefs = {};
const supportJson = JSON.parse(supportRecord.text);


let allATKeys = supportJson.ats.map(({ key }) => key);
const supportJson = JSON.parse(supportJsonRecord.text);
let allATNames = supportJson.ats.map(({ name }) => name);


const validAppliesTo = ['Screen Readers', 'Desktop Screen Readers'].concat(allATKeys);
const allAtKeys = supportJson.ats.map(({ key }) => key);
const allAtNames = supportJson.ats.map(({ name }) => name);


if (!testPlanRecord.isDirectory()) {
const validModes = ['reading', 'interaction', 'item'];
log.error(`The test directory '${testPlanDirectory}' does not exist. Check the path to tests.`);
const validAppliesTo = ['Screen Readers', 'Desktop Screen Readers'].concat(allAtKeys);
}


if (!testPlanRecord.find('data/commands.csv').isFile()) {
utils.checkForMissingPath(testPlanRecord, 'directory', { path: testPlanDirectory });
log.error(
utils.checkForMissingPath(testPlanRecord, 'file', {
`The at-commands.csv file does not exist. Please create '${atCommandsCsvFilePath}' file.`
path: atCommandsCsvFilePath,
);
fileName: 'commands.csv',
}
shortenedFilePath: 'data/commands.csv',
});


// To handle in the case where a v1 test plan hasn't yet been updated
let backupTestsCsvFile, backupReferencesCsvFile;
if (!testPlanRecord.find('data/testsV1.csv').isFile()) {
if (!testPlanRecord.find('data/testsV1.csv').isFile()) {
// Check if original file can be processed
// Check if original file can be processed
if (!testPlanRecord.find('data/tests.csv').isFile()) {
if (!testPlanRecord.find('data/tests.csv').isFile()) {
log.error(`The testsV1.csv file does not exist. Please create '${testsCsvFilePath}' file.`);
log.error(`The testsV1.csv file does not exist. Please create '${testsCsvFilePath}' file.`);
} else {
} else {
backupTestsCsvFile = 'data/tests.csv';
backupTestsCsvFile = 'data/tests.csv';
}
}
}
}


if (!testPlanRecord.find('data/referencesV1.csv').isFile()) {
if (!testPlanRecord.find('data/referencesV1.csv').isFile()) {
// Check if original file can be processed
// Check if original file can be processed
if (!testPlanRecord.find('data/references.csv').isFile()) {
if (!testPlanRecord.find('data/references.csv').isFile()) {
log.error(
log.error(
`The referencesV1.csv file does not exist. Please create '${referencesCsvFilePath}' file.`
`The referencesV1.csv file does not exist. Please create '${referencesCsvFilePath}' file.`
);
);
} else {
} else {
backupReferencesCsvFile = 'data/references.csv';
backupReferencesCsvFile = 'data/references.csv';
}
}
}
}


// get Keys that are defined
// Common regex patterns found in *.csvs
try {
const numericKeyFormat = /^_(\d+)$/;
// read contents of the file
const keys = resourcesRecord.find('keys.mjs').text;


// split the contents by new line
const validReferenceKeys = /^(?:refId|value)$/;
const lines = keys.split(/\r?\n/);
function validateReferencesKeys(row) {
for (const key of Object.keys(row)) {
if (numericKeyFormat.test(key)) {
throw new Error(`Column found without header row, ${+key.substring(1) + 1}`);
} else if (!validReferenceKeys.test(key)) {
throw new Error(`Unknown references.csv key: ${key} - check header row?`);
}
}
Text moved from lines 656-660
if (typeof row.refId !== 'string' || typeof row.value !== 'string') {
throw new Error('Row missing refId or value');
}
return row;
}


Text moved with changes from lines 663-686 (98.5% similarity)
// print all lines
const validCommandKeys = /^(?:testId|task|mode|at|command[A-Z])$/;
lines.forEach(line => {
function validateCommandsKeys(row) {
let parts1 = line.split(' ');
// example header:
let parts2 = line.split('"');
// testId,task,mode,at,commandA,commandB,commandC,commandD,commandE,commandF
for (const key of Object.keys(row)) {
if (numericKeyFormat.test(key)) {
throw new Error(`Column found without header row, ${+key.substring(1) + 1}`);
} else if (!validCommandKeys.test(key)) {
throw new Error(`Unknown commands.csv key: ${key} - check header row?`);
}
}
if (
!(
row.testId?.length &&
row.task?.length &&
row.mode?.length &&
row.at?.length &&
row.commandA?.length
)
) {
throw new Error('Missing one of required testId, task, mode, at, commandA');
}
return row;
}


Text moved from lines 688-697
if (parts1.length > 3) {
const validTestsKeys =
let code = parts1[2].trim();
/^(?:testId|title|appliesTo|mode|task|setupScript|setupScriptDescription|refs|instructions|assertion(?:[1-9]|[1-2][0-9]|30))$/;
keyDefs[code] = parts2[1].trim();
function validateTestsKeys(row) {
// example header:
// testId,title,appliesTo,mode,task,setupScript,setupScriptDescription,refs,instructions,assertion1,assertion2,assertion3,assertion4,assertion5,assertion6,assertion7
for (const key of Object.keys(row)) {
if (numericKeyFormat.test(key)) {
throw new Error(`Column found without header row, ${+key.substring(1) + 1}`);
} else if (!validTestsKeys.test(key)) {
throw new Error(`Unknown testsV1.csv key: ${key} - check header row?`);
}
}
Text moved from lines 699-711
});
}
} catch (err) {
if (
log.warning(err);
!(
row.testId?.length &&
row.title?.length &&
row.appliesTo?.length &&
row.mode?.length &&
row.task?.length
)
) {
throw new Error('Missing one of required testId, title, appliesTo, mode, task');
}
return row;
}
}


const [testsCsv, atCommandsCsv, referencesCsv] = await Promise.all([
utils.readCSVFile(backupTestsCsvFile || 'data/testsV1.csv', validateTestsKeys),
utils.readCSVFile('data/commands.csv', validateCommandsKeys),
utils.readCSVFile(backupReferencesCsvFile || 'data/referencesV1.csv', validateReferencesKeys),
]);

function cleanTask(task) {
function cleanTask(task) {
return task.replace(/'/g, '').replace(/;/g, '').trim().toLowerCase();
return task.replace(/'/g, '').replace(/;/g, '').trim().toLowerCase();
}
}


/**
/**
* Create Test File
* Create Test File
* @param {AriaATCSV.Test} test
* @param {AriaATCSV.Test} test
* @param refs
* @param refs
* @param commands
* @param commands
* @param {object} options
* @param {function(filePath: string, content: any, encoding: string)} options.emitFile
* @param {FileRecordChain} options.scriptsRecord
* @param {Queryable<T>} options.exampleScriptedFilesQueryable
* @returns {(string|*[])[]}
* @returns {(string|*[])[]}
*/
*/
function createTestFile(
function createTestFile(
test,
test,
refs,
refs,
commands,
commands,
{ addTestError, emitFile, scriptsRecord, exampleScriptedFilesQueryable }
{ emitFile, scriptsRecord, exampleScriptedFilesQueryable }
) {
) {
let scripts = [];

// default setupScript if test has undefined setupScript
// default setupScript if test has undefined setupScript
if (!scriptsRecord.find(`${test.setupScript}.js`).isFile()) test.setupScript = '';
if (!scriptsRecord.find(`${test.setupScript}.js`).isFile()) test.setupScript = '';


Text moved with changes from lines 248-257 (98.3% similarity)
function getTask(t) {
let task = cleanTask(t);
if (typeof commands[task] !== 'object') {
utils.addTestError(test.testId, '"' + task + '" does not exist in commands.csv file.');
}
return task;
}

function getModeValue(value) {
function getModeValue(value) {
let v = value.trim().toLowerCase();
let v = value.trim().toLowerCase();
if (!validModes.includes(v)) {
if (!validModes.includes(v)) {
addTestError(test.testId, '"' + value + '" is not valid value for "mode" property.');
utils.addTestError(test.testId, '"' + value + '" is not valid value for "mode" property.');
}
}
return v;
return v;
}
}


Text moved with changes to lines 243-250 (98.3% similarity)
function getTask(t) {
let task = cleanTask(t);

if (typeof commands[task] !== 'object') {
addTestError(test.testId, '"' + task + '" does not exist in commands.csv file.');
}

return task;
}

function getAppliesToValues(values) {
function getAppliesToValues(values) {
function checkValue(value) {
function checkValue(value) {
let v1 = value.trim().toLowerCase();
let v1 = value.trim().toLowerCase();
for (let i = 0; i < validAppliesTo.length; i++) {
for (let i = 0; i < validAppliesTo.length; i++) {
let v2 = validAppliesTo[i];
let v2 = validAppliesTo[i];
if (v1 === v2.toLowerCase()) {
if (v1 === v2.toLowerCase()) {
return v2;
return v2;
}
}
}
}
return false;
return false;
}
}


// check for individual assistive technologies
// check for individual assistive technologies
let items = values.split(',');
let items = values.split(',');
let newValues = [];
let newValues = [];
items.filter(item => {
items.filter(item => {
let value = checkValue(item);
let value = checkValue(item);
if (!value) {
if (!value) {
addTestError(test.testId, '"' + item + '" is not valid value for "appliesTo" property.');
utils.addTestError(
test.testId,
'"' + item + '" is not valid value for "appliesTo" property.'
);
}
}

newValues.push(value);
newValues.push(value);
});
});


return newValues;
return newValues;
}
}


/**
/**
* Determines priority level (default is 1) of assertion string, then adds it to the collection of assertions for
* Determines priority level (default is 1) of assertion string, then adds it to the collection of assertions for
* the test plan
* the test plan
* @param {string} a - Assertion string to be evaluated
* @param {string} a - Assertion string to be evaluated
*/
*/
function addAssertion(a) {
function addAssertion(a) {
let level = '1';
let level = '1';
let str = a;
let str = a;
a = a.trim();
a = a.trim();


// matches a 'colon' when preceded by either of the digits 1 OR 2 (SINGLE CHARACTER), at the start of the string
// matches a 'colon' when preceded by either of the digits 1 OR 2 (SINGLE CHARACTER), at the start of the string
let parts = a.split(/(?<=^[1-2]):/g);
let parts = a.split(/(?<=^[1-2]):/g);


if (parts.length === 2) {
if (parts.length === 2) {
level = parts[0];
level = parts[0];
str = parts[1].substring(0);
str = parts[1].substring(0);
if (level !== '1' && level !== '2') {
if (level !== '1' && level !== '2') {
addTestError(
utils.addTestError(
test.testId,
test.testId,
"Level value must be 1 or 2, value found was '" +
"Level value must be 1 or 2, value found was '" +
level +
level +
"' for assertion '" +
"' for assertion '" +
str +
str +
"' (NOTE: level 2 defined for this assertion)."
"' (NOTE: level 2 defined for this assertion)."
);
);
level = '2';
level = '2';
}
}
}
}


if (a.length) {
if (a.length) {
assertions.push([level, str]);
assertions.push([level, str]);
}
}
}
}


function getReferences(example, testRefs) {
function getExampleReferences() {
const example = refs.example;
let links = '';
let links = '';


if (typeof example === 'string' && example.length) {
if (typeof example === 'string' && example.length) {
links += `<link rel="help" href="${refs.example}">\n`;
links += `<link rel="help" href="${refs.example}">\n`;
}
}


let items = test.refs.split(' ');
let items = test.refs.split(' ');
items.forEach(function (item) {
items.forEach(function (item) {
item = item.trim();
item = item.trim();


if (item.length) {
if (item.length) {
if (typeof refs[item] === 'string') {
if (typeof refs[item] === 'string') {
links += `<link rel="help" href="${refs[item]}">\n`;
links += `<link rel="help" href="${refs[item]}">\n`;
} else {
} else {
addTestError(test.testId, 'Reference does not exist: ' + item);
utils.addTestError(test.testId, 'Reference does not exist: ' + item);
}
}
}
}
});
});


return links;
return links;
}
}


function addSetupScript(scriptName) {
// Prepare file name descriptors
let script = '';
if (scriptName) {
if (!scriptsRecord.find(`${scriptName}.js`).isFile()) {
addTestError(test.testId, `Setup script does not exist: ${scriptName}.js`);
return '';
}

try {
const data = scriptsRecord.find(`${scriptName}.js`).text;
const lines = data.split(/\r?\n/);
lines.forEach(line => {
if (line.trim().length) script += '\t\t\t' + line.trim() + '\n';
});
} catch (err) {
log.warning(err);
}

scripts.push(`\t\t${scriptName}: function(testPageDocument){\n${script}\t\t}`);
}

return script;
}

function getSetupScriptDescription(desc) {
let str = '';
if (typeof desc === 'string') {
let d = desc.trim();
if (d.length) {
str = d;
}
}

return str;
}

function getScripts() {
let js = 'var scripts = {\n';
js += scripts.join(',\n');
js += '\n\t};';
return js;
}

let task = getTask(test.task);
let task = getTask(test.task);
let mode = getModeValue(test.mode);
let appliesTo = getAppliesToValues(test.appliesTo);
let appliesTo = getAppliesToValues(test.appliesTo);
let mode = getModeValue(test.mode);


appliesTo.forEach(at => {
appliesTo.forEach(at => {
if (commands[task]) {
if (commands[task]) {
if (!commands[task][mode][at.toLowerCase()]) {
if (!commands[task][mode][at.toLowerCase()]) {
addTestError(
utils.addTestError(
test.testId,
test.testId,
'command is missing for the combination of task: "' +
'command is missing for the combination of task: "' +
task +
task +
'", mode: "' +
'", mode: "' +
mode +
mode +
'", and AT: "' +
'", and AT: "' +
at.toLowerCase() +
at.toLowerCase() +
'" '
'" '
);
);
}
}
}
}
});
});


let assertions = [];
let id = test.testId;
let id = test.testId;
if (parseInt(test.testId) < 10) {
if (parseInt(test.testId) < 10) {
id = '0' + id;
id = '0' + id;
}
}


// Define file names
const cleanTaskName = cleanTask(test.task).replace(/\s+/g, '-');
const cleanTaskName = cleanTask(test.task).replace(/\s+/g, '-');
let testFileName = `test-${id}-${cleanTaskName}-${mode}.html`;
const testFileName = `test-${id}-${cleanTaskName}-${mode}.html`;
let testJSONFileName = `test-${id}-${cleanTaskName}-${mode}.json`;
const testJSONFileName = `test-${id}-${cleanTaskName}-${mode}.json`;


let testPlanHtmlFileBuildPath = path.join(testPlanBuildDirectory, testFileName);
const testPlanHtmlFileBuildPath = path.join(testPlanBuildDirectory, testFileName);
let testPlanJsonFileBuildPath = path.join(testPlanBuildDirectory, testJSONFileName);
const testPlanJsonFileBuildPath = path.join(testPlanBuildDirectory, testJSONFileName);


let references = getReferences(refs.example, test.refs);
const exampleReferences = getExampleReferences();
addSetupScript(test.setupScript);
const scriptsContent = [...utils.addSetupScript(test.testId, test.setupScript)];


let assertions = [];
for (let i = 1; i < 31; i++) {
for (let i = 1; i < 31; i++) {
if (!test['assertion' + i]) {
if (!test['assertion' + i]) {
continue;
continue;
}
}
addAssertion(test['assertion' + i]);
addAssertion(test['assertion' + i]);
}
}


/** @type {AriaATFile.Behavior} */
/** @type {AriaATFile.Behavior} */
let testData = {
let testData = {
Text moved from lines 433-435
task: task,
mode: mode,
applies_to: appliesTo,
setup_script_description: getSetupScriptDescription(test.setupScriptDescription),
setup_script_description: getSetupScriptDescription(test.setupScriptDescription),
specific_user_instruction: test.instructions,
setupTestPage: test.setupScript,
setupTestPage: test.setupScript,
Text moved to lines 394-396
applies_to: appliesTo,
mode: mode,
task: task,
testPlanStrings: supportJson.testPlanStrings,
testPlanStrings: supportJson.testPlanStrings,
specific_user_instruction: test.instructions,
output_assertions: assertions,
output_assertions: assertions,
};
};


const testHtml = utils.getTestHtml({
title: test.title,
testJson: JSON.stringify(testData, null, 2),
scriptsJs: utils.getScriptsJs(scriptsContent),
commandsJson: beautify({ [task]: commands[task] }, null, 2, 40),
exampleReferences,
testPageAndInstructionsPath: JSON.stringify(
exampleScriptedFilesQueryable.where({ name: test.setupScript ? test.setupScript : '' }).path
),
});

emitFile(testPlanJsonFileBuildPath, JSON.stringify(testData, null, 2), 'utf8');
emitFile(testPlanJsonFileBuildPath, JSON.stringify(testData, null, 2), 'utf8');

emitFile(testPlanHtmlFileBuildPath, testHtml, 'utf8');
function getTestJson() {
return JSON.stringify(testData, null, 2);
}

function getCommandsJson() {
return beautify({ [task]: commands[task] }, null, 2, 40);
}

let testHTML = `
<!DOCTYPE html>
<meta charset="utf-8">
<title>${test.title}</title>
${references}
<script>
${getScripts()}
</script>
<script type="module">
import { initialize, verifyATBehavior, displayTestPageAndInstructions } from "../resources/aria-at-harness.mjs";

new Promise((resolve) => {
fetch('../support.json')
.then(response => resolve(response.json()))
})
.then(supportJson => {
const testJson = ${getTestJson()};
const commandJson = ${getCommandsJson()};
initialize(supportJson, commandJson);
verifyATBehavior(testJson);
displayTestPageAndInstructions(${JSON.stringify(
exampleScriptedFilesQueryable.where({ name: test.setupScript ? test.setupScript : '' }).path
)});
});
</script>
`;

emitFile(testPlanHtmlFileBuildPath, testHTML, 'utf8');

/** @type {AriaATFile.CollectedTest} */
const collectedTest = {};


const applies_to_at = [];
const applies_to_at = [];

allAtKeys.forEach(at => applies_to_at.push(testData.applies_to.indexOf(at) >= 0));
allATKeys.forEach(at => applies_to_at.push(testData.applies_to.indexOf(at) >= 0));


return [testFileName, applies_to_at];
return [testFileName, applies_to_at];
}
}


/**
/**
* Create an index file for a local server
* Create an index file for a local server
* @param tasks
* @param tasks
* @param emitFile
*/
*/
function createIndexFile(tasks, { emitFile }) {
function createIndexFile(tasks, { emitFile }) {
let rows = '';
let rows = '';
let all_ats = '';
let atHeaders = '';

allATNames.forEach(at => (all_ats += '<th>' + at + '</th>\n'));


allAtNames.forEach(at => (atHeaders += '<th>' + at + '</th>\n'));
tasks.forEach(function (task) {
tasks.forEach(function (task) {
rows += `<tr><td>${task.id}</td>`;
rows += `<tr><td>${task.id}</td>`;
rows += `<td scope="row">${task.title}</td>`;
rows += `<td scope="row">${task.title}</td>`;
for (let i = 0; i < allATKeys.length; i++) {
for (let i = 0; i < allAtKeys.length; i++) {
if (task.applies_to_at[i]) {
if (task.applies_to_at[i]) {
rows += `<td class="test"><a href="${task.href}?at=${allATKeys[i]}" aria-label="${allATNames[i]} test for task ${task.id}">${allATNames[i]}</a></td>`;
rows += `<td class="test"><a href="${task.href}?at=${allAtKeys[i]}" aria-label="${allAtNames[i]} test for task ${task.id}">${allAtNames[i]}</a></td>`;
} else {
} else {
rows += `<td class="test none">not included</td>`;
rows += `<td class="test none">not included</td>`;
}
}
}
}
rows += `<td>${task.script}</td></tr>\n`;
rows += `<td>${task.script}</td></tr>\n`;
});
});


let indexHTML = `
const indexHtml = utils.getIndexHtml({ atHeaders, rows });
<!DOCTYPE html>
<meta charset="utf-8">
<head>
<title>Index of Assistive Technology Test Files</title>
<style>
table {
display: table;
border-collapse: collapse;
border-spacing: 2px;
border-color: gray;
}

thead {
display: table-row-group;
vertical-align: middle;
border-bottom: black solid 2px;
}

tbody {
display: table-row-group;
vertical-align: middle;
border-color: gray;
}

tr:nth-child(even) {background: #DDD}
tr:nth-child(odd) {background: #FFF}

tr {
display: table-row;
vertical-align: inherit;
border-color: gray;
}

td {
padding: 3px;
display: table-cell;
}

td.test {
text-align: center;
}

td.none {
color: #333;
}

th {
padding: 3px;
font-weight: bold;
display: table-cell;
}
</style>
</head>
<body>
<main>
<h1>Index of Assistive Technology Test Files</h1>
<p>This is useful for viewing the local files on a local web server.</p>
<table>
<thead>
<tr>
<th>Task ID</th>
<th>Testing Task</th>
${all_ats}
<th>Setup Script Reference</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
</main>
</body>
`;


emitFile(indexFileBuildOutputPath, indexHTML, 'utf8');
emitFile(indexFileBuildOutputPath, indexHtml, 'utf8');
}
}


// Process CSV files
// Process CSV files
var refs = {};
const refs = utils.getRefs(referencesCsv);
var errorCount = 0;
const indexOfURLs = [];
var errors = '';
const newTestPlan = newBuild.find(`tests/${path.basename(testPlanBuildDirectory)}`);
var indexOfURLs = [];

function addTestError(id, error) {
errorCount += 1;
errors += '[Test ' + id + ']: ' + error + '\n';
}

function addCommandError({ testId, task }, key) {
errorCount += 1;
errors += `[Command]: The key reference "${key}" found in "tests/${testPlanName}/data/commands.csv" for "test id ${testId}: ${task}" is invalid. Command may not be defined in "tests/resources/keys.mjs".\n`;
}

const newTestPlan = newBuild.find(`tests/${testPlanName}`);


function emitFile(filepath, content) {
function emitFile(filepath, content) {
newTestPlan.add(path.relative(testPlanBuildDirectory, filepath), {
newTestPlan.add(path.relative(testPlanBuildDirectory, filepath), {
buffer: toBuffer(content),
buffer: toBuffer(content),
});
});
}
}


function generateSourceHtmlScriptFile(filePath, content) {
function generateSourceHtmlScriptFile(filePath, content) {
testPlanUpdate.add(path.relative(testPlanDirectory, filePath), {
testPlanUpdate.add(path.relative(testPlanDirectory, filePath), {
buffer: toBuffer(content),
buffer: toBuffer(content),
});
});
}
}


// intended to be an internal helper to reduce some code duplication and make logging for csv errors simpler
const testsParsed = testsCsv.map(parseTestCSVRow);
async function readCSVFile(filePath, rowValidator = identity => identity) {
const scriptsSource = loadScriptsSource(scriptsRecord);
const rawCSV = await readCSV(testPlanRecord.find(filePath), testPlanName);
const commandsParsed = atCommandsCsv.map(parseCommandCSVRow);
let index = 0;
const referencesParsed = utils.parseReferencesCSV(referencesCsv);
function printError(message) {
// line number is index+2
log.warning(
`WARNING: Error parsing ${path.join(testPlanDirectory, filePath)} line ${
index + 2
}: ${message}`
);
}
try {
const firstRowKeysLength = Object.keys(rawCSV[0]).length;
for (; index < rawCSV.length; index++) {
const keysLength = Object.keys(rawCSV[index]).length;
if (keysLength != firstRowKeysLength) {
printError(
`column number mismatch, please include empty cells to match headers. Expected ${firstRowKeysLength} columns, found ${keysLength}`
);
}
if (!rowValidator(rawCSV[index])) {
printError('validator returned false result');
return;
}
}
} catch (err) {
printError(err);
return;
}
log(`Successfully parsed ${path.join(testPlanDirectory, filePath)}`);
return rawCSV;
}

function validateReferencesKeys(row) {
Text moved to lines 156-160
if (typeof row.refId !== 'string' || typeof row.value !== 'string') {
throw new Error('Row missing refId or value');
}
return row;
}

const validCommandKeys = /^(?:testId|task|mode|at|command[A-Z])$/;
Text moved with changes to lines 162-185 (98.5% similarity)
const numericKeyFormat = /^_(\d+)$/;
function validateCommandsKeys(row) {
// example header:
// testId,task,mode,at,commandA,commandB,commandC,commandD,commandE,commandF
for (const key of Object.keys(row)) {
if (numericKeyFormat.test(key)) {
throw new Error(`Column found without header row, ${+key.substring(1) + 1}`);
} else if (!validCommandKeys.test(key)) {
throw new Error(`Unknown commands.csv key: ${key} - check header row?`);
}
}
if (
!(
row.testId?.length &&
row.task?.length &&
row.mode?.length &&
row.at?.length &&
row.commandA?.length
)
) {
throw new Error('Missing one of required testId, task, mode, at, commandA');
}
return row;
}

Text moved to lines 187-196
const validTestsKeys =
/^(?:testId|title|appliesTo|mode|task|setupScript|setupScriptDescription|refs|instructions|assertion(?:[1-9]|[1-2][0-9]|30))$/;
function validateTestsKeys(row) {
// example header:
// testId,title,appliesTo,mode,task,setupScript,setupScriptDescription,refs,instructions,assertion1,assertion2,assertion3,assertion4,assertion5,assertion6,assertion7
for (const key of Object.keys(row)) {
if (numericKeyFormat.test(key)) {
throw new Error(`Column found without header row, ${+key.substring(1) + 1}`);
} else if (!validTestsKeys.test(key)) {
throw new Error(`Unknown testsV1.csv key: ${key} - check header row?`);
}
Text moved to lines 198-210
}
if (
!(
row.testId?.length &&
row.title?.length &&
row.appliesTo?.length &&
row.mode?.length &&
row.task?.length
)
) {
throw new Error('Missing one of required testId, title, appliesTo, mode, task');
}
return row;
}

const [atCommands, refRows, tests] = await Promise.all([
readCSVFile('data/commands.csv', validateCommandsKeys),
readCSVFile(backupReferencesCsvFile || 'data/referencesV1.csv', validateReferencesKeys),
readCSVFile(backupTestsCsvFile || 'data/testsV1.csv', validateTestsKeys),
]);

for (const row of refRows) {
refs[row.refId] = row.value.trim();
}

const scripts = loadScripts(scriptsRecord);


const commandsParsed = atCommands.map(parseCommandCSVRow);
const keyDefs = utils.getKeyDefs();
const testsParsed = tests.map(parseTestCSVRow);
const keysParsed = utils.parseKeyMap(keyDefs);
const referencesParsed = parseReferencesCSV(refRows);
const keysParsed = parseKeyMap(keyDefs);
const supportParsed = parseSupport(supportJson);
const supportParsed = parseSupport(supportJson);


const keysValidated = validateKeyMap(keysParsed, {
const keysValidated = utils.validateKeyMap(keysParsed, {
addKeyMapError(reason) {
addKeyMapError: utils.addKeyMapError,
errorCount += 1;
errors += `[resources/keys.mjs]: ${reason}\n`;
},
});
});


// Retrieve queryables
const supportQueryables = {
const supportQueryables = {
at: Queryable.from('at', supportParsed.ats),
at: Queryable.from('at', supportParsed.ats),
atGroup: Queryable.from('atGroup', supportParsed.atGroups),
atGroup: Queryable.from('atGroup', supportParsed.atGroups),
};
};
const keyQueryable = Queryable.from('key', keysValidated);
const keyQueryable = Queryable.from('key', keysValidated);
const referenceQueryable = Queryable.from('reference', referencesParsed);

const commandLookups = {
const commandLookups = {
key: keyQueryable,
key: keyQueryable,
support: supportQueryables,
support: supportQueryables,
};
};
const commandsValidated = commandsParsed.map(command =>
const commandsValidated = commandsParsed.map(command =>
validateCommand(command, commandLookups, { addCommandError })
validateCommand(command, commandLookups, { addCommandError: utils.addCommandError })
);
);


Text moved from lines 767-769
const referenceQueryable = Queryable.from('reference', referencesParsed);
const testLookups = {
command: Queryable.from('command', commandsValidated),
mode: Queryable.from('mode', validModes),
script: Queryable.from('script', scriptsSource),
Text moved from lines 771-779
reference: referenceQueryable,
support: supportQueryables,
};
const testsValidated = testsParsed.map(test =>
validateTest(test, testLookups, {
addTestError: utils.addTestError.bind(null, test.testId),
})
);

const examplePathOriginal = referenceQueryable.where({ refId: 'reference' })
const examplePathOriginal = referenceQueryable.where({ refId: 'reference' })
? referenceQueryable.where({ refId: 'reference' }).value
? referenceQueryable.where({ refId: 'reference' }).value
: '';
: '';
if (!examplePathOriginal) {
if (!examplePathOriginal) {
log.error(
log.error(
`ERROR: Valid 'reference' value not found in "tests/${testPlanName}/data/referencesV1.csv".`
`ERROR: Valid 'reference' value not found in "tests/${testPlanName}/${
backupReferencesCsvFile || 'data/referencesV1.csv'
}".`
);
);
}
}
const exampleRecord = testPlanRecord.find(examplePathOriginal);
const exampleRecord = testPlanRecord.find(examplePathOriginal);
if (!exampleRecord.isFile()) {
if (!exampleRecord.isFile()) {
log.error(
log.error(
`ERROR: Invalid 'reference' value path "${examplePathOriginal}" found in "tests/${testPlanName}/data/referencesV1.csv".`
`ERROR: Invalid 'reference' value path "${examplePathOriginal}" found in "tests/${testPlanName}/${
backupReferencesCsvFile || 'data/referencesV1.csv'
}".`
);
);
}
}
Text moved to lines 498-500
const testLookups = {
command: Queryable.from('command', commandsValidated),
mode: Queryable.from('mode', validModes),
reference: referenceQueryable,
Text moved to lines 502-510
script: Queryable.from('script', scripts),
support: supportQueryables,
};
const testsValidated = testsParsed.map(test =>
validateTest(test, testLookups, {
addTestError: addTestError.bind(null, test.testId),
})
);

const examplePathDirectory = path.dirname(examplePathOriginal);
const examplePathDirectory = path.dirname(examplePathOriginal);
const examplePathBaseName = path.basename(examplePathOriginal, '.html');
const examplePathBaseName = path.basename(examplePathOriginal, '.html');
/** @type {function(string): string} */
/** @type {function(string): string} */
const examplePathTemplate = scriptName =>
const examplePathTemplate = scriptName =>
path.join(
path.join(
examplePathDirectory,
examplePathDirectory,
`${examplePathBaseName}${scriptName ? `.${scriptName}` : ''}.html`
`${examplePathBaseName}${scriptName ? `.${scriptName}` : ''}.html`
);
);
const exampleTemplate = validate.reportTo(
const exampleTemplate = validate.reportTo(
reason => log.warning(`[${examplePathOriginal}]: ${reason.message}`),
reason => log.warning(`[${examplePathOriginal}]: ${reason.message}`),
() => createExampleScriptsTemplate(exampleRecord)
() => createExampleScriptsTemplate(exampleRecord)
);
);
const plainScriptedFile = createExampleScriptedFile('', examplePathTemplate, exampleTemplate, '');
const plainScriptedFile = createExampleScriptedFile('', examplePathTemplate, exampleTemplate, '');
const scriptedFiles = scripts.map(({ name, source }) =>
const scriptedFiles = scriptsSource.map(({ name, source }) =>
createExampleScriptedFile(name, examplePathTemplate, exampleTemplate, source)
createExampleScriptedFile(name, examplePathTemplate, exampleTemplate, source)
);
);
const exampleScriptedFiles = [plainScriptedFile, ...scriptedFiles];
const exampleScriptedFiles = [plainScriptedFile, ...scriptedFiles];
const exampleScriptedFilesQueryable = Queryable.from('example', exampleScriptedFiles);
const exampleScriptedFilesQueryable = Queryable.from('example', exampleScriptedFiles);

const commandQueryable = Queryable.from('command', commandsValidated);
const commandQueryable = Queryable.from('command', commandsValidated);

const testsCollected = testsValidated.flatMap(test => {
const testsCollected = testsValidated.flatMap(test => {
return test.target.at.map(({ key }) =>
return test.target.at.map(({ key }) =>
collectTestData({
collectTestData({
test,
test,
command: commandQueryable.where({
command: commandQueryable.where({
testId: test.testId,
testId: test.testId,
target: { at: { key } },
target: { at: { key } },
}),
}),
reference: referenceQueryable,
reference: referenceQueryable,
example: exampleScriptedFilesQueryable,
example: exampleScriptedFilesQueryable,
key: keyQueryable,
key: keyQueryable,
modeInstructionTemplate: MODE_INSTRUCTION_TEMPLATES_Q
modeInstructionTemplate: utils.MODE_INSTRUCTION_TEMPLATES_QUERYABLE(),
})
);
});

const buildFiles = [
...createScriptFiles(scriptsSource, testPlanBuildDirectory),
...exampleScriptedFiles.map(({ path: pathSuffix, content }) => ({
path: path.join(buildTestsDirectory, testPlanName, pathSuffix),
content,
})),
...testsCollected.map(collectedTest =>
utils.createCollectedTestFile(collectedTest, testPlanBuildDirectory)
),
...testsCollected.map(collectedTest =>
utils.createCollectedTestHtmlFile(collectedTest, testPlanBuildDirectory)
),
];
buildFiles.forEach(file => emitFile(file.path, file.content));
scriptedFiles.forEach(file =>
generateSourceHtmlScriptFile(path.join(testsDirectory, testPlanName, file.path), file.content)
);

const atCommandsMap = createCommandTuplesATModeTaskLookup(commandsValidated);

emitFile(
path.join(testPlanBuildDirectory, 'commands.json'),
beautify(atCommandsMap, null, 2, 40)
);

log('Creating the following test files: ');
testsCsv.forEach(function (test) {
try {
const [url, applies_to_at] = createTestFile(test, refs, atCommandsMap, {
emitFile,
scriptsRecord,
exampleScriptedFilesQueryable,
});

indexOfURLs.push({
id: test.testId,
title: test.title,
href: url,
script: test.setupScript,
applies_to_at: applies_to_at,
});

log('[Test ' + test.testId + ']: ' + url);
} catch (err) {
log.warning(err);
}
});

createIndexFile(indexOfURLs, {
emitFile,
});

const prefixBuildPath = TEST_MODE ? '__test__/' : '';
const prefixTestsPath = TEST_MODE ? '__test__/__mocks__/' : '';

const existingRoot = new FileRecordChain({});
existingRoot.add(`${prefixBuildPath}build`, await existingBuildPromise);
existingRoot.add(`${prefixTestsPath}tests/${testPlanName}`, testPlanRecord);

const newRoot = new FileRecordChain({});
newRoot.add(`${prefixBuildPath}build`, newBuild);
newRoot.add(`${prefixTestsPath}tests/${testPlanName}`, testPlanUpdate);

const buildChanges = newRoot.changesAfter(existingRoot);

if (!VALIDATE_CHECK) {
await buildChanges.commit(rootDirectory);
}

if (utils.errorCount) {
log.warning(
`*** ${utils.errorCount} Errors in tests and/or commands in test plan [tests/${testPlanName}] ***`
);
log.warning(utils.errors);
} else {
log(`No validation errors detected for tests/${testPlanName}\n`);
}

return { isSuccessfulRun: utils.errorCount === 0, suppressedMessages: log.suppressedMessages() };
};

exports.processTestDirectory = processTestDirectory;

/**
* @param {AriaATParsed.Command} commandParsed
* @param {object} data
* @param {Queryable<AriaATParsed.Key>} data.key
* @param {object} data.support
* @param {Queryable<{key: string, name: string}>} data.support.at
* @param {object} [options]
* @param {function(AriaATParsed.Command, string): void} [options.addCommandError]
* @returns {AriaATValidated.Command}
*/
function validateCommand(commandParsed, data, { addCommandError = () => {} } = {}) {
return {
...commandParsed,
target: {
...commandParsed.target,
at: {
...commandParsed.target.at,
...mapDefined(data.support.at.where({ key: commandParsed.target.at.key }), ({ name }) => ({
name,
})),
},
},
commands: commandParsed.commands.map(({ id, keypresses: commandKeypresses, ...rest }) => {
const keypresses = commandKeypresses.map(keypress => {
const key = data.key.where(keypress);
if (!key) {
addCommandError(commandParsed, keypress.id);
}
return key || {};
});
return {
id: id,
keystroke: keypresses.map(({ keystroke }) => keystroke).join(', then '),
keypresses,
...rest,
};
}),
};
}

/**
* @param {AriaATParsed.Test} testParsed
* @param {object} data
* @param {Queryable<AriaATParsed.Command>} data.command
* @param {Queryable<AriaATParsed.Key>} data.key
* @param {Queryable<string>} data.mode
* @param {Queryable<AriaATParsed.Reference>} data.reference
* @param {Queryable<AriaATParsed.ScriptSource>} data.script
* @param {object} data.support
* @param {Queryable<{key: string, name: string}>} data.support.at
* @param {Queryable<{key: string, name: string}>} data.support.atGroup
* @param {object} [options]
* @param {function(string): void} [options.addTestError]
* @returns {AriaATValidated.Test}
*/
function validateTest(testParsed, data, { addTestError = () => {} } = {}) {
if (!data.command.where({ task: testParsed.task })) {
addTestError(`"${testParsed.task}" does not exist in commands.csv file.`);
}

testParsed.target.at.forEach(at => {
if (!data.support.atGroup.where({ key: at.key })) {
addTestError(`"${at.key}" is not valid value for "appliesTo" property.`);
}

if (
!data.command.where({
task: testParsed.task,
target: {
at: { key: at.key },
mode: testParsed.target.mode,
},
})
) {
addTestError(
`command is missing for the combination of task: "${testParsed.task}", mode: "${testParsed.target.mode}", and AT: "${at.key}"`
);
}
});

if (!data.mode.where(testParsed.target.mode)) {
addTestError(`"${testParsed.target.mode}" is not valid value for "mode" property.`);
}

const references = testParsed.refer