UNPKG

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
/* eslint-disable max-lines -- over 22 lines */ 'use strict'; /** * 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 };