diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 7b3aa4221..000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,456 +0,0 @@ -version: 2.1 - -defaults: &defaults - working_directory: ~/axe-core - -unix_box: &unix_box - docker: - - image: cimg/node:18.18-browsers - -unix_nightly_box: &unix_nightly_box - docker: - - image: cimg/node:lts-browsers - -orbs: - puppeteer: threetreeslight/puppeteer@0.1.2 - browser-tools: circleci/browser-tools@1.4.8 - -set_npm_auth: &set_npm_auth - run: npm config set "//registry.npmjs.org/:_authToken" $NPM_AUTH - -restore_dependency_cache_unix: &restore_dependency_cache_unix - restore_cache: - name: Restore NPM Cache - keys: - - v9-cache-unix-{{ checksum "package-lock.json" }} - -restore_build: &restore_build - restore_cache: - name: Restore Axe.js Cache - keys: - - v9-cache-build-<< pipeline.git.revision >> - -commands: - browser-tools-job: - steps: - - browser-tools/install-browser-tools - -jobs: - # Fetch and cache dependencies. - dependencies_unix: - <<: *defaults - <<: *unix_box - steps: - - checkout - - <<: *restore_dependency_cache_unix - - run: - name: Skip Install If Cache Exists - command: | - if [ -d "node_modules" ]; then - echo "node_modules exist" - circleci step halt - else - echo "node_modules does not exist" - fi - - browser-tools-job - - <<: *set_npm_auth - - run: npm ci - - run: npx browser-driver-manager install chromedriver --verbose - - save_cache: - key: v9-cache-unix-{{ checksum "package-lock.json" }} - paths: - - node_modules - - # Build and cache axe.js - build_unix: - <<: *defaults - <<: *unix_box - steps: - - checkout - - <<: *restore_dependency_cache_unix - - run: npm run build - - save_cache: - key: v9-cache-build-<< pipeline.git.revision >> - paths: - - axe.js - - axe.min.js - - # Run ESLINT - lint: - <<: *defaults - <<: *unix_box - steps: - - checkout - - <<: *restore_dependency_cache_unix - - run: npm run eslint - - # Run the test suite. - test_chrome: - <<: *defaults - <<: *unix_box - steps: - - checkout - - browser-tools-job - - <<: *restore_dependency_cache_unix - - run: npx browser-driver-manager install chromedriver --verbose - - <<: *restore_build - - run: npm run test -- --browsers Chrome - - run: npm run test:integration:chrome - - test_firefox: - <<: *defaults - <<: *unix_box - steps: - - checkout - - browser-tools-job - - <<: *restore_dependency_cache_unix - - <<: *restore_build - - run: npm run test -- --browsers Firefox - - run: npm run test:integration:firefox - - # Run examples under `doc/examples` - test_examples: - <<: *defaults - <<: *unix_box - steps: - - checkout - - browser-tools-job - - <<: *restore_dependency_cache_unix - - run: npx browser-driver-manager install chromedriver --verbose - - <<: *restore_build - - run: npm run test:examples - - # Run ACT test cases - test_act: - <<: *defaults - <<: *unix_box - steps: - - checkout - - browser-tools-job - - <<: *restore_dependency_cache_unix - - run: npx browser-driver-manager install chromedriver --verbose - - <<: *restore_build - - run: npm run test:act - - # Run ARIA practices test cases - test_aria_practices: - <<: *defaults - <<: *unix_box - steps: - - checkout - - browser-tools-job - - <<: *restore_dependency_cache_unix - - run: npx browser-driver-manager install chromedriver --verbose - - <<: *restore_build - - run: npm run test:apg - - # Test locale files - test_locales: - <<: *defaults - <<: *unix_box - steps: - - checkout - - browser-tools-job - - <<: *restore_dependency_cache_unix - - run: npx browser-driver-manager install chromedriver --verbose - - <<: *restore_build - - run: npm run test:locales - - # Test virtual rules - test_virtual_rules: - <<: *defaults - <<: *unix_box - steps: - - checkout - - browser-tools-job - - <<: *restore_dependency_cache_unix - - run: npx browser-driver-manager install chromedriver --verbose - - <<: *restore_build - - run: npm run test:virtual-rules - - # Run the test suite for nightly builds. - test_nightly_browsers: - <<: *defaults - <<: *unix_nightly_box - steps: - - checkout - - <<: *restore_dependency_cache_unix - - run: sudo apt-get update -y - - <<: *restore_build - - run: - name: Install Chrome and ChromeDriver Beta - command: npx browser-driver-manager install chrome=beta chromedriver=beta --verbose - - run: - name: Install Firefox Nightly - command: | - wget -O firefox-nightly.tar.bz2 "https://download.mozilla.org/?product=firefox-nightly-latest-ssl&os=linux64&lang=en-US" - tar xf firefox-nightly.tar.bz2 - - run: - name: Set Environment Variable - command: echo "export FIREFOX_NIGHTLY_BIN=$(pwd)/firefox/firefox-bin" >> $BASH_ENV - - run: npm run test -- --browsers Chrome,FirefoxNightly - - # Run the test suite for nightly builds. - test_nightly_act: - <<: *defaults - <<: *unix_nightly_box - steps: - - checkout - - <<: *restore_dependency_cache_unix - - browser-tools-job - # install ACT rules - # install first as for some reason installing a single package - # also re-installs all repo dependencies as well - - run: npm install w3c/wcag-act-rules#main - - run: npx browser-driver-manager install chromedriver --verbose - - <<: *restore_build - - run: npm run test:act - - # Run the test suite for nightly builds. - test_nightly_aria_practices: - <<: *defaults - <<: *unix_nightly_box - steps: - - checkout - - <<: *restore_dependency_cache_unix - - browser-tools-job - # install ARIA practices - # install first as for some reason installing a single package - # also re-installs all repo dependencies as well - - run: npm install w3c/aria-practices#main - - run: npx browser-driver-manager install chromedriver --verbose - - <<: *restore_build - - run: npm run test:apg - - # Test api docs can be built - build_api_docs: - <<: *defaults - <<: *unix_box - steps: - - checkout - - <<: *restore_dependency_cache_unix - - run: npm run api-docs - - # Test newest axe-core version rule help docs are active (only on - # master prs) - test_rule_help_version: - <<: *defaults - <<: *unix_box - steps: - - checkout - - <<: *restore_dependency_cache_unix - - run: npm run test:rule-help-version - - # Test jsdom API - test_jsdom: - <<: *defaults - <<: *unix_box - steps: - - checkout - - <<: *restore_dependency_cache_unix - - <<: *restore_build - - run: npm run test:jsdom - - # Release a "next" version - next_release: - <<: *defaults - <<: *unix_box - steps: - - checkout - - <<: *set_npm_auth - - <<: *restore_dependency_cache_unix - - <<: *restore_build - - run: npm run next-release - - run: .circleci/verify-release.sh - - run: npm publish --tag=next - - # Release a "production" version - verify_sri: - <<: *defaults - <<: *unix_box - steps: - - checkout - - <<: *set_npm_auth - - <<: *restore_dependency_cache_unix - - <<: *restore_build - - run: npm run sri-validate - - # Release a "production" version - release: - <<: *defaults - <<: *unix_box - steps: - - checkout - - <<: *set_npm_auth - - <<: *restore_dependency_cache_unix - - <<: *restore_build - - run: .circleci/verify-release.sh - - run: npm publish - - # Create a GitHub release. - github_release: - docker: - - image: cimg/go:1.17.1 - steps: - - checkout - - run: go get gopkg.in/aktau/github-release.v0 - - run: - name: Download and run GitHub release script - command: | - curl https://raw.githubusercontent.com/dequelabs/attest-release-scripts/develop/src/node-github-release.sh -s -o ./node-github-release.sh - chmod +x ./node-github-release.sh - ./node-github-release.sh - - # Verify released package has all required files - verify_release: - <<: *defaults - <<: *unix_box - steps: - - checkout - - <<: *restore_dependency_cache_unix - - run: .circleci/verify-release.sh post - - # Verify canary released package has all required files - verify_next_release: - <<: *defaults - <<: *unix_box - steps: - - checkout - - <<: *restore_dependency_cache_unix - - run: npm run next-release - - run: .circleci/verify-release.sh post - -workflows: - version: 2 - build: - jobs: - # install deps - - dependencies_unix - - build_unix: - requires: - - dependencies_unix - # Run linting - - lint: - requires: - - dependencies_unix - # Run tests on all commits, but after installing dependencies - - test_chrome: - requires: - - build_unix - - test_firefox: - requires: - - build_unix - - test_examples: - requires: - - build_unix - - test_act: - requires: - - build_unix - - test_aria_practices: - requires: - - build_unix - - test_locales: - requires: - - build_unix - - test_virtual_rules: - requires: - - build_unix - - build_api_docs: - requires: - - build_unix - - test_rule_help_version: - requires: - - build_unix - - test_jsdom: - requires: - - build_unix - # Verify the sri history is correct - - verify_sri: - requires: - - build_unix - filters: - branches: - only: - - /^release-.+/ - - master - # Hold for approval - - hold_release: - type: approval - requires: - - test_chrome - - test_firefox - - test_examples - - test_act - - test_aria_practices - - test_locales - - test_virtual_rules - - build_api_docs - - test_rule_help_version - - test_jsdom - - verify_sri - filters: - branches: - only: - - master - # Run a next release on "develop" commits, but only after the tests pass and dependencies are installed - - next_release: - requires: - - test_chrome - - test_firefox - - test_examples - - test_act - - test_aria_practices - - test_locales - - test_virtual_rules - - build_api_docs - - test_rule_help_version - - test_jsdom - filters: - branches: - only: develop - # Run a production release on "master" commits, but only after the tests pass and dependencies are installed - - release: - requires: - - hold_release - filters: - branches: - only: master - # Verify releases have all required files - - verify_release: - requires: - - release - filters: - branches: - only: master - - verify_next_release: - requires: - - next_release - filters: - branches: - only: develop - - github_release: - requires: - - release - nightly: - triggers: - - schedule: - # run at 00:00 UTC every day - cron: '0 0 * * *' - filters: - branches: - only: - - develop - jobs: - - dependencies_unix - - build_unix: - requires: - - dependencies_unix - - test_nightly_browsers: - requires: - - build_unix - - test_nightly_act: - requires: - - build_unix - - test_nightly_aria_practices: - requires: - - build_unix diff --git a/.circleci/verify-release.sh b/.circleci/verify-release.sh deleted file mode 100755 index 93a350f06..000000000 --- a/.circleci/verify-release.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/bin/bash - -set -e - -# Verifying the release can fail due to race condition of -# npm not publishing the package before we try to install -# it -function wait_for_publish() { - echo "Installing $1@$2" - - set +e - for i in {1..10}; do - npm install "$1@$2" 2> /dev/null - if [ $? -eq 0 ]; then - echo "Successfully installed" - set -e - return - else - echo "Retrying..." - sleep 10 - fi - done - - echo "Unable to install. Exiting..." - exit 1 -} - -if [ -n "$1" ] && [ "$1" == "post" ] -then - # verify the released npm package in another dir as we can't - # install a package with the same name - version=$(node -pe "require('./package.json').version") - name=$(node -pe "require('./package.json').name") - - mkdir "verify-release-$version" - cd "verify-release-$version" - npm init -y - - wait_for_publish "$name" "$version" - - node -pe "window={}; document={}; require('$name')" - - cd "node_modules/${name}" -else - # verify main file exists - main=$(node -pe "require('./package.json').main") - node -pe "window={}; document={}; require('./$main')" -fi - -# Test if typescript file exists (if declared) -# -# Note: because we are using node to read the package.json, the -# variable gets set to the string `undefined` if the property -# does not exists, rather than an empty variable. -types=$(node -pe "require('./package.json').types") -if [ "$types" == "undefined" ] -then - types=$(node -pe "require('./package.json').typings") -fi - -if [ "$types" != "undefined" ] && [ ! -f "$types" ] -then - echo "types file missing" - exit 1; -fi \ No newline at end of file diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index ca1d987e9..000000000 --- a/.eslintignore +++ /dev/null @@ -1,11 +0,0 @@ -**/node_modules/* -**/tmp/* - -build/tasks/aria-supported.js - -doc/api/* -doc/examples/jest_react/*.js - -lib/core/imports/*.js -axe.js -axe.min.js \ No newline at end of file diff --git a/.github/actions/install-deps/action.yml b/.github/actions/install-deps/action.yml new file mode 100644 index 000000000..b7d220b7f --- /dev/null +++ b/.github/actions/install-deps/action.yml @@ -0,0 +1,78 @@ +name: 'Install Dependencies' +description: 'Install OS and Project dependencies' + +inputs: + node-version: + description: 'Node.js version to install' + required: false + start-xvfb: + description: 'If provided, this is the display number to run xvfb on. Should be in `:N` format, e.g., `:99`.' + required: false + nightly: + description: 'If true, installs the nightly versions of browsers.' + required: false +outputs: + chrome-path: + description: 'Path to the installed Chrome binary' + value: ${{ steps.setup-chrome.outputs.chrome-path }} + firefox-path: + description: 'Path to the installed Firefox binary' + value: ${{ steps.setup-firefox.outputs.firefox-path }} + chromedriver-path: + description: 'Path to the installed ChromeDriver binary' + value: ${{ steps.setup-chrome.outputs.chromedriver-path }} + chrome-version: + description: 'Version of the installed Chrome binary' + value: ${{ steps.setup-chrome.outputs.chrome-version }} + chromedriver-version: + description: 'Version of the installed ChromeDriver binary' + value: ${{ steps.setup-chrome.outputs.chromedriver-version }} + firefox-version: + description: 'Version of the installed Firefox binary' + value: ${{ steps.setup-firefox.outputs.firefox-version }} + +runs: + using: 'composite' + steps: + - name: Setup Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + registry-url: 'https://registry.npmjs.org' + node-version: ${{ inputs.node-version }} + node-version-file: ${{ inputs.node-version == '' && '.nvmrc' || '' }} + cache: npm + - name: Fix Chrome Sandbox Permissions + shell: bash + run: | + sudo chown root:root /opt/google/chrome/chrome-sandbox + sudo chmod 4755 /opt/google/chrome/chrome-sandbox + - name: Install Xvfb + shell: bash + if: ${{ inputs.start-xvfb }} + run: | + sudo apt-get update + sudo apt-get install -y xvfb x11-xserver-utils + - name: Install Google Chrome for Testing + id: setup-chrome + uses: browser-actions/setup-chrome@b94431e051d1c52dcbe9a7092a4f10f827795416 # v2.1.0 + with: + chrome-version: ${{ inputs.nightly == 'true' && 'beta' || 'stable' }} + install-chromedriver: true + install-dependencies: true + - name: Install Firefox + id: setup-firefox + uses: browser-actions/setup-firefox@5914774dda97099441f02628f8d46411fcfbd208 # v1.7.0 + with: + firefox-version: ${{ inputs.nightly == 'true' && 'latest-nightly' || 'latest' }} + - name: Install Project Dependencies + shell: bash + run: npm ci + - name: Start Xvfb + if: ${{ inputs.start-xvfb }} + env: + DISPLAY: ${{ inputs.start-xvfb }} + shell: bash + # This is the same resolution as what CircleCI used. + # Maintaining it for consistency between the environments + # since something may be resolution dependent. + run: Xvfb "$DISPLAY" -screen 0 1280x1024x24 & diff --git a/.github/bin/determine-version.sh b/.github/bin/determine-version.sh new file mode 100755 index 000000000..076e7a77b --- /dev/null +++ b/.github/bin/determine-version.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -eo pipefail + +echo "::group::Determining new prerelease version" + +NAME=$(npm pkg get name | tr -d '"') +LATEST_VERSION=$(npm pkg get version | tr -d '"') + +SHORT_SHA=$(echo "${GITHUB_SHA}" | cut -c1-7) +NEW_VERSION="$LATEST_VERSION-canary.${SHORT_SHA}" + +echo "Latest version in package.json: $LATEST_VERSION" +echo "New prerelease version: $NEW_VERSION" + +echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" +echo "name=$NAME" >> "$GITHUB_OUTPUT" + +echo "::endgroup::" diff --git a/.github/bin/validate-npm-deploy.sh b/.github/bin/validate-npm-deploy.sh new file mode 100755 index 000000000..0e2029b5f --- /dev/null +++ b/.github/bin/validate-npm-deploy.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +set -eo pipefail + +if [ -z "$PACKAGE_NAME" ] || [ -z "$VERSION" ]; then + echo "::error::PACKAGE_NAME and VERSION environment variables must be set." + exit 1 +fi + +NPM_ROOT_PATH=$(npm root -g) + +npm install -g "${PACKAGE_NAME}@${VERSION}" || { + echo "::error::✗ Failed to install package: ${PACKAGE_NAME}@${VERSION}" + exit 1 +} + +cd "$NPM_ROOT_PATH" || { + echo "::error::✗ Failed to change directory to global npm root: $NPM_ROOT_PATH" + exit 1 +} + +node -pe "window={}; document={}; require('${PACKAGE_NAME}');" || { + echo "::error::✗ Failed to import CommonJS module for package: ${PACKAGE_NAME}" + exit 1 +} + +cd "${NPM_ROOT_PATH}/${PACKAGE_NAME}" || { + echo "::error::✗ Failed to change directory to package path: ${NPM_ROOT_PATH}/${PACKAGE_NAME}" + exit 1 +} + +types=$(node -pe "require('./package.json').types") +if [ "$types" == "undefined" ] +then + types=$(node -pe "require('./package.json').typings") +fi +if [ "$types" != "undefined" ] && [ ! -f "$types" ] +then + echo "::error::The types file is missing" + exit 1; +fi +echo "Types file '$types' is present in the package" diff --git a/.github/bin/validate-package.mjs b/.github/bin/validate-package.mjs new file mode 100755 index 000000000..69a797fa7 --- /dev/null +++ b/.github/bin/validate-package.mjs @@ -0,0 +1,440 @@ +#!/usr/bin/env node +/** + * @fileoverview Validates the package before publishing. + * This script performs several checks to ensure the package + * is correctly set up, including: + * - Verifying the existence of files listed in `package.json`'s `files` array. + * - Ensuring the package can be imported using both ESM `import` and CommonJS `require()`. + * - Validating Subresource Integrity (SRI) hashes for the built files. + * + * The script generates a summary report compatible with + * GitHub Actions, providing detailed feedback on each + * validation step. + * + * Running this script locally has a few implications to be + * aware of: + * 1. It links and unlinks the package globally. So this + * could impact other workspaces where current links are used. + * 2. To test the step summary, set the `GITHUB_STEP_SUMMARY` + * environment variable to a file path. If this file does not + * exist, it will be created. + */ + +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { access, appendFile, readFile } from 'node:fs/promises'; +import { execSync } from 'node:child_process'; +import pkg from '../../package.json' with { type: 'json' }; + +const isDebug = process.env.DEBUG === 'true'; +const repoRoot = resolve(import.meta.dirname, '..', '..'); +/** + * Start the exit code at 0 for a successful run. If any checks + * fail, we increment by 1 for each failure. When every check is done, + * we exit with the final exit code. + * + * This means our exit code informs us of how many failures happened. + * + * For anyone unfamiliar with exit codes in shell programs, + * an exit code of `0` means success, and any non-zero exit code + * means failure. + * + * Special note as well, in theory this _could_ go above `255`, + * causing the actual exit code to wrap around back to `0` and + * keep counting. But, if we have that many checks in here down + * the road then all the validation will need a major refactor. + */ +let exitCode = 0; +const missing = []; +const summaryFile = process.env.GITHUB_STEP_SUMMARY; +let summary = `# Package Validation + +
+
Package Name
+
${pkg.name}
+
Package Version
+
${pkg.version}
+
License
+
${pkg.license}
+
+ +`; + +console.group('Package Information'); +console.log('Name:', pkg.name); +console.log('Version:', pkg.version); +console.log('License:', pkg.license); +console.groupEnd(); + +/** + * Checks if a file or folder exists on the filesystem. + * + * @param {string} path - The path to check (relative to repo root) + * @returns {Promise} True if the path exists, false otherwise + */ +const exists = async path => { + const absolutePath = resolve(repoRoot, path); + try { + await access(absolutePath); + return true; + } catch { + return false; + } +}; + +/** + * Appends text to the GitHub Actions step summary file if it + * exists. This is mostly useful for local testing where the + * summary file may not be set. However if we want to set it + * for testing, we can. + * + * Since we build the summary in chunks, this function + * appends the current summary and then clears it for the + * next section. + * + * @param {string} text - The text to append to the summary file + * @returns {Promise} + */ +const appendToSummaryFile = async text => { + if (summaryFile) { + await appendFile(summaryFile, text); + summary = ''; + } +}; + +/** + * Verifies that all files and folders listed in the `files` + * array of `package.json` exist in the repository. + */ +const fileExistenceCheck = async () => { + summary += ` +\n## File Existence Check + +The following results table shows the status of files and folders +listed in the \`files\` array of \`package.json\`. + +> [!NOTE] +> This check only validates the existence of files and folders +> defined. It does not validate the contents. Thus a folder +> could exist but be empty and still pass this check. Or +> a file could exist but have incorrect syntax. + +| File | Status |\n|------|--------| +`; + + console.log('Checking for existence of package files:'); + + for (const file of pkg.files) { + if (await exists(file)) { + console.info(`✓ ${file}`); + summary += `| \`${file}\` | ✓ Found |\n`; + } else { + console.error(`✗ ${file}`); + summary += `| \`${file}\` | ✗ Missing |\n`; + missing.push(file); + } + } + + await appendToSummaryFile(summary); + + if (missing.length > 0) { + await appendToSummaryFile( + `\n**ERROR: Missing files: ${missing.join(', ')}**\n` + ); + console.error(`::error::Missing files: ${missing.join(', ')}`); + exitCode++; + } +}; + +/** + * Validates that the main package file can be loaded via + * CommonJS require. This ensures backward compatibility + * for projects using CommonJS. + */ +const validateCommonJS = async () => { + summary += `\n## CommonJS Compatibility Check + +This check validates that the main package file can be loaded +using CommonJS \`require()\`, ensuring backward compatibility. + +| File | Status | Version |\n|------|--------|--------| +`; + + const require = createRequire(import.meta.url); + + console.log('Validating CommonJS compatibility:'); + + try { + const axe = require(`${pkg.name}`); + + if (!axe || typeof axe !== 'object') { + throw new Error('Module did not export an object'); + } + + if (!axe.version) { + throw new Error('Missing version property'); + } + + console.info(`✓ ${pkg.name} (CommonJS)`); + summary += `| \`${pkg.name}\` | ✓ CommonJS Compatible | ${axe.version} |\n`; + } catch (error) { + console.error(`✗ ${pkg.name} (CommonJS):`, error.message); + summary += `| \`${pkg.name}\` | ✗ CommonJS Failed | Not Found |\n`; + summary += `\n\`\`\`\n${error.message}\n\`\`\`\n`; + exitCode++; + } + + await appendToSummaryFile(summary); +}; + +/** + * Validates that the package and all files listed in the + * `files` array of `package.json` can be imported using + * ESM `import` statements. + */ +const validateImportable = async () => { + summary += `\n## Importable Check + +This check attempts to import the package. As well as all +defined files in the \`files\` array of \`package.json\`. + +> [!NOTE] +> This check fails anything that resolves to \`node_modules\`, +> this is because \`axe-core\` should be linked before +> this is called. When \`exports\` can be added to the +> package definition, then we can self reference imports and +> the link will no longer be required. + +| File | Status | Version |\n|------|--------|--------| +`; + + const importTargets = [...pkg.files.map(file => `${pkg.name}/${file}`)]; + let anyCaught = false; + + console.log('Validating package files are importable:'); + + try { + const axe = await import(pkg.name); + console.info(`✓ ${pkg.name}`); + + if (!axe.default?.version) { + throw new Error('Missing version property'); + } + + summary += `| \`${pkg.name}\` | ✓ Importable | ${axe.default.version} |\n`; + } catch { + console.error(`✗ ${pkg.name}`); + summary += `| \`${pkg.name}\` | ✗ Not Importable | Not Found |\n`; + anyCaught = true; + } + + for (const target of importTargets) { + // Skip things that can't be imported directly + // One day we can hopefully import anything as bytes to validate. + // Ref: https://github.com/tc39/proposal-import-bytes + if ( + target.endsWith('.txt') || + target.endsWith('/') || + target.endsWith('.d.ts') + ) { + continue; + } + + // `import.meta.resolve` is used here to determine + // where the import would be resolved from, following + // any symlinks. Since this package is linked, it + // should never have `node_modules` in the resolved + // path. It *could* happen if a dev is writing code + // within a parent folder named as such, but that is + // unsafe anyways. + // ------------------------------------------------- + // If this is ever setup to run in the post-deploy + // test, then this will cause issues as that runs + // from this folder specifically. + if (import.meta.resolve(target).includes('node_modules')) { + exitCode++; + summary += `| \`${target}\` | ✗ Resolves to node_modules |\n`; + console.error(`✗ ${target} resolves to node_modules`); + continue; + } + + try { + let version = ''; + if (target.endsWith('.json')) { + const data = await import(target, { with: { type: 'json' } }); + version = Object.keys(data.default).at(-1); + } else { + const axe = await import(target); + + if (!axe.default?.version) { + throw new Error('Missing version property'); + } + + version = axe.default.version; + } + console.info(`✓ ${target}`); + summary += `| \`${target}\` | ✓ Importable | ${version} |\n`; + } catch (error) { + console.error(`✗ ${target}`); + summary += `| \`${target}\` | ✗ Not Importable | Not Found |\n`; + summary += `\n\`\`\`\n${error.message}\n\`\`\`\n`; + anyCaught = true; + } + } + + if (anyCaught) { + exitCode++; + } + + await appendToSummaryFile(summary); +}; + +/** + * When a PR targets `master` or a `release-*` branch, + * or these branches are pushed to, we run SRI validation. + * Otherwise, it is skipped since the SRI hashes are only + * updated when releasing. + * + * The history file is deprecated. However, until it is removed + * we should be prudent and continue to validate it. + */ +const validateSriHashes = async () => { + const currentBranch = + process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || ''; + + if (!/^release-.+/.test(currentBranch) && currentBranch !== 'master') { + console.log(`Skipping SRI validation (current branch: ${currentBranch})`); + return; + } + + summary += `\n## Subresource Integrity Check + +This check validates the current build against the SRI hash +for the version defined in \`sri-history.json\`. + +| File | Status | +|------|--------| +`; + + const sriHistory = await import(`${pkg.name}/sri-history.json`, { + with: { type: 'json' } + }); + const expectedSri = sriHistory.default[pkg.version]; + // calculate the SRI hash for `axe.js` and `axe.min.js` + // Using `sri-toolbox` as that is what is used in the build process + const { generate } = await import('sri-toolbox'); + + const filesToCheck = [ + { + name: 'axe.js', + path: fileURLToPath(import.meta.resolve(`${pkg.name}/axe.js`)) + }, + { + name: 'axe.min.js', + path: fileURLToPath(import.meta.resolve(`${pkg.name}/axe.min.js`)) + } + ]; + const mismatches = []; + + for (const file of filesToCheck) { + const calculatedSri = generate( + { algorithms: ['sha256'] }, + await readFile(file.path) + ); + + console.log(`Expected SRI for ${file.name}:`, expectedSri[file.name]); + console.log(`Calculated SRI for ${file.name}:`, calculatedSri); + if (calculatedSri !== expectedSri[file.name]) { + console.error(`✗ ${file.name}`); + summary += `| \`${file.name}\` | ✗ Invalid SRI |\n`; + mismatches.push({ + name: file.name, + expected: expectedSri[file.name], + calculated: calculatedSri + }); + continue; + } + + console.info(`✓ ${file.name}`); + summary += `| \`${file.name}\` | ✓ Valid SRI |\n`; + } + + if (mismatches.length > 0) { + summary += `\n### SRI Mismatches\n\n`; + + for (const mismatch of mismatches) { + summary += `**${mismatch.name}:**\n`; + summary += `- Expected: \`${mismatch.expected}\`\n`; + summary += `- Calculated: \`${mismatch.calculated}\`\n\n`; + } + + exitCode++; + } + + await appendToSummaryFile(summary); +}; + +// Start running checks that don't require linking first. +await fileExistenceCheck(); + +/** + * @type {import('child_process').ExecSyncOptionsWithBufferEncoding} + */ +const execOptions = { + cwd: repoRoot, + stdio: isDebug ? 'inherit' : 'pipe', + timeout: 200000 +}; + +console.log('Creating npm link for package validation...'); + +try { + // Link the package globally, then update the package + // internally to use the linked version. + // This is needed because we don't have `exports` defined + // yet, so self referencing imports won't work. + // We also have a circular dependency on the package. + // That means if we try to resolve the import without + // linking, it will resolve the version in `node_modules` + // from npm. + execSync('npm link', execOptions); + execSync(`npm link ${pkg.name}`, execOptions); + + // Run any checks that require the package to reference itself. + await validateCommonJS(); + await validateImportable(); + await validateSriHashes(); +} catch (error) { + console.error('Failed to create npm link:', error.message); + await appendToSummaryFile(` + ## Failed to create npm link + +
Click to expand error details + + \n\`\`\`\n${error.message}\n\`\`\`\n + +
+ + This failure prevented running critical validation checks. + Therefore the entire validation has failed. + `); + console.error(`Failed to create npm link: ${error.message}`); + exitCode++; +} + +console.log('Removing npm link...'); +try { + execSync(`npm unlink ${pkg.name}`, execOptions); + execSync('npm unlink -g', execOptions); +} catch (error) { + // Not a hard failure if unlinking fails since all these + // checks are last. As long as they completed fine, + // validation is acceptable. + // This is more for when running locally to test if + // something goes wrong. As the developer's machine state + // is impacted and they need to know about it. + console.error('Failed to remove npm link:', error.message); +} + +process.exit(exitCode); diff --git a/.github/bin/wait-for-npm-ready.sh b/.github/bin/wait-for-npm-ready.sh new file mode 100755 index 000000000..245f8abf7 --- /dev/null +++ b/.github/bin/wait-for-npm-ready.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -eo pipefail + +if [ -z "$VERSION" ] || [ -z "$PACKAGE_NAME" ]; then + echo "✗ ERROR: VERSION and PACKAGE_NAME environment variables must be set." + exit 1 +fi + +SLEEP_SECONDS=${SLEEP_SECONDS:-10} +MAX_ATTEMPTS=${MAX_ATTEMPTS:-30} + +echo "::group::Waiting for ${PACKAGE_NAME}@${VERSION} to be available on npm registry..." + +for i in $(seq 1 "$MAX_ATTEMPTS"); do + echo "Attempt $i of $MAX_ATTEMPTS..." + + if npm view "${PACKAGE_NAME}@${VERSION}" version > /dev/null 2>&1; then + PUBLISHED_VERSION=$(npm view "${PACKAGE_NAME}@${VERSION}" version) + echo "✓ Package ${PACKAGE_NAME}@${PUBLISHED_VERSION} is now available on npm!" + echo "::endgroup::" + exit 0 + fi + + if [ "$i" -lt "$MAX_ATTEMPTS" ]; then + echo "Package not yet available, waiting ${SLEEP_SECONDS} seconds..." + sleep "$SLEEP_SECONDS" + fi +done + +echo "✗ Timeout: Package ${PACKAGE_NAME}@${VERSION} not available after $((MAX_ATTEMPTS * SLEEP_SECONDS)) seconds" +echo "::endgroup::" + +exit 1 diff --git a/.github/bin/wait-for-workflow-success.sh b/.github/bin/wait-for-workflow-success.sh new file mode 100755 index 000000000..7ba2246e5 --- /dev/null +++ b/.github/bin/wait-for-workflow-success.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash + +# This script waits for a specified GitHub Actions workflow to complete successfully. +# Debug mode can be enabled by setting the DEBUG environment variable to "true". +# Exit codes are as follows: +# 0 - Workflow completed successfully +# 1 - Workflow completed with failure +# 2 - Missing required tools on the host system +# 3 - Missing required environment variables for configuration +# 20 - Timeout waiting for workflow to complete + +set -eo pipefail + +if ! command -v jq &> /dev/null; then + echo "::error::jq is not installed. Please install jq to use this script." + exit 2 +fi + +if ! command -v gh &> /dev/null; then + echo "::error::GitHub CLI (gh) is not installed. Please install gh to use this script." + exit 2 +fi + +if [ -z "$REPOSITORY" ]; then + echo "::error::REPOSITORY environment variable must be set." + exit 3 +fi + +if [ -z "$SHA" ]; then + echo "::error::SHA environment variable must be set." + exit 3 +fi + +if [ -z "$WORKFLOW_NAME" ]; then + echo "::error::WORKFLOW_NAME environment variable must be set." + exit 3 +fi + +if [ -z "$BRANCH" ]; then + echo "::error::BRANCH environment variable must be set." + exit 3 +fi + +# When running locally for testing, this might be forgotten to get set. +# Create a temp file just so there is something to write to that will get thrown away. +if [ -z "$GITHUB_STEP_SUMMARY" ]; then + GITHUB_STEP_SUMMARY=$(mktemp) +fi + +echo "Waiting for '$WORKFLOW_NAME' workflow to complete for commit $SHA" + +# If not provided, default to 5 minutes for the job runner to time out. +TIMEOUT_MINUTES=${TIMEOUT_MINUTES:-5} +# Round down if given a fractional number by just removing the decimal portion. +TIMEOUT_MINUTES=${TIMEOUT_MINUTES%.*} +sleep_seconds=30 +max_attempts=$(( (TIMEOUT_MINUTES * 60) / sleep_seconds )) +attempt=0 + +# We *could* do `status=success` as a query parameter. But then we lose visibility +# into "in-progress" for debugging purposes to at least know if it found a run +# while waiting. +# Ref: https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-repository +api_url="repos/$REPOSITORY/actions/runs?head_sha=$SHA&branch=$BRANCH&exclude_pull_requests=true&event=push" + +# This jq filter can seem complicated. So here is the breakdown: +# 1. `.workflow_runs` - Get the array of workflow runs from the API response +# 2. `sort_by(.created_at) | reverse` - Sort the runs by creation date in descending order. Since the API has no guaranteed order. +# 3. `[.[] | select(.name == "'"$WORKFLOW_NAME"'")][0]` - Filter the runs to only include those with the specified workflow name. Then take the first one (most recent) +# 4. `{status: .status, conclusion: .conclusion}` - Extract only the status and conclusion fields. Since we know this is the most recent run, we only care about these fields later. +# 5. `select(. != null)` - Ensure that we only get a result if there is a matching workflow run +jq_filter='.workflow_runs | sort_by(.created_at) | reverse | [.[] | select(.name == "'"$WORKFLOW_NAME"'")][0] | {status: .status, conclusion: .conclusion} | select(. != null)' + +cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_STEP_SUMMARY" +} + +while [ "$attempt" -lt "$max_attempts" ]; do + # Redirect errors to /dev/null to avoid unusable data in the variable in case of failure. + # If we seem to be having issues in CI later, it would be valuable to setup debugging to log to $GITHUB_STEP_SUMMARY. + workflow_data=$(gh api "$api_url" --jq "$jq_filter" 2>"$log_output" || echo "") + + if [ -z "$workflow_data" ]; then + echo "Attempt $((attempt + 1))/$max_attempts - Workflow run not found yet" + else + status=$(echo "$workflow_data" | jq -r '.status') + conclusion=$(echo "$workflow_data" | jq -r '.conclusion') + + echo "Attempt $((attempt + 1))/$max_attempts - Status: $status, Conclusion: $conclusion" + + if [ "$status" = "completed" ]; then + # Write the result to the summary file + function writeResultToSummary() { + cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_STEP_SUMMARY" < [!TIP] +> Re-running this workflow with debug mode enabled will capture API error logs to help diagnose issues. + +> [!WARNING] +> This can typically indicate that GitHub Action runners are experiencing delays. +> Please check the [GitHub Status Page](https://www.githubstatus.com/) for any ongoing incidents. +> If the status is normal, or if it already is, wait a little bit before re-running the workflow. + +> [!CAUTION] +> If another commit is already deployed, then do *not* re-run this deployment workflow. +> Re-running this would cause an older commit to be the next tag. +> If multiple deployments are failed in a row, then re-run them sequentially as the incident is resolved. + +EOF +writeLogToSummary + +echo "::error::Timeout waiting for '$WORKFLOW_NAME' workflow to complete" +exit 20 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b7ba1c567..4ed3712ff 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,6 +14,8 @@ updates: update-types: - 'minor' - 'patch' + cooldown: + default-days: 7 - package-ecosystem: 'npm' directory: '/' @@ -32,13 +34,27 @@ updates: versions: ['>=9.0.0'] - dependency-name: 'chai' versions: ['>=5.0.0'] + - dependency-name: 'conventional-commits-parser' + versions: ['>=6.0.0'] # Prevent Webpack error caused by v0.11+ of esbuild # @see https://github.com/dequelabs/axe-core/issues/3771 - dependency-name: 'esbuild' versions: ['>=0.11.0'] + # Prevent colorjs.io issue caused by >v0.4.3 + # @see https://github.com/dequelabs/axe-core/issues/4428 + - dependency-name: 'colorjs.io' + versions: ['>0.4.3'] + # Still need to support node 18 + - dependency-name: 'glob' + versions: ['>=11.0.0'] + # Use node 4 types for backward compatibility + - dependency-name: '@types/node' + versions: ['>=5.0.0'] groups: # Any updates not caught by the group config will get individual PRs npm-low-risk: update-types: - 'minor' - 'patch' + cooldown: + default-days: 7 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..fda5a9dec --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,197 @@ +# Do not rename this file. The name "deploy.yml" is known to +# npm for trusted OIDC publishing. +name: Deploy + +on: + # Run on push and not `workflow_run` after tests finish. + # Specifically because `workflow_run` only runs from the context + # of the default branch, regardless of which branch triggered the tests. + # That means no non-default branches could deploy. + push: + branches: + - master + - develop + +concurrency: + group: deploy/${{ github.ref_name }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + # Since we can't run against `workflow_run`, we have to + # wait for for the Tests to succeed first before any + # processing can happen. + wait-for-tests: + name: Wait for Tests to Pass + if: github.repository_owner == 'dequelabs' + runs-on: ubuntu-24.04 + permissions: + contents: read + actions: read + statuses: read + timeout-minutes: 15 + steps: + - &checkout + name: Checkout repository + timeout-minutes: 2 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - name: Wait for Tests workflow to complete + timeout-minutes: 13 + env: + SHA: ${{ github.sha }} + REPOSITORY: ${{ github.repository }} + BRANCH: ${{ github.ref_name }} + WORKFLOW_NAME: Tests + DEBUG: ${{ runner.debug == '1' }} + # One minute less than the job timeout to allow for the script to do cleanup work. + TIMEOUT_MINUTES: 12 + GH_TOKEN: ${{ github.token }} + run: ./.github/bin/wait-for-workflow-success.sh + deploy-next: + name: Deploy "next" to npm + needs: wait-for-tests + if: ${{ github.ref_name == 'develop' }} + environment: + name: registry.npmjs.org + permissions: + contents: read + id-token: write # Required for OIDC + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.determine-version.outputs.version }} + packageName: ${{ steps.determine-version.outputs.name }} + steps: + - *checkout + - &setup-node + name: Setup NodeJS + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + registry-url: 'https://registry.npmjs.org' + node-version-file: .nvmrc + cache: npm + - &install-project-deps + name: Install Project Dependencies + shell: bash + run: npm ci + - &build + name: Build + run: | + npm run prepare + npm run build + - name: Determine prerelease version + id: determine-version + run: ./.github/bin/determine-version.sh + - name: Bump version + env: + NEW_VERSION: ${{ steps.determine-version.outputs.version }} + run: npm version "$NEW_VERSION" --no-git-tag-version --ignore-scripts + - &validate-package + name: Validate package is consumable + env: + # Ref: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#runner-context + # Linting shows this context might be invalid, but it shouldn't be per docs. + # Probably something missing in the schema. + DEBUG: ${{ runner.debug == '1' }} + run: node .github/bin/validate-package.mjs + - name: Publish "next" version to npm + run: npm publish --tag=next + validate-next-deploy: + name: Validate Next Deployment + needs: deploy-next + runs-on: ubuntu-24.04 + steps: + - *checkout + - *setup-node + # In theory since this is a new job now, by the time + # this would kick off the package should be available. + # But, to be safe in case of delays in propagation, + # we'll implement a retry mechanism. + - name: Wait for package to be available on npm + env: + VERSION: ${{ needs.deploy-next.outputs.version }} + PACKAGE_NAME: ${{ needs.deploy-next.outputs.packageName }} + run: ./.github/bin/wait-for-npm-ready.sh + - name: Validate installation of "next" version + env: + PACKAGE_NAME: ${{ needs.deploy-next.outputs.packageName }} + VERSION: ${{ needs.deploy-next.outputs.version }} + run: ./.github/bin/validate-npm-deploy.sh + prod-hold: + name: Await approval to deploy to production + needs: wait-for-tests + if: ${{ github.ref_name == 'master' }} + environment: + name: production-hold + runs-on: ubuntu-24.04 + steps: + - name: Awaiting approval to deploy to production + run: echo "Approval granted to proceed to production deployment." + prod-deploy: + name: Deploy stable to npm + needs: prod-hold + if: ${{ needs.prod-hold.result == 'success' }} + environment: + name: registry.npmjs.org + permissions: + contents: read + id-token: write # Required for OIDC + outputs: + version: ${{ steps.get-data.outputs.version }} + packageName: ${{ steps.get-data.outputs.name }} + runs-on: ubuntu-24.04 + steps: + - *checkout + - *setup-node + - *install-project-deps + - *build + - *validate-package + - name: Publish stable version to npm + run: npm publish + - name: Get published package data + id: get-data + run: | + VERSION=$(npm pkg get version | tr -d '"') + NAME=$(npm pkg get name | tr -d '"') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "name=$NAME" >> $GITHUB_OUTPUT + create-github-release: + name: Create GitHub Release + needs: prod-deploy + runs-on: ubuntu-24.04 + permissions: + contents: write # Required to create releases + steps: + - *checkout + - name: Install Release Helper + run: go install gopkg.in/aktau/github-release.v0@latest + - name: Download Release Script + run: curl https://raw.githubusercontent.com/dequelabs/attest-release-scripts/develop/src/node-github-release.sh -s -o ./node-github-release.sh + - name: Make Release Script Executable + run: chmod +x ./node-github-release.sh + - name: Create GitHub Release + run: ./node-github-release.sh + validate-deploy: + name: Validate Deployment + needs: prod-deploy + runs-on: ubuntu-24.04 + steps: + - *checkout + - *setup-node + # In theory since this is a new job now, by the time + # this would kick off the package should be available. + # But, to be safe in case of delays in propagation, + # we'll implement a retry mechanism. + - name: Wait for package to be available on npm + env: + VERSION: ${{ needs.prod-deploy.outputs.version }} + PACKAGE_NAME: ${{ needs.prod-deploy.outputs.packageName }} + run: ./.github/bin/wait-for-npm-ready.sh + - name: Validate installation of stable version + env: + PACKAGE_NAME: ${{ needs.prod-deploy.outputs.packageName }} + VERSION: ${{ needs.prod-deploy.outputs.version }} + run: ./.github/bin/validate-npm-deploy.sh diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 74898ee2d..7c290510c 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -1,19 +1,27 @@ name: Formatter -on: [pull_request] +on: + pull_request: + branches: + - develop jobs: prettier: + # This conditional prevents running the job on PRs from forks; won't + # have permissions to commit changes, so the job would fail if it ran. + # PRs from forks will instead rely on failing the fmt_check job in test.yml + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.ref }} - name: Install dependencies run: npm ci - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: 16 + node-version-file: .nvmrc cache: 'npm' # Workflows are not allowed to edit workflows. As result, we need to prevent Prettier from formatting them. - name: Prevent workflows from being formatted @@ -21,6 +29,6 @@ jobs: - run: npm run fmt # Prevent the prettierignore change from being committed. - run: git checkout .prettierignore - - uses: stefanzweifel/git-auto-commit-action@8756aa072ef5b4a080af5dc8fef36c5d586e521d # tag=v5 + - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # tag=v5 with: commit_message: ':robot: Automated formatting fixes' diff --git a/.github/workflows/nightly-tests.yml b/.github/workflows/nightly-tests.yml new file mode 100644 index 000000000..ab4cf3fd7 --- /dev/null +++ b/.github/workflows/nightly-tests.yml @@ -0,0 +1,81 @@ +name: Nightly Tests + +on: + schedule: + # Runs every day at 2:17 AM UTC + # Schedules should try to be offset from common times + # to avoid high contention times on GitHub runners. + - cron: '17 2 * * *' + workflow_dispatch: + +env: + CHROME_DEVEL_SANDBOX: /opt/google/chrome/chrome-sandbox + +permissions: + contents: read + +jobs: + browsers: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + DISPLAY: ':99' + steps: + - &checkout + name: Checkout repository + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - name: Install Dependencies + id: install-deps + uses: ./.github/actions/install-deps + with: + nightly: 'true' + start-xvfb: ${{ env.DISPLAY }} + - &build + name: Build + id: build + run: | + npm run prepare + npm run build + - name: Run Firefox Nightly Browser Tests + env: + FIREFOX_NIGHTLY_BIN: ${{ steps.install-deps.outputs.firefox-path }} + run: npm run test -- --browsers FirefoxNightly + - name: Run Chrome Beta Browser Tests + if: ${{ !cancelled() && steps.build.conclusion == 'success' }} + env: + CHROME_BIN: ${{ steps.install-deps.outputs.chrome-path }} + CHROMEDRIVER_BIN: ${{ steps.install-deps.outputs.chromedriver-path }} + run: npm run test -- --browsers Chrome + act: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - *checkout + - &install-deps + name: Install Deps + id: install-deps + uses: ./.github/actions/install-deps + - *build + - name: Install Latest WCAG ACT Rules + run: npm install w3c/wcag-act-rules#main + - name: Run ACT Tests + env: + CHROME_BIN: ${{ steps.install-deps.outputs.chrome-path }} + CHROMEDRIVER_BIN: ${{ steps.install-deps.outputs.chromedriver-path }} + run: npm run test:act + aria-practices: + runs-on: ubuntu-24.04 + timeout-minutes: 7 + steps: + - *checkout + - *install-deps + - *build + - name: Install Latest W3C Aria Practices + run: npm install w3c/aria-practices#main + - name: Run ARIA Practices Tests + env: + CHROME_BIN: ${{ steps.install-deps.outputs.chrome-path }} + CHROMEDRIVER_BIN: ${{ steps.install-deps.outputs.chromedriver-path }} + run: npm run test:apg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba0714f1c..ad707b7f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,12 +7,12 @@ jobs: name: Create release runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: 16 + node-version-file: .nvmrc cache: 'npm' - name: Run release script and open PR run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5cbf7d464..c5bb50be0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,38 +6,244 @@ on: branches: - master - develop + - release-* + +# We want to group to the workflow for each branch. +# Non-push events will be cancelled if a new one is started. +# Push events will run sequentially. This helps ensure that +# the `next` tag isn't out of sync. +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: {} + +env: + CHROME_DEVEL_SANDBOX: /opt/google/chrome/chrome-sandbox jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 5 + lint: + runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - &checkout + name: Checkout repository + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - &setup-node + name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: - node-version: 20 + node-version-file: .nvmrc cache: 'npm' - - run: npm ci - - run: npm run build - - uses: actions/upload-artifact@v4 + - &install-deps-directly + name: Install Dependencies + run: npm ci + - name: Run ESLint + run: npm run eslint + + fmt_check: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - *checkout + - *setup-node + - *install-deps-directly + - run: npm run fmt:check + + build: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - *checkout + - *setup-node + - *install-deps-directly + - &build + name: Build + run: | + npm run prepare + npm run build + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: axe-core path: axe.js retention-days: 1 + test_chrome: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + DISPLAY: :99 + steps: + - *checkout + - &install-deps-with-xvfb + name: Install Deps + uses: ./.github/actions/install-deps + id: install-deps + with: + start-xvfb: ${{ env.DISPLAY }} + - *build + - name: Run Tests Against Chrome + env: + CHROME_BIN: ${{ steps.install-deps.outputs.chrome-path }} + CHROMEDRIVER_BIN: ${{ steps.install-deps.outputs.chromedriver-path }} + run: npm run test -- --browsers Chrome + - name: Run Chrome Integration Tests + env: + CHROME_BIN: ${{ steps.install-deps.outputs.chrome-path }} + CHROMEDRIVER_BIN: ${{ steps.install-deps.outputs.chromedriver-path }} + run: npm run test:integration:chrome + + test_firefox: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + DISPLAY: :99 + steps: + - *checkout + - *install-deps-with-xvfb + - *build + - name: Run Tests Against Firefox + env: + FIREFOX_BIN: ${{ steps.install-deps.outputs.firefox-path }} + run: npm run test -- --browsers Firefox + - name: Run Firefox Integration Tests + env: + FIREFOX_BIN: ${{ steps.install-deps.outputs.firefox-path }} + run: npm run test:integration:firefox + + # Run examples under `doc/examples` + test_examples: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - *checkout + - &install-deps + name: Install Deps + id: install-deps + uses: ./.github/actions/install-deps + - *build + - name: Run Tests Against Examples + run: npm run test:examples + + test_act: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + needs: build + steps: + - *checkout + - *install-deps + - &restore-axe-build + name: Restore axe build + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: axe-core + - name: Run ACT Tests + env: + CHROME_BIN: ${{ steps.install-deps.outputs.chrome-path }} + CHROMEDRIVER_BIN: ${{ steps.install-deps.outputs.chromedriver-path }} + run: npm run test:act + + test_aria_practices: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + needs: build + steps: + - *checkout + - *install-deps + - *restore-axe-build + - name: Run ARIA Practices Tests + env: + CHROME_BIN: ${{ steps.install-deps.outputs.chrome-path }} + CHROMEDRIVER_BIN: ${{ steps.install-deps.outputs.chromedriver-path }} + run: npm run test:apg + + test_locales: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + needs: build + steps: + - *checkout + - *install-deps + - *restore-axe-build + - name: Run Locale Tests + run: npm run test:locales + + test_virtual_rules: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + needs: build + steps: + - *checkout + - *install-deps + - *restore-axe-build + - name: Run Virtual Rules Tests + run: npm run test:virtual-rules + + test_jsdom: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + needs: build + steps: + - *checkout + - *install-deps + - *restore-axe-build + - name: Run jsdom Tests + run: npm run test:jsdom + + build_api_docs: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - *checkout + - *install-deps + - name: Run API Docs Build + run: npm run api-docs + + test_rule_help_version: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + if: ${{ github.ref_name == 'master' }} + steps: + - *checkout + - *install-deps + - name: Run Rule Help Version Tests + run: npm run test:rule-help-version + + sri-validate: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + needs: build + # Run on master and RC branches along with PRs targeting those branches. + if: ${{ github.ref_name == 'master' || startsWith(github.ref_name, 'release-') || github.event.pull_request.base.ref == 'master' || startsWith(github.event.pull_request.base.ref, 'release-') }} + steps: + - *checkout + - *install-deps + - *restore-axe-build + - name: Validate Subresource Integrity + run: npm run sri-validate + test_node: + # The package can't be built on Node 6 anymore, but should still run there. + # So we need to pull in a previous build artifact. + needs: build strategy: matrix: - node: [6, 18, 20] - runs-on: ubuntu-latest - timeout-minutes: 5 - needs: build + node: + - 6 + - 18 + - 20 + - 22 + - 24 + runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - *checkout + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: - node-version: ${{ matrix.node}} - - uses: actions/download-artifact@v4.1.7 - with: - name: axe-core - - run: npm run test:node + node-version: ${{ matrix.node }} + - *restore-axe-build + - name: Run Node.js Tests + run: npm run test:node diff --git a/.github/workflows/update-generated-files.yaml b/.github/workflows/update-generated-files.yaml index 2277e82c6..1968e89c5 100644 --- a/.github/workflows/update-generated-files.yaml +++ b/.github/workflows/update-generated-files.yaml @@ -9,13 +9,17 @@ env: BRANCH_NAME: sync-generated-files jobs: - update_genereated_files: + update_generated_files: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: 'npm' - name: Build run: | @@ -28,7 +32,7 @@ jobs: changes=$(git status --porcelain) # see https://unix.stackexchange.com/a/509498 echo $changes | grep . && echo "Changes detected" || echo "No changes" - echo ::set-output name=changes::"$changes" + echo "changes=$changes" >> "$GITHUB_OUTPUT" - name: Check branch exists id: branchExists @@ -36,7 +40,7 @@ jobs: run: | exists=$(git ls-remote --heads origin $BRANCH_NAME) echo $exists | grep . && echo "Branch '$BRANCH_NAME' already exists on remote" || echo "Branch does not exists in remote" - echo ::set-output name=exists::"$exists" + echo "exists=$exists" >> "$GITHUB_OUTPUT" - name: Create pull request if: ${{ steps.changes.outputs.changes && !steps.branchExists.outputs.exists }} diff --git a/.gitignore b/.gitignore index f72967491..9aacdeb71 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,3 @@ typings/axe-core/axe-core-tests.js # doc doc/rule-descriptions.*.md - diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d692677a2..a65031166 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,46 @@ The files in this project are formatted by [Prettier](https://prettier.io/) and npm run eslint ``` +### When to use HTMLElement vs Virtual Node + +Axe-core uses an internal copy of the HTML page for most things internal to axe-core (called the Virtual Tree). Each HTML element on the page has an equivalent [Virtual Node](./lib/core/base/virtual-node) element, which allows us to cache or normalize information about the HTML element without mutating the actual DOM node. + +Typically we use the Virtual Node when possible, but understand that's not always possible (such as accessing DOM only APIs like `getRootNode` or `getBoundingClientRect`). Furthermore, any function within the [utils directory](./lib/core/utils/) should not use Virtual Nodes. The reason for this is that using Virtual Nodes requires that axe first be [setup](./lib/core/public/setup.js) and create the Virtual Tree. For the most part, util functions are functions that can be run when no Virtual Tree exists. + +### Directory Structure + +Axe-core + +- `standards` - Data objects of the HTML, WCAG, and ARIA standards and specs. +- `core/base` - Defines many of the internal object structures for axe-core (rules, checks, virtual node, etc.). +- `core/imports` - Polyfills or imports from node modules. +- `core/public` - Functions for how axe is run or configured. +- `core/reporters` - The different reporters that configure what data is reported from a run. +- `core/utils` - Utility functions that can be run without needing to set up the Virtual Tree. +- `commons/aria` - Functions that are used to validate ARIA spec or implement ARIA calculations (role, value, etc.). +- `commons/color` - Functions that are used to calculate the foreground or background color of an element. +- `commons/dom` - Functions that access information about the page, DOM, nodes, or its visual state. +- `commons/forms` - Functions for helping with forms and their associated inputs. +- `commons/matches` - Functions used to match a virtual node against a special matcher object. +- `commons/math` - Math functions mainly used for target size and position calculations. +- `commons/standards` - Functions for querying information from `standards`. +- `commons/tables` - Functions for helping with data tables. +- `commons/text` - Functions for calculating the accessible name of an element and handling strings or text related attributes. +- `rules` - JSON metadata files for each axe-core rule as well as their associated matches functions. +- `checks` - JSON metadata files for each axe-core check as well as their associated evaluate functions. + +### Import Requirements + +Which functions can be imported and how they can be imported depends on where you are trying to import them from. The following is a list of directories and their import restrictions: + +- `standards` - Shouldn't use imports as they are just hard coded data objects. +- `core/utils` - Can import other `core/utils`, `core`, `core/base`, or `standards` functions by direct file path (no import from index files). +- `core/public` - Can import other `core/public` by direct file path, or any import allowed by `core/utils` by direct file path. +- `core/imports` - The only files allowed to import from node modules, but shouldn't import from any other directory. +- `core/reporters` Can import from `core/utils` by import from index files. +- `commons` - Can import other `commons` by direct file path, or any import allowed by `core/utils` by import from index files. +- `checks` and `rules` - Can import from any directory by import from index files. + ### Shadow DOM For any proposed changes to rules, checks, commons, or other APIs to be accepted in axe-core, your code must support open Shadow DOM. See [API.md](./doc/API.md) and the [developer guide](./doc/developer-guide.md) for documentation on the available methods and test utilities. You can also look at existing tests for examples using our APIs. @@ -162,7 +202,7 @@ If you need to debug the unit tests in a browser, you can run: npm run test:debug ``` -This will start the Karma server and open up the Chrome browser. Click the `Debug` button to start debugging the tests. You can also navigate to the listed URL in your browser of choice to debug tests using that browser. +This will start the Karma server and open up the Chrome browser. Click the `Debug` button to start debugging the tests. You can either use that browser's debugger or attach an external debugger on port 9765; [a VS Code launch profile](./.vscode/launch.json) is provided. You can also navigate to the listed URL in your browser of choice to debug tests using that browser. Because the amount of tests is so large, it's recommended to debug only a specific set of unit tests rather than the whole test suite. You can use the `testDirs` argument when using the debug command and pass a specific test directory. The test directory names are the same as those used for `test:unit:*`: diff --git a/Gruntfile.js b/Gruntfile.js index 47d9e0787..3b9872799 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -23,9 +23,13 @@ module.exports = function (grunt) { }); } else if (grunt.option('all-lang')) { var localeFiles = require('fs').readdirSync('./locales'); - langs = localeFiles.map(function (file) { - return '.' + file.replace('.json', ''); - }); + langs = localeFiles + .filter(function (file) { + return !file.startsWith('_') && file.endsWith('.json'); + }) + .map(function (file) { + return '.' + file.replace('.json', ''); + }); langs.unshift(''); // Add default } else { langs = ['']; diff --git a/README.md b/README.md index f65c5bd43..505643baa 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,12 @@ # a11y-engine-axe-core -This is a fork of public Axe core repo: -https://github.com/dequelabs/axe-core +[![License](https://img.shields.io/npm/l/axe-core.svg?color=c41)](LICENSE) +[![Version](https://img.shields.io/npm/v/axe-core.svg)](https://www.npmjs.com/package/axe-core) +[![NPM downloads](https://img.shields.io/npm/dw/axe-core.svg?color=080)![](https://img.shields.io/npm/dy/axe-core.svg?color=080&label=)](https://npm-stat.com/charts.html?package=axe-core&from=2017-01-01) +[![Commits](https://img.shields.io/github/commit-activity/y/dequelabs/axe-core.svg)](https://github.com/dequelabs/axe-core/commits/develop) +[![GitHub contributors](https://img.shields.io/github/contributors/dequelabs/axe-core.svg?color=080)](https://github.com/dequelabs/axe-core/graphs/contributors) +[![Join our Slack chat](https://img.shields.io/badge/slack-chat-purple.svg?logo=slack)](https://accessibility.deque.com/axe-community) +[![Package Quality](https://npm.packagequality.com/shield/axe-core.svg)](https://packagequality.com/#?package=axe-core) ## Pull latest changes from public axe-core repo @@ -12,11 +17,169 @@ cd a11y-engine-axe-core # Add public axe-core repo as remote public git remote add public git@github.com:dequelabs/axe-core.git -# Creates a merge commit -git pull public master +Axe-core has different types of rules, for WCAG 2.0, 2.1, 2.2 on level A, AA and AAA as well as a number of best practices that help you identify common accessibility practices like ensuring every page has an `h1` heading, and to help you avoid "gotchas" in ARIA like where an ARIA attribute you used will get ignored. The complete list of rules, grouped WCAG level and best practice, can be found in [doc/rule-descriptions.md](./doc/rule-descriptions.md). # push the changes to internal private repo git push origin master ``` -Axe-core readme: https://github.com/dequelabs/axe-core/blob/v4.9.0/README.md +Now include the javascript file in each of your iframes in your fixtures or test systems: + +```html + +``` + +Now insert calls at each point in your tests where a new piece of UI becomes visible or exposed: + +```js +axe + .run() + .then(results => { + if (results.violations.length) { + throw new Error('Accessibility issues found'); + } + }) + .catch(err => { + console.error('Something bad happened:', err.message); + }); +``` + +## Philosophy + +The web can only become an accessible, inclusive space if developers are empowered to take responsibility for accessibility testing and accessible coding practices. + +Automated accessibility testing is a huge timesaver, it doesn't require special expertise, and it allows teams to focus expert resources on the accessibility issues that really need them. Unfortunately, most accessibility tools are meant to be run on sites and applications that have reached the end of the development process and often don't give clear or consistent results, causing frustration and delays just when you thought your product was ready to ship. + +Axe was built to reflect how web development actually works. It works with all modern browsers, tools, and testing environments a dev team might use. With axe, accessibility testing can be performed as part of your unit testing, integration testing, browser testing, and any other functional testing your team already performs on a day-to-day basis. Building accessibility testing into the early development process saves time, resources, and all kinds of frustration. + +## About axe - our Manifesto + +- Axe is open source. +- It returns zero false positives (bugs notwithstanding). +- It's designed to work on all modern browsers and with whatever tools, frameworks, libraries and environments you use today. +- It's actively supported by [Deque Systems](https://www.deque.com), a major accessibility vendor. +- It integrates with your existing functional/acceptance automated tests. +- It automatically determines which rules to run based on the evaluation context. +- Axe supports in-memory fixtures, static fixtures, integration tests, and iframes of infinite depth. +- Axe is highly configurable. + +## Supported Browsers + +The [axe-core API](doc/API.md) fully supports the following browsers: + +- Microsoft Edge v40 and above +- Google Chrome v42 and above +- Mozilla Firefox v38 and above +- Apple Safari v7 and above +- Internet Explorer v11 (DEPRECATED) + +Support means that we will fix bugs and attempt to test each browser regularly. Only Chrome and Firefox are currently tested on every pull request. + +There is limited support for JSDOM. We will attempt to make all rules compatible with JSDOM but where this is not possible, we recommend turning those rules off. Currently the `color-contrast` rule is known not to work with JSDOM. + +We can only support environments where features are either natively supported or polyfilled correctly. We do not support the deprecated v0 Shadow DOM implementation. + +## Contents of the API Package + +The [axe-core API](doc/API.md) package consists of: + +- `axe.js` - the JavaScript file that should be included in your web site under test (API) +- `axe.min.js` - a minified version of the above file + +## Localization + +Axe can be built using your local language. To do so, a localization file must be added to the `./locales` directory. This file must be named in the following manner: `.json`. To build axe using this locale, instead of the default, run axe with the `--lang` flag, like so: + +`grunt build --lang=nl` + +or equivalently: + +`npm run build -- --lang=nl` + +This will create a new build for axe, called `axe..js` and `axe..min.js`. If you want to build all localized versions, simply pass in `--all-lang` instead. If you want to build multiple localized versions (but not all of them), you can pass in a comma-separated list of languages to the `--lang` flag, like `--lang=nl,ja`. + +To create a new translation for axe, start by running `grunt translate --lang=`. This will create a json file in the `./locales` directory, with the default English text in it for you to translate. Alternatively, you could copy `./locales/_template.json`. We welcome any localization for axe-core. For details on how to contribute, see the Contributing section below. For details on the message syntax, see [Check Message Template](/doc/check-message-template.md). + +To update an existing translation file, re-run `grunt translate --lang=`. This will add new messages used in English and remove messages which were not used in English. + +Additionally, locale can be applied at runtime by passing a `locale` object to `axe.configure()`. The locale object must be of the same shape as existing locales in the `./locales` directory. For example: + +```js +axe.configure({ + locale: { + lang: 'de', + rules: { + accesskeys: { + help: 'Der Wert des accesskey-Attributes muss einzigartig sein.' + } + // ... + }, + checks: { + abstractrole: { + fail: 'Abstrakte ARIA-Rollen dürfen nicht direkt verwendet werden.' + }, + 'aria-errormessage': { + // Note: doT (https://github.com/olado/dot) templates are supported here. + fail: 'Der Wert der aria-errormessage ${data.values}` muss eine Technik verwenden, um die Message anzukündigen (z. B., aria-live, aria-describedby, role=alert, etc.).' + } + // ... + } + } +}); +``` + +### Supported Locales + +Axe-core supports the following locales. Do note that since locales are contributed by our community, they are not guaranteed to include all translations needed in a release. + +- Basque +- Chinese (Simplified) +- Chinese (Traditional) +- Danish +- Dutch +- French +- German +- Greek +- Hebrew +- Italian +- Japanese +- Korean +- Norwegian (Bokmål) +- Polish +- Portuguese (Brazilian) +- Spanish + +## Updates & Security + +Axe-core has a new minor release every 3 to 5 months, which usually introduces new rules and features. We recommend scheduling time to upgrade to these versions. Security updates will be made available for minor version lines up to **18 months old**. + +- See [release and support](doc/release-and-support.md) for details on the frequency of releases, long-term support and recommendations on upgrading axe-core. +- See [backward compatibility](doc/backwards-compatibility-doc.md) for details on the types of changes different releases may introduce. + +## Deque Trademarks Policy + +DEQUE, DEQUELABS, AXE®, and AXE-CORE® are trademarks of Deque Systems, Inc. Use of the Deque trademarks must be in accordance with [Deque's trademark policy](https://www.deque.com/legal/trademarks/). + +## Supported ARIA Roles and Attributes. + +Refer [axe-core ARIA support](./doc/aria-supported.md) for a complete list of ARIA supported roles and attributes by axe. + +## Contributing + +Read the [Proposing Axe-core Rules guide](./doc/rule-proposal.md) + +Read the [documentation on the architecture](./doc/developer-guide.md) + +Read the [documentation on contributing](CONTRIBUTING.md) + +## Projects using axe-core + +[List of projects using axe-core](doc/projects.md) + +## Acknowledgements + +Thanks to Marat Dulin for his [css-selector-parser](https://www.npmjs.com/package/css-selector-parser) implementation which is included for shadow DOM support. Another thank you to the [Slick Parser](https://github.com/mootools/slick/blob/master/Source/Slick.Parser.js) implementers for their contribution, we have used some of their algorithms in our shadow DOM support code. Thanks to Lea Verou and Chris Lilley for their [colorjs.io](https://colorjs.io/) library which we have used for converting between color formats. + +## Licenses + +Axe-core is distributed under the [Mozilla Public License, version 2.0](LICENSE). It comes bundled with several dependencies which are distributed under their own terms. (See [LICENSE-3RD-PARTY.txt](LICENSE-3RD-PARTY.txt)) diff --git a/axe.d.ts b/axe.d.ts index 8ecd9de75..82722edd8 100644 --- a/axe.d.ts +++ b/axe.d.ts @@ -70,16 +70,19 @@ declare namespace axe { | LabelledShadowDomSelector | LabelledFramesSelector; type SelectorList = Array | NodeList; + type ContextProp = Selector | SelectorList; type ContextObject = | { - include: Selector | SelectorList; - exclude?: Selector | SelectorList; + include: ContextProp; + exclude?: ContextProp; } | { - exclude: Selector | SelectorList; - include?: Selector | SelectorList; + exclude: ContextProp; + include?: ContextProp; }; - type ElementContext = Selector | SelectorList | ContextObject; + type ContextSpec = ContextProp | ContextObject; + /** Synonym to ContextSpec */ + type ElementContext = ContextSpec; type SerialSelector = | BaseSelector @@ -140,15 +143,19 @@ declare namespace axe { iframes?: boolean; elementRef?: boolean; frameWaitTime?: number; - preload?: boolean; + preload?: boolean | PreloadOptions; performanceTimer?: boolean; pingWaitTime?: number; } + interface PreloadOptions { + assets: string[]; + timeout?: number; + } interface AxeResults extends EnvironmentData { toolOptions: RunOptions; passes: Result[]; violations: Result[]; - incomplete: Result[]; + incomplete: IncompleteResult[]; inapplicable: Result[]; } interface Result { @@ -160,6 +167,9 @@ declare namespace axe { tags: TagValue[]; nodes: NodeResult[]; } + interface IncompleteResult extends Result { + error?: Omit; + } interface NodeResult { html: string; impact?: ImpactValue; @@ -197,6 +207,21 @@ declare namespace axe { fail: string | { [key: string]: string }; incomplete?: string | { [key: string]: string }; } + interface RuleError { + name: string; + message: string; + stack: string; + ruleId?: string; + method?: string; + cause?: SerialError; + errorNode?: SerialDqElement; + } + interface SerialError { + message: string; + stack: string; + name: string; + cause?: SerialError; + } interface CheckLocale { [key: string]: CheckMessages; } @@ -335,6 +360,9 @@ declare namespace axe { interface DqElement extends SerialDqElement { element: Element; toJSON(): SerialDqElement; + } + interface DqElementConstructor { + new (elm: Element, options?: { absolutePaths?: boolean }): DqElement; mergeSpecs( childSpec: SerialDqElement, parentSpec: SerialDqElement @@ -358,23 +386,25 @@ declare namespace axe { frameContext: FrameContextObject; } - interface RawCheckResult extends Omit { + interface RawCheckResult + extends Omit { relatedNodes?: Array; + impact?: ImpactValue; } - interface RawNodeResult { + interface RawNodeResult { node: SerialDqElement | DqElement; any: RawCheckResult[]; all: RawCheckResult[]; none: RawCheckResult[]; - impact: ImpactValue | null; + impact: ImpactValue | undefined; result: T; } interface RawResult extends Omit { inapplicable: Array; passes: RawNodeResult<'passed'>[]; - incomplete: RawNodeResult<'incomplete'>[]; + incomplete: RawNodeResult<'cantTell'>[]; violations: RawNodeResult<'failed'>[]; pageLevel: boolean; result: 'failed' | 'passed' | 'incomplete' | 'inapplicable'; @@ -398,6 +428,42 @@ declare namespace axe { boundingClientRect: DOMRect; } + type GridCell = VirtualNode[]; + + interface Grid { + container: VirtualNode | null; + cells: unknown; // opaque implementation detail + boundaries?: DOMRect; + toGridIndex(num: number): number; + getCellFromPoint(point: { x: number; y: number }): GridCell; + loopGridPosition( + gridPosition: DOMRect, + callback: (gridCell: GridCell, pos: { row: number; col: number }) => void + ): void; + getGridPositionOfRect( + rect: { top: number; right: number; bottom: number; left: number }, + margin?: number + ): DOMRect; + } + + interface CustomNodeSerializer { + toSpec: (dqElm: DqElement) => T; + mergeSpecs: (nodeSpec: T, parentFrameSpec: T) => T; + } + + interface NodeSerializer { + update: (serializer: CustomNodeSerializer) => void; + toSpec: (node: Element | VirtualNode) => SerialDqElement; + dqElmToSpec: ( + dqElm: DqElement | SerialDqElement, + options?: RunOptions + ) => SerialDqElement; + mergeSpecs: ( + nodeSpec: SerialDqElement, + parentFrameSpec: SerialDqElement + ) => SerialDqElement; + } + interface Utils { getFrameContexts: ( context?: ElementContext, @@ -406,15 +472,29 @@ declare namespace axe { shadowSelect: (selector: CrossTreeSelector) => Element | null; shadowSelectAll: (selector: CrossTreeSelector) => Element[]; getStandards(): Required; - DqElement: new ( - elm: Element, - options?: { absolutePaths?: boolean } - ) => DqElement; + isContextSpec: (context: unknown) => context is ContextSpec; + isContextObject: (context: unknown) => context is ContextObject; + isContextProp: (context: unknown) => context is ContextProp; + isLabelledFramesSelector: ( + selector: unknown + ) => selector is LabelledFramesSelector; + isLabelledShadowDomSelector: ( + selector: unknown + ) => selector is LabelledShadowDomSelector; + RuleError: new (options: { + error: Error; + ruleId?: string; + method?: string; + errorNode?: SerialDqElement; + }) => RuleError; + serializeError: (error: Error) => SerialError; + DqElement: DqElementConstructor; uuid: ( options?: { random?: Uint8Array | Array }, buf?: Uint8Array | Array, offset?: number ) => string | Uint8Array | Array; + nodeSerializer: NodeSerializer; } interface Aria { @@ -424,6 +504,7 @@ declare namespace axe { interface Dom { isFocusable: (node: Element | VirtualNode) => boolean; isNativelyFocusable: (node: Element | VirtualNode) => boolean; + getNodeGrid: (node: Node | VirtualNode) => Grid; } type AccessibleTextOptions = { diff --git a/bower.json b/bower.json index bff620abe..52beb0a4f 100644 --- a/bower.json +++ b/bower.json @@ -1,6 +1,6 @@ { "name": "axe-core", - "version": "4.9.0", + "version": "4.11.0", "deprecated": true, "contributors": [ { diff --git a/build/check-node-version.js b/build/check-node-version.js index 53f7cce71..6aa7fb789 100644 --- a/build/check-node-version.js +++ b/build/check-node-version.js @@ -1,8 +1,12 @@ #! /usr/bin/env node +const fs = require('fs'); +const path = require('path'); -const currentVersion = process.version.replace('v', ''); +const PATH_TO_NVMRC = path.join(__dirname, '..', '.nvmrc'); +const nvmrc = fs.readFileSync(PATH_TO_NVMRC, 'utf8'); +const minimumVersionMajor = parseInt(nvmrc.trim(), 10); -const minimumVersionMajor = 18; +const currentVersion = process.version.replace('v', ''); const currentVersionMajor = parseInt(currentVersion.split('.')[0]); const usesMinimumVersion = currentVersionMajor >= minimumVersionMajor; diff --git a/build/configure.js b/build/configure.js index ca3241fbd..a8a1da734 100644 --- a/build/configure.js +++ b/build/configure.js @@ -16,7 +16,7 @@ var axeVersion = packageJSON.version.substring( ); var descriptionTableHeader = - '| Rule ID | Description | Impact | Tags | Issue Type | ACT Rules |\n| :------- | :------- | :------- | :------- | :------- | :------- |\n'; + '| Rule ID | Description | Impact | Tags | Issue Type | [ACT Rules](https://www.w3.org/WAI/standards-guidelines/act/rules/) |\n| :------- | :------- | :------- | :------- | :------- | :------- |\n'; // prevent striping newline characters from strings (e.g. failure // summaries). must be synced with lib/core/imports/index.js diff --git a/build/next-version.js b/build/next-version.js deleted file mode 100755 index 189311c0a..000000000 --- a/build/next-version.js +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env node - -const fs = require('fs'); -const path = require('path'); -const assert = require('assert'); - -const pkgFile = path.resolve(__dirname, '..', 'package.json'); -const pkg = JSON.parse(fs.readFileSync(pkgFile)); - -const { CIRCLE_SHA1, CIRCLE_BRANCH } = process.env; -assert(CIRCLE_BRANCH, 'CIRCLE_BRANCH environment variable not set'); -assert(CIRCLE_SHA1, 'CIRCLE_SHA1 environment variable not set'); -assert( - CIRCLE_BRANCH === 'develop', - 'This script should only be run from "develop"' -); - -// Shorten the SHA -const GIT_SHA = CIRCLE_SHA1.substr(0, 7); - -// Strip the "dist tag" from the version (if it exists) -const version = pkg.version.replace(/-\w+\.\w+$/, ''); -const nextVersion = `${version}-canary.${GIT_SHA}`; -console.log(nextVersion); diff --git a/build/rule-generator/questions.js b/build/rule-generator/questions.js index dc5e665d0..28f9a511f 100644 --- a/build/rule-generator/questions.js +++ b/build/rule-generator/questions.js @@ -33,7 +33,10 @@ const validateGetRuleName = async input => { throw new Error(`RULE name conflicts with an existing rule's filename.`); } // 3) ensure no rule id overlaps - const ruleSpecs = await glob(`${directories.rules}/**/*.json`); + const ruleSpecs = await glob(`${directories.rules}/**/*.json`, { + posix: true, + absolute: true + }); const axeRulesIds = ruleSpecs.reduce((out, specPath) => { const spec = require(specPath); out.push(spec.id); @@ -62,7 +65,10 @@ const validateGetCheckName = async input => { ); } // 2) ensure no check filename overlaps - const checkSpecs = await glob(`${directories.checks}/**/*.json`); + const checkSpecs = await glob(`${directories.checks}/**/*.json`, { + posix: true, + absolute: true + }); // cannot use `fs.existsSync` here, as we do not know which category of checks to look under const axeChecksFileNames = checkSpecs.map( f => f.replace('.json', '').split('/').reverse()[0] @@ -71,7 +77,10 @@ const validateGetCheckName = async input => { throw new Error('CHECK name conflicts with an existing filename.'); } // 3) ensure no check id overlaps - const ruleSpecs = await glob(`${directories.rules}/**/*.json`); + const ruleSpecs = await glob(`${directories.rules}/**/*.json`, { + posix: true, + absolute: true + }); const axe = require(directories.axePath); const axeChecksIds = ruleSpecs.reduce((out, specPath) => { const spec = require(specPath); diff --git a/build/tasks/configure.js b/build/tasks/configure.js index f4a3c6d26..19a8372ef 100644 --- a/build/tasks/configure.js +++ b/build/tasks/configure.js @@ -18,9 +18,11 @@ module.exports = function (grunt) { }); this.files.forEach(function (file) { - const match = file.dest.auto.match(/\.([a-z]{2,3})\.js/); - if (match) { - options.locale = match[1]; + // locale will always be the 2nd to last part of the + // filename and in the format of "..js" + const parts = file.dest.auto.split('.'); + if (parts.length > 2) { + options.locale = parts[parts.length - 2]; } buildRules(grunt, options, null, function (result) { diff --git a/doc/API.md b/doc/API.md index ea1f04a44..18f4dbb9c 100644 --- a/doc/API.md +++ b/doc/API.md @@ -1,4 +1,4 @@ -# Axe Javascript Accessibility API +# Axe JavaScript Accessibility API ## Table of Contents @@ -23,7 +23,7 @@ 1. [API Name: axe.setup](#api-name-axesetup) 1. [API Name: axe.teardown](#api-name-axeteardown) 1. [API Name: axe.frameMessenger](#api-name-axeframemessenger) - 1. [API name: axe.runPartial / axe.finishRun](#api-name-axerunpartial-/-axefinishrun) + 1. [API name: axe.runPartial / axe.finishRun](#api-name-axerunpartial--axefinishrun) 1. [Virtual DOM Utilities](#virtual-dom-utilities) 1. [API Name: axe.utils.querySelectorAll](#api-name-axeutilsqueryselectorall) 1. [API Name: axe.utils.getRule](#api-name-axeutilsgetrule) @@ -49,8 +49,8 @@ This section gives a quick description of how to use the axe APIs to analyze web The axe API can be used as part of a broader process that is performed on many, if not all, pages of a website. The API is used to analyze web page content and return a JSON object that lists any accessibility violations found. Here is how to get started: 1. Load page in testing system -2. Optionally, set configuration options for the javascript API (`axe.configure`) -3. Call analyze javascript API (`axe.run`) +2. Optionally, set configuration options for the JavaScript API (`axe.configure`) +3. Call analyze JavaScript API (`axe.run`) 4. Either assert against results or save them for later processing 5. Repeat for any inactive or non-rendered content after making it visible @@ -58,7 +58,7 @@ The axe API can be used as part of a broader process that is performed on many, ### Overview -The axe APIs are provided in the javascript file axe.js. It must be included in the web page under test, as well as each `iframe` under test. Parameters are sent as javascript function parameters. Results are returned in JSON format. +The axe APIs are provided in the JavaScript file axe.js. It must be included in the web page under test, as well as each `iframe` under test. Parameters are sent as JavaScript function parameters. Results are returned in JSON format. ### Full API Reference for Developers @@ -72,7 +72,7 @@ For a full listing of API offered by axe, clone the repository and run `npm run ### Axe-core Tags -Each rule in axe-core has a number of tags. These provide metadata about the rule. Each rule has one tag that indicates which WCAG version / level it belongs to, or if it doesn't it have the `best-practice` tag. If the rule is required by WCAG, there is a tag that references the success criterion number. For example, the `wcag111` tag means a rule is required for WCAG 2 success criterion 1.1.1. +Each rule in axe-core has a number of tags. These provide metadata about the rule. Each rule has one tag that indicates which WCAG version / level it belongs to, or if it doesn't, it has the `best-practice` tag. If the rule is required by WCAG, there is a tag that references the success criterion number. For example, the `wcag111` tag means a rule is required for WCAG 2 success criterion 1.1.1. The `experimental`, `ACT`, `TT`, and `section508` tags are only added to some rules. Each rule with a `section508` tag also has a tag to indicate what requirement in old Section 508 the rule is required by. For example `section508.22.a`. @@ -94,6 +94,8 @@ The `experimental`, `ACT`, `TT`, and `section508` tags are only added to some ru | `TT*.*` | Test ID in Trusted Tester | | `EN-301-549` | Rule required under [EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/03.02.01_60/en_301549v030201p.pdf) | | `EN-9.*` | Section in EN 301 549 listing the requirement | +| `RGAAv4` | Rule required under [RGAA](https://accessibilite.numerique.gouv.fr/methode/criteres-et-tests/) | +| `RGAA-*.*.*` | Section in RGAA listing the requirement | | `experimental` | Cutting-edge rules, disabled by default | | `cat.*` | Category mappings used by Deque (see below) | @@ -146,7 +148,7 @@ In this example, we pass in the WCAG 2 A and AA tags into `axe.getRules` to retr ```js [ { - description: "Ensures elements of image maps have alternate text", + description: "Ensure elements of image maps have alternate text", help: "Active elements must have alternate text", helpUrl: "https://dequeuniversity.com/rules/axe/3.5/area-alt?application=axeAPI", ruleId: "area-alt", @@ -162,7 +164,7 @@ In this example, we pass in the WCAG 2 A and AA tags into `axe.getRules` to retr actIds: ['c487ae'] }, { - description: "Ensures ARIA attributes are allowed for an element's role", + description: "Ensure ARIA attributes are allowed for an element's role", help: "Elements must only use allowed ARIA attributes", helpUrl: "https://dequeuniversity.com/rules/axe/3.5/aria-allowed-attr?application=axeAPI", ruleId: "aria-allowed-attr", @@ -182,7 +184,7 @@ In this example, we pass in the WCAG 2 A and AA tags into `axe.getRules` to retr To configure the format of the data used by axe. This can be used to add new rules, which must be registered with the library to execute. -**important**: `axe.configure()` does not communicate configuration calls into iframes. Instead `axe.configure()` must be called with the same argument in each `frame` / `iframe` individually. +**Important**: `axe.configure()` does not communicate configuration calls into iframes. Instead `axe.configure()` must be called with the same argument in each `frame` / `iframe` individually. #### Description @@ -213,7 +215,7 @@ axe.configure({ - `reporter` - Used to set the output format that the axe.run function will pass to the callback function. Can pass a reporter name or a custom reporter function. Valid names are: - `v1` to use the previous version's format: `axe.configure({ reporter: "v1" });` - `v2` to use the current version's format: `axe.configure({ reporter: "v2" });` - - `raw` to return the raw result data without formating: `axe.configure({ reporter: "raw" });` + - `raw` to return the raw result data without formatting: `axe.configure({ reporter: "raw" });` - `raw-env` to return the raw result data with environment data: `axe.configure({ reporter: "raw-env" });` - `no-passes` to return only violation results: `axe.configure({ reporter: "no-passes" });` - `checks` - Used to add checks to the list of checks used by rules, or to override the properties of existing checks @@ -567,7 +569,7 @@ The `resultTypes` option can be used to limit the number of nodes for a rule to After axe has processed all rules normally, it generates a unique selector for all nodes in all rules. This process can be time consuming, especially for pages with lots of nodes. By limiting the nodes to a maximum of one for result types you are not interested in, you can greatly speed up the tail end performance of axe. -Types listed in this option will cause rules that fall under those types to show all nodes. Types _not_ listed will causes rules that fall under one of the missing types to show a maximum of one node. This allows you to still see those results and inform the user of them if appropriate. +Types listed in this option will cause rules that fall under those types to show all nodes. Types _not_ listed will cause rules that fall under one of the missing types to show a maximum of one node. This allows you to still see those results and inform the user of them if appropriate. ```js axe.run( @@ -670,7 +672,7 @@ The results of axe are grouped according to their outcome into the following arr - `passes`: These results indicate what elements passed the rules - `violations`: These results indicate what elements failed the rules - `inapplicable`: These results indicate which rules did not run because no matching content was found on the page. For example, with no video, those rules won't run. -- `incomplete`: Also known as "needs review," these results were aborted and require further testing. This can happen either because of technical restrictions to what the rule can test, or because a javascript error occurred. +- `incomplete`: Also known as "needs review," these results were aborted and require further testing. This can happen either because of technical restrictions to what the rule can test, or because a JavaScript error occurred. Each object returned in these arrays have the following properties: @@ -710,7 +712,6 @@ axe.run(document, function (err, results) { - `passes[0]` ... - - `help` - `"Elements must have sufficient color contrast"` - `helpUrl` - `"https://dequeuniversity.com/courses/html-css/visual-layout/color-contrast"` - `id` - `"color-contrast"` @@ -723,7 +724,6 @@ axe.run(document, function (err, results) { ###### `violations` - `violations[0]` - - `help` - `"