pa11y-ci-reporter-runner
Version:
Pa11y CI Reporter Runner is designed to facilitate testing of Pa11y CI reporters. Given a Pa11y CI JSON results file and optional configuration it simulates the Pa11y CI calls to the reporter.
344 lines (312 loc) • 10.7 kB
JavaScript
/* eslint-disable max-lines -- over 22 lines */
;
/**
* Service that interprets pa11yci-machine.
*
* @module pa11yci-service
*/
const { createActor, getNextSnapshot } = require('xstate');
const machine = require('./pa11yci-machine');
const RunnerStates = require('./runner-states');
const finalState = RunnerStates.afterAll;
/**
* Enum for machine events.
*
* @readonly
* @enum {string}
* @static
* @private
*/
const MachineEvents = Object.freeze({
NEXT: 'NEXT',
RESET: 'RESET'
});
/**
* Enum for runner service states.
*
* @readonly
* @enum {string}
* @static
* @private
*/
const StateTypes = Object.freeze({
current: 'current',
next: 'next'
});
/**
* Generates a machine event object for the given event type.
*
* @param {string} eventType The type of event (a MachineEvents value).
* @returns {object} The machine event object.
*/
const getMachineEvent = (eventType) => ({ type: eventType });
/**
* Gets the initial pa11yci-runner state machine context given an array of URLs.
*
* @param {Array} urls Array of URLs.
* @returns {object} The initial state machine context.
* @private
*/
const getInitialContext = (urls) => ({
urlIndex: 0,
urls
});
/**
* Checks the runner state to determine whether it has an associated URL.
*
* @param {string} state The state to check.
* @returns {boolean} True if the state has an associated url.
* @private
*/
const hasUrl = (state) =>
state === RunnerStates.beginUrl || state === RunnerStates.urlResults;
/**
* Gets the URL for the given machine state.
*
* @param {object} machineState The machine state to check against.
* @returns {string} The current URL for the machine state.
* @private
*/
const getUrlForState = (machineState) =>
// Only a subset of states have an associated URL
hasUrl(machineState.value)
? // User supplied input used to index user supplied data file
// nosemgrep: eslint.detect-object-injection
machineState.context.urls[machineState.context.urlIndex]
: undefined;
/**
* Validates that the given state is a valid RunnerStates value. Throws if not.
*
* @param {string} state The state to validate.
* @throws {TypeError} Throws if state is not a valid RunnerStates value.
* @private
*/
const validateRunnerState = (state) => {
if (!Object.keys(RunnerStates).includes(state)) {
throw new TypeError(`targetState "${state}" invalid`);
}
};
/**
* Checks if the machine state matches the targetState and either no
* targetUrl was specified or the context matches the targetUrl.
*
* @param {object} machineState The machine state to check against.
* @param {RunnerStates} targetState The target runner state.
* @param {string} [targetUrl] The target URL for the state.
* @returns {boolean} True if the context matches the target, otherwise false.
* @private
*/
const isAtTarget = (machineState, targetState, targetUrl) =>
machineState.value === targetState &&
(!targetUrl || getUrlForState(machineState) === targetUrl);
/**
* Gets a summary of the machine state (state, url).
*
* @param {object} machineState The machine state object.
* @returns {object} The machine state summary.
* @private
*/
const getStateSummary = (machineState) => ({
state: machineState.value,
url: getUrlForState(machineState)
});
/**
* Factory function that returns a pa11yci-runner service.
*
* @param {Array} urls Array of URLs.
* @param {object} actions Actions object with functions to execute for each event.
* @returns {object} A pa11yci-runner service.
* @static
* @public
*/
// eslint-disable-next-line max-lines-per-function -- factory function with state
const serviceFactory = (urls, actions) => {
let pendingCommand;
// Implement custom xstate actor, which allows for tracking for current
// and the next state, which is required for some control functions.
const pa11yActor = createActor(machine, {
input: getInitialContext(urls)
});
let currentState = pa11yActor.getSnapshot();
let nextState = getNextSnapshot(
machine,
currentState,
getMachineEvent(MachineEvents.NEXT)
);
/**
* Validates that a command is allowed in the given state. Throws if invalid.
*
* @throws {Error} Throws if a previous command is still pending.
* @throws {Error} Throws if the runner state is afterAll (must reset first).
* @private
*/
const validateCommandAllowed = () => {
if (pendingCommand) {
throw new Error(
'runner cannot accept a command while another command is pending, await previous command'
);
}
if (currentState.value === finalState) {
throw new Error(
`runner must be reset before executing any other functions from the ${finalState} state`
);
}
};
/**
* Sends the specified event to the pa11yci-machine and executes
* the applicable action for that state.
*
* @param {MachineEvents} event The event name to be sent.
* @async
* @private
*/
const sendEvent = async (event) => {
try {
// Track pending command to block other commands
pendingCommand = true;
// Send event to the machine and executes the action for the
// new current state (except in init, which has no action).
currentState = getNextSnapshot(
machine,
currentState,
getMachineEvent(event)
);
if (currentState.value !== 'init') {
// Value is internal object state.
// nosemgrep: eslint.detect-object-injection, unsafe-dynamic-method
await actions[currentState.value](getUrlForState(currentState));
}
// Check next state and save for reference.
if (currentState.value !== finalState) {
nextState = getNextSnapshot(
machine,
currentState,
getMachineEvent(MachineEvents.NEXT)
);
}
} finally {
// Ensure pending command is reset in all cases, including on error.
pendingCommand = false;
}
};
/**
* Resets the service to the init state.
*
* @instance
* @async
* @public
*/
const reset = async () => {
await sendEvent(MachineEvents.RESET);
};
/**
* Executes the next event in the Pa11y CI sequence, calling the
* appropriate reporter function.
*
* @instance
* @async
* @public
*/
const runNext = async () => {
validateCommandAllowed();
await sendEvent(MachineEvents.NEXT);
};
/**
* Gets the state for the given state type (current or next).
*
* @param {StateTypes} stateType The runner service state type.
* @returns {object} The state object for teh given type.
* @private
*/
const getState = (stateType) =>
stateType === StateTypes.current ? currentState : nextState;
/**
* Common function for executing runUntil and runUntilNext using the
* specified state type to check for completion. Executes the entire
* Pa11y CI sequence, calling all reporter functions, until the
* specified state and optional URL are reached. If a URL is not specified,
* the run completes on the first occurrence of the target state.
*
* @param {StateTypes} stateType The runner service state type.
* @param {RunnerStates} targetState The target state to run to.
* @param {string} [targetUrl] The target URL to run to.
* @async
* @private
*/
const runUntilInternal = async (stateType, targetState, targetUrl) => {
validateCommandAllowed();
validateRunnerState(targetState);
// Run until target or final state is achieved
while (
!isAtTarget(getState(stateType), targetState, targetUrl) &&
getState(stateType).value !== finalState
) {
/* eslint-disable-next-line no-await-in-loop -- required to be
done in sequence */
await sendEvent(MachineEvents.NEXT);
}
// If the finalState is reached and not at target then it is
// not in the results and throw to indicate the command failed.
if (!isAtTarget(getState(stateType), targetState, targetUrl)) {
const urlString = targetUrl ? ` for targetUrl "${targetUrl}"` : '';
throw new Error(
`targetState "${targetState}"${urlString} was not found`
);
}
};
/**
* Executes the entire Pa11y CI sequence, calling all reporter functions,
* until the specified current state and optional URL are reached. If a URL is not
* specified, the run completes on the first occurrence of the target state.
*
* @instance
* @param {RunnerStates} targetState The target state to run to.
* @param {string} [targetUrl] The target URL to run to.
* @async
* @public
*/
const runUntil = async (targetState, targetUrl) => {
await runUntilInternal(StateTypes.current, targetState, targetUrl);
};
/**
* Executes the entire Pa11y CI sequence, calling all reporter functions,
* until the specified next state and optional URL are reached. If a URL is not
* specified, the run completes on the first occurrence of the target state.
*
* @instance
* @param {RunnerStates} targetState The target state to run to.
* @param {string} [targetUrl] The target URL to run to.
* @async
* @public
*/
const runUntilNext = async (targetState, targetUrl) => {
await runUntilInternal(StateTypes.next, targetState, targetUrl);
};
/**
* Get the current state (state, url).
*
* @instance
* @returns {object} The current runner state.
* @public
*/
const getCurrentState = () => getStateSummary(currentState);
/**
* Get the next state (state, url).
*
* @instance
* @returns {object} The next runner state.
* @public
*/
const getNextState = () => getStateSummary(nextState);
return {
getCurrentState,
getNextState,
reset,
runNext,
runUntil,
runUntilNext
};
};
module.exports = {
serviceFactory
};