├── .babelrc ├── docker-compose.travis.yml ├── .flowconfig ├── Dockerfile ├── docker-services.yml ├── .gitignore ├── flow-typed └── npm │ ├── flow-bin_v0.x.x.js │ ├── axe-core_vx.x.x.js │ ├── flow-watch_vx.x.x.js │ ├── axe-webdriverjs_vx.x.x.js │ ├── csv-stringify_vx.x.x.js │ ├── flow-typed_vx.x.x.js │ ├── selenium-webdriver_v3.x.x.js │ └── jest_v19.x.x.js ├── manifest.yml ├── lib ├── run-script.js ├── mkdirp-sync.js ├── browser-main.js ├── util.js ├── docker │ ├── process-util.js │ └── entrypoint.js ├── components │ ├── static-page.jsx │ ├── preamble.jsx │ ├── dashboard.jsx │ ├── axe-violations.jsx │ ├── history-sync.jsx │ └── table.jsx ├── github-stats.js ├── github-repos.js ├── cache.js ├── websites.js ├── github.js ├── axe-stats.js ├── webdriver.js └── config.js ├── test ├── __snapshots__ │ └── axe-violations.test.jsx.snap ├── cache.test.js ├── axe-stats.test.js ├── util.test.js ├── axe-violations.test.jsx └── history-sync.test.jsx ├── docker-compose.yml ├── webpack.config.js ├── package.json ├── static └── style.css ├── travis-deploy.sh ├── .travis.yml ├── LICENSE.md ├── retryable-stats.js ├── stats.js └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /docker-compose.travis.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | selenium: 4 | extends: 5 | file: docker-services.yml 6 | service: selenium 7 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | ./cache 3 | 4 | [include] 5 | 6 | [libs] 7 | ./flow-typed 8 | ./node_modules/github/lib/index.js.flow 9 | 10 | [options] 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:7.7.4 2 | 3 | ENV PATH /app/node_modules/.bin:$PATH 4 | 5 | # Needed for Flow. 6 | RUN apt-get update && apt-get install -y ocaml libelf-dev 7 | -------------------------------------------------------------------------------- /docker-services.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | selenium: 4 | image: selenium/standalone-chrome:3.3.0 5 | volumes: 6 | - /dev/shm:/dev/shm 7 | ports: 8 | - 4444:4444 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cache/ 2 | jest/ 3 | node_modules/ 4 | static/.travis.yml 5 | static/vendor/ 6 | static/stats.csv 7 | static/index.html 8 | static/bundle.js 9 | static/records.json 10 | .env 11 | -------------------------------------------------------------------------------- /flow-typed/npm/flow-bin_v0.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 6a5610678d4b01e13bbfbbc62bdaf583 2 | // flow-typed version: 3817bc6980/flow-bin_v0.x.x/flow_>=v0.25.x 3 | 4 | declare module "flow-bin" { 5 | declare module.exports: string; 6 | } 7 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | # This cloud.gov manifest is only currently used for development purposes, 2 | # not deployment. 3 | applications: 4 | - name: a11y 5 | memory: 64M 6 | buildpack: https://github.com/cloudfoundry/staticfile-buildpack.git 7 | env: 8 | FORCE_HTTPS: true 9 | path: static 10 | -------------------------------------------------------------------------------- /lib/run-script.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /** 4 | * Run the given promise-generating function as a main script. If any 5 | * errors are raised by the promise, a traceback will be printed and 6 | * the process will exit with a nonzero exit code. 7 | */ 8 | module.exports = function runScript(main /*: () => Promise */) { 9 | main().catch(err => { 10 | // Note that node will eventually do this by default. 11 | console.log(err); 12 | process.exit(1); 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /test/__snapshots__/axe-violations.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getAxeViolationStats() matches snapshot 1`] = ` 4 | Array [ 5 | Object { 6 | "count": 2, 7 | "helpUrl": "https://info/bar", 8 | "name": "bar", 9 | }, 10 | Object { 11 | "count": 2, 12 | "helpUrl": "https://info/foo", 13 | "name": "foo", 14 | }, 15 | Object { 16 | "count": 1, 17 | "helpUrl": "https://info/baz", 18 | "name": "baz", 19 | }, 20 | ] 21 | `; 22 | -------------------------------------------------------------------------------- /lib/mkdirp-sync.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | module.exports = function(p /*: string */) /*: void */ { 7 | const partsSoFar = []; 8 | 9 | if (/^win/.test(process.platform)) { 10 | throw new Error('this function is not currently compatible with Windows'); 11 | } 12 | 13 | p.split(path.sep).forEach(part => { 14 | partsSoFar.push(part); 15 | 16 | const dirname = '/' + path.join(...partsSoFar); 17 | 18 | if (!fs.existsSync(dirname)) { 19 | fs.mkdirSync(dirname); 20 | } 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | app: 4 | build: . 5 | volumes: 6 | - .:/app 7 | - home:/home/ 8 | - node_modules:/app/node_modules/ 9 | working_dir: /app 10 | entrypoint: node /app/lib/docker/entrypoint.js 11 | command: webpack-dev-server --content-base static/ --host 0.0.0.0 12 | ports: 13 | - 8080:8080 14 | links: 15 | - selenium 16 | environment: 17 | - SELENIUM_REMOTE_URL=http://selenium:4444/wd/hub 18 | - SELENIUM_BROWSER=chrome 19 | - HOST_USER=a11y_user 20 | - USER_OWNED_DIRS=/app/node_modules 21 | selenium: 22 | extends: 23 | file: docker-services.yml 24 | service: selenium 25 | volumes: 26 | node_modules: 27 | home: 28 | -------------------------------------------------------------------------------- /lib/browser-main.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const React = require('react'); 4 | const ReactDOM = require('react-dom'); 5 | 6 | const Dashboard = require('./components/dashboard'); 7 | const { ELEMENT_ID, JSON_FILENAME } = require('./config'); 8 | 9 | const IS_JS_DISABLED = /nojs=on/i.test(window.location.search); 10 | 11 | function render(props) { 12 | ReactDOM.render( 13 | React.createElement(Dashboard, props), 14 | document.getElementById(ELEMENT_ID) 15 | ); 16 | } 17 | 18 | function main() { 19 | const req = new XMLHttpRequest(); 20 | 21 | req.open('GET', JSON_FILENAME); 22 | req.responseType = 'json'; 23 | req.addEventListener('load', () => { 24 | render(req.response); 25 | }); 26 | req.send(null); 27 | } 28 | 29 | if (process.env.APP_ENV == 'browser' && !IS_JS_DISABLED) { 30 | main(); 31 | } 32 | -------------------------------------------------------------------------------- /test/cache.test.js: -------------------------------------------------------------------------------- 1 | const { urlToCacheKey } = require('../lib/cache'); 2 | 3 | describe('urlToCacheKey', () => { 4 | test('works with URLs that have a path ending with "/"', () => { 5 | expect(urlToCacheKey('https://boop.com/')).toEqual([ 6 | 'boop.com', '__index' 7 | ]); 8 | }); 9 | 10 | test('works with URLs that have multiple sub-paths', () => { 11 | expect(urlToCacheKey('https://boop.com/a/b/c')).toEqual([ 12 | 'boop.com', 'a__b__c' 13 | ]); 14 | }); 15 | 16 | test('slugifies parts', () => { 17 | expect(urlToCacheKey('https://blarg.com/fop[')).toEqual([ 18 | 'blarg.com', 'fop' 19 | ]); 20 | }); 21 | 22 | test('slugifies querystring', () => { 23 | expect(urlToCacheKey('https://blarg.com/fop?boop=1')).toEqual([ 24 | 'blarg.com', 'fop_boop1' 25 | ]); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const path = require('path'); 4 | const webpack = require('webpack'); 5 | 6 | const { JS_FILENAME } = require('./lib/config'); 7 | 8 | module.exports = { 9 | entry: './lib/browser-main.js', 10 | resolve: { 11 | extensions: ['.js', '.jsx'] 12 | }, 13 | plugins: [ 14 | new webpack.DefinePlugin({ 15 | 'process.env': { 16 | APP_ENV: JSON.stringify('browser') 17 | } 18 | }) 19 | ], 20 | module: { 21 | loaders: [ 22 | { 23 | test: /\.jsx?$/, 24 | exclude: /(node_modules|bower_components)/, 25 | loader: 'babel-loader', 26 | query: { 27 | presets: ['env', 'react'] 28 | } 29 | } 30 | ], 31 | }, 32 | output: { 33 | path: path.resolve(__dirname, 'static'), 34 | filename: JS_FILENAME, 35 | publicPath: '/' 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /test/axe-stats.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const { 4 | ENABLE_INTERNET_TESTS, 5 | SELENIUM_REMOTE_URL, 6 | SELENIUM_BROWSER 7 | } = require('../lib/config'); 8 | const getWebdriver = require('../lib/webdriver'); 9 | const getAxeStats = require('../lib/axe-stats'); 10 | 11 | describe('getAxeStats()', () => { 12 | const internetIt = (ENABLE_INTERNET_TESTS && 13 | SELENIUM_REMOTE_URL && 14 | SELENIUM_BROWSER) ? it : it.skip; 15 | 16 | // This can take particularly long on Travis. 17 | jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; 18 | 19 | internetIt('works on sites w/ CSP like github.com', async () => { 20 | const d = await getWebdriver(); 21 | const s = await getAxeStats(d, 'https://github.com/18F/a11y-metrics'); 22 | expect(s.url).toBe('https://github.com/18F/a11y-metrics'); 23 | await d.quit(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/util.test.js: -------------------------------------------------------------------------------- 1 | const { shortenUrl } = require('../lib/util'); 2 | 3 | describe('util.shortenUrl()', () => { 4 | it('returns the URL if it does not have hostname and pathname', () => { 5 | expect(shortenUrl('blarg')).toBe('blarg'); 6 | }); 7 | 8 | it('removes protocol', () => { 9 | expect(shortenUrl('http://blarg.com')).toBe('blarg.com'); 10 | }); 11 | 12 | it('trims trailing slash', () => { 13 | expect(shortenUrl('https://blarg.com/')).toBe('blarg.com'); 14 | }); 15 | 16 | it('removes querystring details', () => { 17 | expect(shortenUrl('https://blarg.com/?q=bleh')).toBe('blarg.com'); 18 | }); 19 | 20 | it('removes hash details', () => { 21 | expect(shortenUrl('https://blarg.com/#boop')).toBe('blarg.com'); 22 | }); 23 | 24 | it('removes port details', () => { 25 | expect(shortenUrl('https://blarg.com:8080')).toBe('blarg.com'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const urlParse = require('url').parse; 4 | 5 | /** 6 | * Shorten the given URL to make it more human-readable. Note that 7 | * it discards protocol and querystring information. 8 | */ 9 | exports.shortenUrl = function(url /*: string */) /*: string */ { 10 | const info = urlParse(url); 11 | 12 | if (!(info.hostname && info.pathname)) 13 | return url; 14 | 15 | let short = info.hostname + info.pathname; 16 | 17 | if (/\/$/.test(short)) { 18 | short = short.slice(0, -1); 19 | } 20 | 21 | return short; 22 | }; 23 | 24 | /** 25 | * Compare two strings case-insensitively. Useful for sorting. 26 | */ 27 | exports.cmpStr = function(a /*: string */, b /*: string */) /*: number */ { 28 | a = a.toLowerCase(); 29 | b = b.toLowerCase(); 30 | 31 | if (a < b) { 32 | return -1; 33 | } 34 | 35 | if (a > b) { 36 | return 1; 37 | } 38 | 39 | return 0; 40 | }; 41 | -------------------------------------------------------------------------------- /lib/docker/process-util.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | var child_process = require('child_process'); 4 | 5 | exports.successSync = function(cmdline /*: string */) /*: boolean */ { 6 | try { 7 | child_process.execSync(cmdline, { 8 | stdio: ['ignore', 'ignore', 'ignore'] 9 | }); 10 | return true; 11 | } catch (e) { 12 | return false; 13 | } 14 | } 15 | 16 | exports.runSync = function(cmdline /*: string */) { 17 | child_process.execSync(cmdline, {stdio: [0, 1, 2]}); 18 | } 19 | 20 | exports.spawnAndBindLifetimeTo = function( 21 | command /*: string */, 22 | args /*: Array */ 23 | ) { 24 | var child = child_process.spawn(command, args, { 25 | stdio: [0, 1, 2] 26 | }); 27 | 28 | child.on('close', function(code) { 29 | process.exit(code); 30 | }); 31 | 32 | process.on('SIGTERM', function() { 33 | // Forward the SIGTERM to the child so Docker exits quickly. 34 | child.kill('SIGTERM'); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /lib/components/static-page.jsx: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const React = require('react'); 4 | 5 | const { JS_FILENAME } = require('../config'); 6 | 7 | class StaticPage extends React.Component { 8 | /*:: 9 | props: { 10 | title: string; 11 | html: string; 12 | id: string; 13 | } 14 | */ 15 | 16 | render() { 17 | const html = { 18 | __html: this.props.html 19 | }; 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | {this.props.title} 28 | 29 | 30 |
31 | 32 | 33 | 34 | ); 35 | } 36 | } 37 | 38 | module.exports = StaticPage; 39 | -------------------------------------------------------------------------------- /test/axe-violations.test.jsx: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const { 4 | getAxeViolationStats, 5 | flattenViolations, 6 | uniqueWebpages 7 | } = require('../lib/components/axe-violations'); 8 | 9 | const violation = kind => ({ 10 | kind, 11 | helpUrl: `https://info/${kind}`, 12 | nodeCount: 1 13 | }); 14 | 15 | describe('uniqueWebpages()', () => { 16 | it('filters out records w/ identical homepages', () => { 17 | const records /*: any */ = [ 18 | {website: {homepage: 'a'}}, 19 | {website: {homepage: 'b'}}, 20 | {website: {homepage: 'a'}}, 21 | ]; 22 | expect(uniqueWebpages(records)).toEqual([ 23 | {website: {homepage: 'a'}}, 24 | {website: {homepage: 'b'}}, 25 | ]); 26 | }); 27 | }); 28 | 29 | describe('getAxeViolationStats()', () => { 30 | it('matches snapshot', () => { 31 | expect(getAxeViolationStats([ 32 | violation('foo'), 33 | violation('bar'), 34 | violation('foo'), 35 | violation('bar'), 36 | violation('baz'), 37 | ])).toMatchSnapshot(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /lib/github-stats.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const cache = require('./cache'); 4 | const github = require('./github'); 5 | 6 | const { QUERY } = require('./config'); 7 | 8 | /*:: 9 | export type GithubSearchResults = { 10 | data: { 11 | total_count: number 12 | } 13 | }; 14 | */ 15 | 16 | function getGithubStats( 17 | repo /*: string */ 18 | ) /*: Promise */ { 19 | const [org, name] = repo.split('/'); 20 | 21 | return cache.get(['github', org, name], () => { 22 | console.log(`Fetching GitHub stats for ${repo}.`); 23 | 24 | return github.retry(() => github.api.search.issues({ 25 | q: `repo:${org}/${name} ${QUERY}`, 26 | per_page: 100, 27 | })); 28 | }); 29 | } 30 | 31 | module.exports = getGithubStats; 32 | 33 | if (module.parent === null) { 34 | require('./run-script')(async () => { 35 | const websites = await require('./websites')(); 36 | 37 | for (let website of websites) { 38 | console.log(`Processing ${website.repo}.`); 39 | 40 | await getGithubStats(website.repo); 41 | } 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "axe-core": "^2.1.7", 4 | "babel-core": "^6.24.0", 5 | "babel-loader": "^6.4.1", 6 | "babel-preset-env": "^1.3.2", 7 | "babel-preset-react": "^6.23.0", 8 | "babel-register": "^6.24.0", 9 | "csv-stringify": "^1.0.4", 10 | "dotenv": "^4.0.0", 11 | "github": "^9.2.0", 12 | "react": "^15.4.2", 13 | "react-dom": "^15.4.2", 14 | "selenium-webdriver": "^3.3.0", 15 | "slugify": "^1.1.0", 16 | "uswds": "^1.0.0", 17 | "webpack": "^2.3.3", 18 | "webpack-dev-server": "^2.4.2" 19 | }, 20 | "devDependencies": { 21 | "flow-bin": "^0.42.0", 22 | "flow-typed": "^2.0.0", 23 | "flow-watch": "^1.1.1", 24 | "jest": "^19.0.2" 25 | }, 26 | "scripts": { 27 | "copy:vendor": "mkdir -p static/vendor/uswds && cp -R node_modules/uswds/dist/* static/vendor/uswds/", 28 | "flow:watch": "flow-watch --ignore node_modules/ -e js,jsx --watch '*.jsx' --watch '*.js' --watch .flowconfig", 29 | "build": "yarn copy:vendor && node retryable-stats.js && webpack", 30 | "test": "jest --verbose && flow" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /flow-typed/npm/axe-core_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 0878a42e61d71e170aacd4691aa73ec3 2 | // flow-typed version: <>/axe-core_v^2.1.7/flow_v0.42.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'axe-core' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'axe-core' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'axe-core/axe' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'axe-core/axe.min' { 30 | declare module.exports: any; 31 | } 32 | 33 | // Filename aliases 34 | declare module 'axe-core/axe.js' { 35 | declare module.exports: $Exports<'axe-core/axe'>; 36 | } 37 | declare module 'axe-core/axe.min.js' { 38 | declare module.exports: $Exports<'axe-core/axe.min'>; 39 | } 40 | -------------------------------------------------------------------------------- /flow-typed/npm/flow-watch_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: eb2815285432101505a3eb3f0fcff76a 2 | // flow-typed version: <>/flow-watch_v^1.1.1/flow_v0.42.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'flow-watch' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'flow-watch' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'flow-watch/src/flow-watch' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'flow-watch/src/runFlow' { 30 | declare module.exports: any; 31 | } 32 | 33 | // Filename aliases 34 | declare module 'flow-watch/src/flow-watch.js' { 35 | declare module.exports: $Exports<'flow-watch/src/flow-watch'>; 36 | } 37 | declare module 'flow-watch/src/runFlow.js' { 38 | declare module.exports: $Exports<'flow-watch/src/runFlow'>; 39 | } 40 | -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | @import url(./vendor/uswds/css/uswds.min.css); 2 | 3 | details summary::-webkit-details-marker { 4 | display: none; 5 | } 6 | 7 | details ul { 8 | font-size: 1rem; 9 | margin-bottom: 0; 10 | } 11 | 12 | summary:after { 13 | float: right; 14 | font-family: monospace; 15 | content: "+"; 16 | cursor: pointer; 17 | color: #0071bc; 18 | } 19 | 20 | details[open] summary:after { 21 | content: "-"; 22 | } 23 | 24 | td.axe-violations { 25 | width: 10em; 26 | } 27 | 28 | td.axe-violations aside { 29 | color: crimson; 30 | } 31 | 32 | html { 33 | padding: 0 2em; 34 | } 35 | 36 | .sortable { 37 | display: flex; 38 | align-items: center; 39 | } 40 | 41 | .sortable > .sort-toggler { 42 | flex: 1; 43 | } 44 | 45 | .sort-toggler button, 46 | .sort-toggler button:hover, 47 | .sort-toggler button:active { 48 | background: inherit; 49 | color: inherit; 50 | text-decoration: underline; 51 | padding: 0; 52 | text-align: left; 53 | } 54 | 55 | .sortable > span.sort-indicator { 56 | min-width: 1em; 57 | text-align: right; 58 | } 59 | 60 | .sortable > span.sort-indicator[data-is-sorted="true"]:after { 61 | content: '↑'; 62 | } 63 | 64 | .sortable > span.sort-indicator[data-is-sorted="true"][data-is-descending="true"]:after { 65 | content: '↓'; 66 | } 67 | -------------------------------------------------------------------------------- /travis-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script is based on: 4 | # http://www.steveklabnik.com/automatically_update_github_pages_with_travis_example/ 5 | 6 | # Note that the static files branch (specified by STATIC_BRANCH) must exist 7 | # at the time this script is run. You may want to create an orphan branch 8 | # using the instructions at http://stackoverflow.com/a/4288660/2422398. 9 | 10 | set -o errexit -o nounset 11 | 12 | export SOURCE_BRANCH="master" 13 | export STATIC_BRANCH="static-site" 14 | export REPO="18F/a11y-metrics" 15 | export STATIC_DIR="static" 16 | export USER_NAME="Atul Varma" 17 | export USER_EMAIL="atul.varma@gsa.gov" 18 | 19 | if [ "$TRAVIS_BRANCH" != "$SOURCE_BRANCH" ] 20 | then 21 | echo "This commit was made against the $TRAVIS_BRANCH and not $SOURCE_BRANCH! No deploy!" 22 | exit 0 23 | fi 24 | 25 | rev=$(git rev-parse --short HEAD) 26 | 27 | cp .travis.yml $STATIC_DIR 28 | 29 | cd $STATIC_DIR 30 | 31 | git init 32 | git config user.name $USER_NAME 33 | git config user.email $USER_EMAIL 34 | 35 | git remote add upstream "https://$GITHUB_API_TOKEN@github.com/$REPO.git" 36 | git fetch upstream 37 | git reset upstream/$STATIC_BRANCH 38 | 39 | touch . 40 | 41 | git add -A . 42 | git commit -m "rebuild static site at ${rev}" 43 | git push -q upstream HEAD:$STATIC_BRANCH 44 | -------------------------------------------------------------------------------- /lib/github-repos.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const cache = require('./cache'); 4 | const github = require('./github'); 5 | 6 | /*:: 7 | export type GithubRepo = { 8 | name: string, 9 | full_name: string, 10 | pushed_at: string, 11 | homepage: string | null, 12 | open_issues_count: number, 13 | stargazers_count: number, 14 | fork: boolean 15 | }; 16 | 17 | type GithubRepoResponse = { 18 | data: Array 19 | }; 20 | */ 21 | 22 | function getGithubReposPage( 23 | org /*: string */, 24 | page /*: number */ 25 | ) /*: Promise */ { 26 | return cache.get(['github', `${org}.${page}`], () => { 27 | console.log(`Fetching GitHub repos for ${org} (page ${page}).`); 28 | 29 | return github.retry(() => github.api.repos.getForOrg({ 30 | org, 31 | page: page, 32 | per_page: 100, 33 | })); 34 | }); 35 | } 36 | 37 | async function getGithubRepos( 38 | org /*: string */ 39 | ) /*: Promise> */ { 40 | let repos = []; 41 | let page = 1; 42 | let done = false; 43 | 44 | while (!done) { 45 | let res = await getGithubReposPage(org, page); 46 | repos.push(...res.data); 47 | if (!github.api.hasNextPage(res)) { 48 | done = true; 49 | } 50 | page++; 51 | } 52 | 53 | return repos; 54 | } 55 | 56 | module.exports = getGithubRepos; 57 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | branches: 2 | except: 3 | - static-site 4 | language: node_js 5 | node_js: 6 | - "7" 7 | cache: yarn 8 | addons: 9 | apt: 10 | packages: 11 | - ocaml 12 | - libelf-dev 13 | sudo: required 14 | services: 15 | - docker 16 | before_install: 17 | - docker-compose -f docker-compose.travis.yml up -d 18 | script: 19 | - flow start 20 | - yarn test 21 | - flow stop 22 | - echo "Travis event type is $TRAVIS_EVENT_TYPE" 23 | - if [[ "$TRAVIS_EVENT_TYPE" = "cron" ]]; then yarn build && bash travis-deploy.sh; fi 24 | env: 25 | global: 26 | - ENABLE_INTERNET_TESTS=yup 27 | - SELENIUM_REMOTE_URL=http://localhost:4444/wd/hub 28 | - SELENIUM_BROWSER=chrome 29 | - secure: "QiG9F18miRg5NmPHngudfh1MtPH0yWiCpMBob0NbGbbrtTvTeudbF6jd/V67Lt+AEOz5A/cpDJ7WScuieQQDuIqNyAFSul2Fsd0AxYOa/EyKoWvTyaoFa6gYTp/ENsJBSOPe/11Ca8XeHF9LQ7F+NX15WmdJOTBucrNfzibXS/DwwIeNPq+mxvQ2SRdTBTYgl4J/7xcW07iB7f/AEPkGn5QVg07xcbbtdNCxytyPTxVyhNSOIQUG1wCnx1bM9pXPshNYKwa94xTZU128b2i9MLStlpNvwEvPOOX8E4ayh0UwmrRq9irV6YIbgSeY1JcMdnLLQvBXakKgJuAsPIqhPGTBMxWwSLop81flT9GcJIXj0shFZGyDlkfRMkHlRHz9HhqA0ccE0THCoHSx86skSR38cOEbkmuxX4xJQOmODMsGYANyUXz8kBIldkbPy5FUsaHGj3UWRBacqf/V7O4u9LhwaXRuQoWt3JSjv3xs9opMUvPtIoWThzMTXSXmh+QD4R2OD9fYfYPV3/Y60S+jAfvhtfy8xGSLWcgsi95/j880SriW3XgZIgDkPS1JJNI72e8qWkkkgOrXSdt1nU/Yq5LmVoDThICdvCVZOeKI/uG+IsOe9nFFBs2fwnFeAmgQ5E/NK3Guwwy136QPOb83AKTyCjnuObmirolVd7Ayr6w=" 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | As a work of the United States Government, this project is in the 2 | public domain within the United States. 3 | 4 | Additionally, we waive copyright and related rights in the work 5 | worldwide through the CC0 1.0 Universal public domain dedication. 6 | 7 | ## CC0 1.0 Universal Summary 8 | 9 | This is a human-readable summary of the 10 | [Legal Code (read the full text)](https://creativecommons.org/publicdomain/zero/1.0/legalcode). 11 | 12 | ### No Copyright 13 | 14 | The person who associated a work with this deed has dedicated the work to 15 | the public domain by waiving all of his or her rights to the work worldwide 16 | under copyright law, including all related and neighboring rights, to the 17 | extent allowed by law. 18 | 19 | You can copy, modify, distribute and perform the work, even for commercial 20 | purposes, all without asking permission. 21 | 22 | ### Other Information 23 | 24 | In no way are the patent or trademark rights of any person affected by CC0, 25 | nor are the rights that other persons may have in the work or in how the 26 | work is used, such as publicity or privacy rights. 27 | 28 | Unless expressly stated otherwise, the person who associated a work with 29 | this deed makes no warranties about the work, and disclaims liability for 30 | all uses of the work, to the fullest extent permitted by applicable law. 31 | When using or citing the work, you should not imply endorsement by the 32 | author or the affirmer. 33 | -------------------------------------------------------------------------------- /lib/cache.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const urlParse = require('url').parse; 6 | const slugify = require('slugify'); 7 | 8 | const mkdirpSync = require('./mkdirp-sync'); 9 | 10 | const CACHE_DIR = path.join(__dirname, '..', 'cache'); 11 | 12 | /** 13 | * Get a cache key. If it doesn't exist, the given getter will be 14 | * called to provide a value, and that value will be called. 15 | * 16 | * The getter must return a Promise that resolves to data. 17 | */ 18 | exports.get = function getKey( 19 | key /*: Array */, 20 | getter /*: () => Promise */ 21 | ) /*: Promise */ { 22 | const filename = path.join(CACHE_DIR, ...key) + '.json'; 23 | 24 | if (fs.existsSync(filename)) { 25 | return Promise.resolve(JSON.parse(fs.readFileSync(filename, { 26 | encoding: 'utf-8' 27 | }))); 28 | } 29 | 30 | return getter().then(function(data) { 31 | const dirname = path.dirname(filename); 32 | 33 | mkdirpSync(dirname); 34 | fs.writeFileSync(filename, JSON.stringify(data, null, 2)); 35 | return data; 36 | }); 37 | } 38 | 39 | /** 40 | * Converts a URL to a cache key. This algorithm can result in 41 | * key collisions but should be good enough for our purposes. 42 | */ 43 | exports.urlToCacheKey = function( 44 | url /*: string */ 45 | ) /*: Array */ { 46 | const info = urlParse(url); 47 | let path = (slugify((info.pathname || '/').slice(1).replace(/\//g, '__')) 48 | || '__index'); 49 | 50 | if (info.search) { 51 | path += '_' + slugify(info.search); 52 | } 53 | 54 | return [slugify(info.host), path]; 55 | } 56 | -------------------------------------------------------------------------------- /flow-typed/npm/axe-webdriverjs_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: e6adeffb1e727d26d987cd121b6fbad4 2 | // flow-typed version: <>/axe-webdriverjs_v^0.5.0/flow_v0.42.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'axe-webdriverjs' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'axe-webdriverjs' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'axe-webdriverjs/lib/index' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'axe-webdriverjs/lib/inject' { 30 | declare module.exports: any; 31 | } 32 | 33 | declare module 'axe-webdriverjs/lib/normalize-context' { 34 | declare module.exports: any; 35 | } 36 | 37 | declare module 'axe-webdriverjs/lib/warn' { 38 | declare module.exports: any; 39 | } 40 | 41 | // Filename aliases 42 | declare module 'axe-webdriverjs/lib/index.js' { 43 | declare module.exports: $Exports<'axe-webdriverjs/lib/index'>; 44 | } 45 | declare module 'axe-webdriverjs/lib/inject.js' { 46 | declare module.exports: $Exports<'axe-webdriverjs/lib/inject'>; 47 | } 48 | declare module 'axe-webdriverjs/lib/normalize-context.js' { 49 | declare module.exports: $Exports<'axe-webdriverjs/lib/normalize-context'>; 50 | } 51 | declare module 'axe-webdriverjs/lib/warn.js' { 52 | declare module.exports: $Exports<'axe-webdriverjs/lib/warn'>; 53 | } 54 | -------------------------------------------------------------------------------- /lib/websites.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const getGithubRepos = require('./github-repos'); 4 | 5 | /*:: 6 | import type {GithubRepo} from './github-repos' 7 | 8 | export type Website = { 9 | homepage: string, 10 | repo: string 11 | }; 12 | */ 13 | 14 | const { 15 | ORG, 16 | MIN_OPEN_ISSUES, 17 | MIN_STARS, 18 | MIN_LAST_PUSH_YEAR, 19 | REPO_BLACKLIST, 20 | HOMEPAGE_RE_BLACKLIST, 21 | } = require('./config'); 22 | 23 | /** 24 | * Returns whether or not the given GitHub repository is "interesting", 25 | * i.e. worthy of inclusion in the dashboard. 26 | * 27 | * For more details on the criteria used for this, see ./config.js. 28 | */ 29 | function isInterestingRepo(r /*: GithubRepo */) /*: boolean */ { 30 | return !r.fork && !!r.homepage && 31 | !HOMEPAGE_RE_BLACKLIST.some(re => re.test(r.homepage || '')) && 32 | new Date(r.pushed_at).getFullYear() >= MIN_LAST_PUSH_YEAR && 33 | (r.open_issues_count >= MIN_OPEN_ISSUES || 34 | r.stargazers_count >= MIN_STARS); 35 | } 36 | 37 | async function getWebsites() /*: Promise> */ { 38 | let repos = await getGithubRepos(ORG); 39 | 40 | return repos.filter(isInterestingRepo).map(repo => ({ 41 | homepage: repo.homepage || '', 42 | repo: repo.full_name 43 | })).filter(website => REPO_BLACKLIST.indexOf(website.repo) === -1); 44 | } 45 | 46 | module.exports = getWebsites; 47 | 48 | if (module.parent === null) { 49 | require('./run-script')(async () => { 50 | console.log(`Processing ${ORG}.`); 51 | 52 | const repos = (await getWebsites()) 53 | .map(r => `* ${r.repo} - ${r.homepage || ''}`); 54 | 55 | console.log(repos.join('\n')); 56 | console.log(`\n${repos.length} interesting repos found in ${ORG}.`); 57 | }); 58 | } 59 | -------------------------------------------------------------------------------- /lib/docker/entrypoint.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const execSync = require('child_process').execSync; 6 | 7 | const putil = require('./process-util'); 8 | 9 | const ROOT_DIR = path.join(__dirname, '..', '..'); 10 | const HOST_UID = fs.statSync(ROOT_DIR).uid; 11 | const HOST_USER = process.env.HOST_USER || 'code_executor_user'; 12 | const USER_OWNED_DIRS = (process.env.USER_OWNED_DIRS || '') 13 | .split(':').filter(dir => !!dir); 14 | 15 | if (!process.getuid) throw new Error('process.getuid() is unavailable!'); 16 | 17 | function getUsernameForUid(uid /*: number */) /*: string */ { 18 | return execSync( 19 | `awk -v val=${uid} -F ":" '$3==val{print $1}' /etc/passwd`, 20 | {encoding: 'ascii'} 21 | ).trim(); 22 | } 23 | 24 | if (HOST_UID !== process.getuid()) { 25 | let username = getUsernameForUid(HOST_UID); 26 | 27 | if (!username) { 28 | username = HOST_USER; 29 | 30 | while (putil.successSync(`id -u ${username}`)) { 31 | username += '0'; 32 | } 33 | 34 | console.log(`Configuring uid ${HOST_UID} as user ${username}...`); 35 | 36 | putil.runSync( 37 | 'groupadd code_executor_group && ' + 38 | `useradd -d /home/${username} -m ${username} ` + 39 | `-g code_executor_group -u ${HOST_UID}` 40 | ); 41 | } 42 | 43 | const home = `/home/${username}`; 44 | 45 | for (let dirname of [home, ...USER_OWNED_DIRS]) { 46 | if (fs.statSync(dirname).uid !== HOST_UID) { 47 | execSync(`chown -R ${username} ${dirname}`); 48 | } 49 | } 50 | 51 | process.env['HOME'] = home; 52 | 53 | if (!process.setuid) throw new Error('process.setuid() is unavailable!'); 54 | 55 | process.setuid(HOST_UID); 56 | } 57 | 58 | putil.spawnAndBindLifetimeTo(process.argv[2], process.argv.slice(3)); 59 | -------------------------------------------------------------------------------- /lib/github.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const GitHubApi = require('github'); 4 | 5 | const { GITHUB_API_TOKEN } = require('./config'); 6 | 7 | const MIN_RETRY_INTERVAL = 1000; 8 | const SERVER_ERROR_RETRY_INTERVAL = 10000; 9 | 10 | const api = new GitHubApi({ 11 | protocol: 'https', 12 | host: 'api.github.com', 13 | headers: { 14 | 'user-agent': '18F/a11y-metrics' 15 | }, 16 | timeout: 5000 17 | }); 18 | 19 | if (GITHUB_API_TOKEN) { 20 | api.authenticate({ 21 | type: 'token', 22 | token: GITHUB_API_TOKEN 23 | }); 24 | } 25 | 26 | function retry( 27 | promiseFactory /*: () => Promise */ 28 | ) /*: Promise */ { 29 | const retryIn = (ms /*: number */) /*: Promise */ => 30 | new Promise((resolve, reject) => { 31 | setTimeout(() => resolve(retry(promiseFactory)), ms); 32 | }); 33 | 34 | return promiseFactory().catch(err => { 35 | if (err.code && err.code >= 500) { 36 | console.log( 37 | `Got HTTP ${err.code} from GitHub, retrying in ` + 38 | `${SERVER_ERROR_RETRY_INTERVAL}ms.` 39 | ); 40 | return retryIn(SERVER_ERROR_RETRY_INTERVAL); 41 | } 42 | 43 | if (err.code && err.code === 403) { 44 | if (err.headers && typeof err.headers === 'object' && 45 | err.headers['x-ratelimit-remaining'] === '0') { 46 | const reset = parseInt(err.headers['x-ratelimit-reset']); 47 | let msUntilReset = (reset * 1000) - Date.now(); 48 | 49 | if (isNaN(reset) || msUntilReset < MIN_RETRY_INTERVAL) { 50 | msUntilReset = MIN_RETRY_INTERVAL; 51 | } 52 | 53 | console.log( 54 | `GitHub rate limit exceeded, retrying in ${msUntilReset}ms.` 55 | ); 56 | 57 | return retryIn(msUntilReset); 58 | } 59 | } 60 | 61 | throw err; 62 | }); 63 | } 64 | 65 | module.exports = { 66 | api, 67 | retry 68 | }; 69 | -------------------------------------------------------------------------------- /lib/axe-stats.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const axe = require('axe-core'); 4 | 5 | const cache = require('./cache'); 6 | 7 | const SCRIPT_TIMEOUT_MS = 15000; 8 | const PAGE_LOAD_TIMEOUT_MS = 20000; 9 | 10 | /*:: 11 | export type AxeViolation = { 12 | help: string; 13 | helpUrl: string; 14 | nodes: Array; 15 | }; 16 | 17 | export type AxeStats = { 18 | incomplete: Array, 19 | passes: Array, 20 | violations: Array, 21 | url: string 22 | }; 23 | */ 24 | 25 | function getAxeStats( 26 | driver /*: webdriver$WebDriver */, 27 | homepage /*: string */ 28 | ) /*: Promise */ { 29 | const subkey = cache.urlToCacheKey(homepage); 30 | 31 | return cache.get(['axe', ...subkey], async function() { 32 | console.log(`Obtaining axe-core stats for ${homepage}.`); 33 | 34 | const timeouts = driver.manage().timeouts(); 35 | 36 | timeouts.setScriptTimeout(SCRIPT_TIMEOUT_MS); 37 | timeouts.pageLoadTimeout(PAGE_LOAD_TIMEOUT_MS); 38 | 39 | await driver.get(homepage); 40 | 41 | const script = ` 42 | var callback = arguments[arguments.length - 1]; 43 | ${axe.source}; 44 | axe.run(function(err, results) { 45 | callback({ 46 | error: err && err.toString(), 47 | results: results 48 | }); 49 | }); 50 | `; 51 | 52 | const response = await driver.executeAsyncScript(script); 53 | 54 | if (response.error !== null) { 55 | throw new Error("axe.run() failed: " + response.error); 56 | } 57 | 58 | return response.results; 59 | }); 60 | } 61 | 62 | module.exports = getAxeStats; 63 | 64 | if (module.parent === null) { 65 | require('./run-script')(async () => { 66 | const driver = await require('../lib/webdriver')(); 67 | const websites = await require('./websites')(); 68 | 69 | for (let website of websites) { 70 | console.log(`Processing ${website.homepage} (${website.repo}).`); 71 | 72 | await getAxeStats(driver, website.homepage); 73 | } 74 | 75 | await driver.quit(); 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /lib/components/preamble.jsx: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const React = require('react'); 4 | 5 | const { ORG, QUERY, CSV_FILENAME, JSON_FILENAME } = require('../config'); 6 | 7 | class Preamble extends React.Component { 8 | /*:: 9 | props: { createdAt: string }; 10 | */ 11 | 12 | render() { 13 | return ( 14 |
15 |

16 | This is an early-stage prototype dashboard for {ORG} to track the accessibility characteristics across all its projects. 17 |

18 |

19 | Notes: 20 |

21 |
    22 |
  • 23 | This dashboard is accurate as of {new Date(this.props.createdAt).toLocaleDateString()}. 24 |
  • 25 |
  • 26 | For more details on how repositories are chosen, see the GitHub README. 27 |
  • 28 |
  • 29 | The GitHub a11y issues count is found by searching for both open and closed issues in a project's repository matching the query {QUERY}. 30 |
  • 31 |
  • 32 | The aXe violations and aXe passes counts are found by visiting a project's homepage using Deque Systems' aXe-core tool. 33 | {' '} 34 | For more detailed information, please visit the project's homepage using the aXe Chrome plugin. 35 |
  • 36 |
37 |

38 | You can also download the information in this dashboard as CSV or JSON. 39 |

40 |

41 | If you have any other questions or concerns, feel free to file a GitHub issue! 42 |

43 |
44 | ); 45 | } 46 | } 47 | 48 | module.exports = Preamble; 49 | -------------------------------------------------------------------------------- /test/history-sync.test.jsx: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const { 4 | HistorySync, 5 | DEFAULT_SORT, 6 | DEFAULT_IS_DESCENDING 7 | } = require('../lib/components/history-sync'); 8 | 9 | const defaults = { 10 | sortBy: DEFAULT_SORT, 11 | isDescending: DEFAULT_IS_DESCENDING 12 | }; 13 | 14 | describe('HistorySync.parseHash()', () => { 15 | const { parseHash } = HistorySync; 16 | 17 | it('returns defaults when hash is empty', () => { 18 | expect(parseHash('#')).toEqual(defaults); 19 | expect(parseHash('')).toEqual(defaults); 20 | }); 21 | 22 | it('returns default sortBy when it is invalid', () => { 23 | expect(parseHash('#sort=blarg')).toEqual(defaults); 24 | }); 25 | 26 | it('returns valid sortBy when it is valid', () => { 27 | expect(parseHash('#sort=issues').sortBy).toEqual('issues'); 28 | expect(parseHash('#sort=homepage').sortBy).toEqual('homepage'); 29 | }); 30 | 31 | it('returns isDescending=true when it is "on"', () => { 32 | expect(parseHash('#desc=on').isDescending).toBe(true); 33 | }); 34 | 35 | it('returns isDescending=false when it is anything else', () => { 36 | expect(parseHash('#desc=lol').isDescending).toBe(false); 37 | }); 38 | }); 39 | 40 | describe('HistorySync.stringifyHash()', () => { 41 | const { stringifyHash } = HistorySync; 42 | 43 | it('returns empty string when given defaults', () => { 44 | expect(stringifyHash(defaults)).toEqual(''); 45 | }); 46 | 47 | it('works when isDescending=true', () => { 48 | expect(stringifyHash({ 49 | sortBy: DEFAULT_SORT, 50 | isDescending: true 51 | })).toEqual('#desc=on'); 52 | }); 53 | 54 | it('works when sortBy is non-default', () => { 55 | expect(stringifyHash({ 56 | sortBy: 'issues', 57 | isDescending: DEFAULT_IS_DESCENDING 58 | })).toEqual('#sort=issues'); 59 | }); 60 | 61 | it('works when sortBy and isDescending are non-default', () => { 62 | expect(stringifyHash({ 63 | sortBy: 'issues', 64 | isDescending: true 65 | })).toEqual('#sort=issues&desc=on'); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /lib/components/dashboard.jsx: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const React = require('react'); 4 | const urlParse = require('url').parse; 5 | 6 | const { 7 | HistorySync, 8 | DEFAULT_SORT, 9 | DEFAULT_IS_DESCENDING 10 | } = require('./history-sync'); 11 | const Preamble = require('./preamble'); 12 | const Table = require('./table'); 13 | const { AxeViolations } = require('./axe-violations'); 14 | 15 | /*:: 16 | import type {Record} from './table'; 17 | import type {TableSort, TableSortConfig} from './history-sync'; 18 | 19 | type DashboardProps = { 20 | title: string; 21 | createdAt: string; 22 | records: Array; 23 | }; 24 | 25 | type DashboardState = { 26 | mounted: boolean; 27 | sortBy: TableSort; 28 | isDescending: boolean; 29 | }; 30 | */ 31 | 32 | class Dashboard extends React.Component { 33 | /*:: 34 | props: DashboardProps; 35 | state: DashboardState; 36 | 37 | handleSortChange: (TableSortConfig) => void; 38 | */ 39 | 40 | constructor(props /*: DashboardProps */) { 41 | super(props); 42 | this.state = { 43 | mounted: false, 44 | sortBy: DEFAULT_SORT, 45 | isDescending: DEFAULT_IS_DESCENDING 46 | }; 47 | this.handleSortChange = sort => { 48 | this.setState(sort); 49 | }; 50 | } 51 | 52 | componentDidMount() { 53 | this.setState({ mounted: true }); 54 | } 55 | 56 | render() { 57 | return ( 58 |
59 | 62 |

{this.props.title}

63 | 64 |

Per-project statistics

65 | 70 |

Top aXe violations

71 | 72 | 73 | ); 74 | } 75 | } 76 | 77 | module.exports = Dashboard; 78 | -------------------------------------------------------------------------------- /lib/webdriver.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const http = require('http'); 4 | const webdriver = require('selenium-webdriver'); 5 | 6 | const { 7 | SELENIUM_REMOTE_URL, 8 | SELENIUM_BROWSER, 9 | } = require('./config'); 10 | 11 | const TIMEOUT_MS = 1000; 12 | const RETRIES = 5; 13 | const TIME_BETWEEN_RETRIES_MS = 500; 14 | 15 | function checkDriverReadiness( 16 | url /*: string */, 17 | cb /*: (err: Error | null) => void */ 18 | ) /*: void */ { 19 | const req = http.get(url, res => { 20 | if (res.statusCode !== 302) { 21 | return cb(new Error(`got HTTP ${res.statusCode}`)); 22 | } 23 | res.setTimeout(TIMEOUT_MS, () => { 24 | cb(new Error('timeout exceeded (response)')); 25 | }); 26 | res.on('data', () => {}); 27 | res.on('end', () => { cb(null); }); 28 | }); 29 | 30 | req.setTimeout(TIMEOUT_MS, () => { 31 | cb(new Error('timeout exceeded (request)')); 32 | }); 33 | req.on('error', cb); 34 | } 35 | 36 | function getWebdriver() /*: Promise */ { 37 | if (!SELENIUM_BROWSER) { 38 | return Promise.reject(new Error('SELENIUM_BROWSER must be defined')); 39 | } 40 | 41 | if (!SELENIUM_REMOTE_URL) { 42 | return Promise.reject(new Error('SELENIUM_REMOTE_URL must be defined')); 43 | } 44 | 45 | return new Promise((resolve, reject) => { 46 | const retry = (retriesLeft) => { 47 | checkDriverReadiness(SELENIUM_REMOTE_URL, function(err) { 48 | if (err) { 49 | if (retriesLeft === 0) { 50 | return reject(new Error('maximum retries exceeded')); 51 | } 52 | 53 | const errStr = err ? err.toString() : 'unknown error'; 54 | 55 | console.log(`Webdriver not ready (${errStr}), retrying.`); 56 | 57 | setTimeout(() => { 58 | retry(retriesLeft - 1); 59 | }, TIME_BETWEEN_RETRIES_MS); 60 | return; 61 | } 62 | const driver = new webdriver.Builder() 63 | .forBrowser(SELENIUM_BROWSER) 64 | .usingServer(SELENIUM_REMOTE_URL) 65 | .build(); 66 | resolve(driver); 67 | }); 68 | }; 69 | 70 | retry(RETRIES); 71 | }); 72 | } 73 | 74 | module.exports = getWebdriver; 75 | -------------------------------------------------------------------------------- /flow-typed/npm/csv-stringify_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 015861863f039d3329be44aa550da4ac 2 | // flow-typed version: <>/csv-stringify_v^1.0.4/flow_v0.42.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'csv-stringify' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'csv-stringify' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'csv-stringify/lib/index' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'csv-stringify/lib/sync' { 30 | declare module.exports: any; 31 | } 32 | 33 | declare module 'csv-stringify/samples/api.callback' { 34 | declare module.exports: any; 35 | } 36 | 37 | declare module 'csv-stringify/samples/api.pipe' { 38 | declare module.exports: any; 39 | } 40 | 41 | declare module 'csv-stringify/samples/api.stream' { 42 | declare module.exports: any; 43 | } 44 | 45 | declare module 'csv-stringify/samples/options.header' { 46 | declare module.exports: any; 47 | } 48 | 49 | // Filename aliases 50 | declare module 'csv-stringify/lib/index.js' { 51 | declare module.exports: $Exports<'csv-stringify/lib/index'>; 52 | } 53 | declare module 'csv-stringify/lib/sync.js' { 54 | declare module.exports: $Exports<'csv-stringify/lib/sync'>; 55 | } 56 | declare module 'csv-stringify/samples/api.callback.js' { 57 | declare module.exports: $Exports<'csv-stringify/samples/api.callback'>; 58 | } 59 | declare module 'csv-stringify/samples/api.pipe.js' { 60 | declare module.exports: $Exports<'csv-stringify/samples/api.pipe'>; 61 | } 62 | declare module 'csv-stringify/samples/api.stream.js' { 63 | declare module.exports: $Exports<'csv-stringify/samples/api.stream'>; 64 | } 65 | declare module 'csv-stringify/samples/options.header.js' { 66 | declare module.exports: $Exports<'csv-stringify/samples/options.header'>; 67 | } 68 | -------------------------------------------------------------------------------- /retryable-stats.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /** 4 | * This script is used to run a script with the following retry criteria: 5 | * 6 | * * If the script produces no output (via stdout/stderr) for a given 7 | * period of time, we retry it. 8 | * * If the script exits with a non-zero exit code, we retry it. 9 | * 10 | * Once a maximum number of retries has been reached, we exit with 11 | * a non-zero exit code. 12 | */ 13 | 14 | const child_process = require('child_process'); 15 | 16 | const CMD = "node"; 17 | const ARGS = ["stats.js"]; 18 | const MS_PER_MINUTE = 1000 * 60; 19 | const MAX_TIME_WITH_NO_OUTPUT_MS = 2 * MS_PER_MINUTE; 20 | const MAX_RETRIES = 3; 21 | 22 | function retry( 23 | cmd /*: string */, 24 | args /*: Array */, 25 | max_time_with_no_output_ms /*: number */, 26 | retries /*: number */ 27 | ) { 28 | const allArgs = JSON.stringify([cmd, ...args]); 29 | console.log(`Running ${allArgs}...`); 30 | 31 | const child = child_process.spawn(cmd, args); 32 | let timeout = 0; 33 | const cancelTimeout = () => { 34 | if (timeout) clearTimeout(timeout); 35 | timeout = 0; 36 | }; 37 | const resetTimeout = () => { 38 | cancelTimeout(); 39 | timeout = setTimeout(() => { 40 | console.log( 41 | `No output from process received in ` + 42 | `${max_time_with_no_output_ms}ms, killing it.` 43 | ); 44 | child.kill(); 45 | }, max_time_with_no_output_ms); 46 | }; 47 | 48 | resetTimeout(); 49 | 50 | child.stdout.on('data', resetTimeout); 51 | child.stderr.on('data', resetTimeout); 52 | 53 | child.stdout.pipe(process.stdout); 54 | child.stderr.pipe(process.stderr); 55 | 56 | child.on('exit', (code, signal) => { 57 | cancelTimeout(); 58 | 59 | if (signal) { 60 | console.log(`Process exited via signal ${signal}.`); 61 | } else { 62 | console.log(`Process exited with code ${code}.`); 63 | } 64 | 65 | if (code === 0) { 66 | process.exit(0); 67 | } else if (retries) { 68 | console.log('Retrying...'); 69 | retry(cmd, args, max_time_with_no_output_ms, retries - 1); 70 | } else { 71 | console.log('No more retries left, exiting.'); 72 | process.exit(1); 73 | } 74 | }); 75 | } 76 | 77 | if (module.parent === null) { 78 | retry(CMD, ARGS, MAX_TIME_WITH_NO_OUTPUT_MS, MAX_RETRIES); 79 | } 80 | -------------------------------------------------------------------------------- /lib/components/axe-violations.jsx: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const React = require('react'); 4 | 5 | const { cmpStr } = require('../util'); 6 | 7 | /*:: 8 | import type {Record, BasicAxeViolation} from './table'; 9 | 10 | type Props = { 11 | records: Array; 12 | }; 13 | 14 | export type AxeViolationStat = { 15 | name: string; 16 | helpUrl: string; 17 | count: number; 18 | }; 19 | */ 20 | 21 | function getAxeViolationStats( 22 | violations /*: Array */ 23 | ) /*: Array */ { 24 | const violationsMap /*: { [string]: number } */ = {}; 25 | const helpUrlsMap /*: { [string]: string } */ = {}; 26 | 27 | violations.forEach(v => { 28 | if (!(v.kind in violationsMap)) { 29 | violationsMap[v.kind] = 0; 30 | helpUrlsMap[v.kind] = v.helpUrl; 31 | } 32 | violationsMap[v.kind]++; 33 | }); 34 | 35 | const violationStats = Object.keys(violationsMap).map(key => ({ 36 | name: key, 37 | helpUrl: helpUrlsMap[key], 38 | count: violationsMap[key] 39 | })); 40 | 41 | violationStats.sort((a, b) => { 42 | if (b.count === a.count) { 43 | return cmpStr(a.name, b.name); 44 | } 45 | return b.count - a.count; 46 | }); 47 | 48 | return violationStats; 49 | } 50 | 51 | function flattenViolations( 52 | records /*: Array */ 53 | ) /*: Array */ { 54 | const violations = []; 55 | 56 | records.forEach(r => { 57 | r.axeStats.violations.forEach(v => { 58 | violations.push(v); 59 | }); 60 | }); 61 | 62 | return violations; 63 | } 64 | 65 | function uniqueWebpages( 66 | records /*: Array */ 67 | ) /*: Array */ { 68 | const pages = {}; 69 | 70 | return records.filter(r => { 71 | if (r.website.homepage in pages) { 72 | return false; 73 | } 74 | pages[r.website.homepage] = true; 75 | return true; 76 | }); 77 | } 78 | 79 | function AxeViolations(props /*: Props */) { 80 | const violations = flattenViolations(uniqueWebpages(props.records)); 81 | const violationStats = getAxeViolationStats(violations); 82 | 83 | return ( 84 |
85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | {violationStats.map(v => 93 | 94 | 95 | 96 | 97 | )} 98 | 99 |
Violation nameHomepages with violation
{v.name}{v.count}
100 | ); 101 | } 102 | 103 | module.exports = { 104 | AxeViolations, 105 | getAxeViolationStats, 106 | flattenViolations, 107 | uniqueWebpages 108 | }; 109 | -------------------------------------------------------------------------------- /lib/components/history-sync.jsx: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /*:: 4 | export type TableSort = 'homepage' | 'repo' | 'issues' | 'axe-v' | 'axe-p'; 5 | 6 | export type TableSortConfig = { 7 | sortBy: TableSort, 8 | isDescending: boolean 9 | }; 10 | */ 11 | 12 | const querystring = require('querystring'); 13 | const React = require('react'); 14 | 15 | const SORT_HOME = 'homepage'; 16 | const SORT_REPO = 'repo'; 17 | const SORT_ISSU = 'issues'; 18 | const SORT_AXEV = 'axe-v'; 19 | const SORT_AXEP = 'axe-p'; 20 | 21 | const DEFAULT_SORT = SORT_REPO; 22 | const DEFAULT_IS_DESCENDING = false; 23 | 24 | class HistorySync extends React.Component { 25 | /*:: 26 | props: { 27 | onChange: (TableSortConfig) => void; 28 | sortBy: TableSort; 29 | isDescending: boolean; 30 | }; 31 | handleHashChange: () => void; 32 | */ 33 | 34 | componentDidMount() { 35 | this.handleHashChange = () => { 36 | const sort = HistorySync.parseHash(window.location.hash); 37 | 38 | if (sort.sortBy !== this.props.sortBy || 39 | sort.isDescending !== this.props.isDescending) { 40 | this.props.onChange(sort); 41 | } 42 | }; 43 | window.addEventListener('hashchange', this.handleHashChange); 44 | this.handleHashChange(); 45 | } 46 | 47 | componentDidUpdate() { 48 | const hash = HistorySync.stringifyHash(this.props); 49 | if (hash !== window.location.hash) { 50 | window.location.hash = hash; 51 | } 52 | } 53 | 54 | componentWillUnmount() { 55 | window.removeEventListener('hashchange', this.handleHashChange); 56 | } 57 | 58 | static parseHash(hash /*: string */) /*: TableSortConfig */ { 59 | const parts = querystring.parse(hash.slice(1)); 60 | let sortBy = parts['sort']; 61 | let isDescending = DEFAULT_IS_DESCENDING; 62 | 63 | if (parts['desc'] === 'on') { 64 | isDescending = true; 65 | } 66 | 67 | switch (sortBy) { 68 | case SORT_HOME: 69 | case SORT_REPO: 70 | case SORT_ISSU: 71 | case SORT_AXEV: 72 | case SORT_AXEP: 73 | break; 74 | default: 75 | sortBy = DEFAULT_SORT; 76 | } 77 | 78 | return {sortBy, isDescending}; 79 | } 80 | 81 | static stringifyHash(sort /*: TableSortConfig */) /*: string */ { 82 | if (sort.sortBy === DEFAULT_SORT && 83 | sort.isDescending === DEFAULT_IS_DESCENDING) { 84 | return ''; 85 | } 86 | 87 | // We're not using querystring.stringify() here because we want 88 | // a deterministic ordering. 89 | 90 | let parts = []; 91 | 92 | if (sort.sortBy !== DEFAULT_SORT) { 93 | parts.push('sort=' + encodeURIComponent(sort.sortBy)); 94 | } 95 | 96 | if (sort.isDescending) { 97 | parts.push('desc=on'); 98 | } 99 | 100 | return '#' + parts.join('&'); 101 | } 102 | 103 | render() { 104 | return null; 105 | } 106 | } 107 | 108 | module.exports = { 109 | HistorySync, 110 | SORT_HOME, 111 | SORT_REPO, 112 | SORT_ISSU, 113 | SORT_AXEV, 114 | SORT_AXEP, 115 | DEFAULT_SORT, 116 | DEFAULT_IS_DESCENDING 117 | }; 118 | -------------------------------------------------------------------------------- /stats.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | require('babel-register'); 4 | 5 | const fs = require('fs'); 6 | const cbStringify = require('csv-stringify'); 7 | const React = require('react'); 8 | const ReactDOMServer = require('react-dom/server'); 9 | 10 | const getAxeStats = require('./lib/axe-stats'); 11 | const getGithubStats = require('./lib/github-stats'); 12 | const getWebsites = require('./lib/websites'); 13 | const getWebdriver = require('./lib/webdriver'); 14 | const Dashboard = require('./lib/components/dashboard'); 15 | const StaticPage = require('./lib/components/static-page'); 16 | 17 | const config = require('./lib/config'); 18 | 19 | const OUTPUT_CSV = `static/${config.CSV_FILENAME}`; 20 | const OUTPUT_HTML = 'static/index.html'; 21 | const RECORDS_JSON = `static/${config.JSON_FILENAME}`; 22 | 23 | function stringify(input /*: Array */) /*: Promise */ { 24 | return new Promise((resolve, reject) => { 25 | cbStringify(input, (err, output) => { 26 | if (err) { 27 | return reject(err); 28 | } 29 | resolve(output); 30 | }); 31 | }); 32 | } 33 | 34 | async function main() { 35 | const rows = [[ 36 | 'Homepage', 37 | 'GitHub Repository', 38 | 'GitHub issues (open and closed) mentioning accessibility', 39 | 'aXe violations on front page', 40 | 'aXe passes on front page' 41 | ]]; 42 | const records = []; 43 | 44 | const websites = await getWebsites(); 45 | const driver = await getWebdriver(); 46 | 47 | for (let website of websites) { 48 | const github = await getGithubStats(website.repo); 49 | const axe = await getAxeStats(driver, website.homepage); 50 | 51 | rows.push([ 52 | website.homepage, 53 | website.repo, 54 | github.data.total_count, 55 | axe.violations.length, 56 | axe.passes.length 57 | ]); 58 | 59 | records.push({ 60 | website, 61 | axeStats: { 62 | violations: axe.violations.map(v => ({ 63 | kind: v.help, 64 | helpUrl: v.helpUrl, 65 | nodeCount: v.nodes.length 66 | })), 67 | passes: axe.passes.length, 68 | }, 69 | issueCount: github.data.total_count 70 | }); 71 | } 72 | 73 | await driver.quit(); 74 | 75 | fs.writeFileSync(OUTPUT_CSV, await stringify(rows)); 76 | console.log(`Wrote ${OUTPUT_CSV}.`); 77 | 78 | const dashboardProps = { 79 | title: config.TITLE, 80 | records, 81 | createdAt: new Date().toISOString() 82 | }; 83 | const appHtml = ReactDOMServer.renderToString( 84 | React.createElement(Dashboard, dashboardProps) 85 | ); 86 | const html = '' + ReactDOMServer.renderToStaticMarkup( 87 | React.createElement(StaticPage, { 88 | title: config.TITLE, 89 | id: config.ELEMENT_ID, 90 | html: appHtml 91 | }) 92 | ); 93 | 94 | fs.writeFileSync(OUTPUT_HTML, html); 95 | console.log(`Wrote ${OUTPUT_HTML}.`); 96 | 97 | fs.writeFileSync(RECORDS_JSON, JSON.stringify(dashboardProps)); 98 | console.log(`Wrote ${RECORDS_JSON}.`); 99 | } 100 | 101 | if (module.parent === null) { 102 | require('./lib/run-script')(main); 103 | } 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/18F/a11y-metrics.svg?branch=master)](https://travis-ci.org/18F/a11y-metrics) 2 | 3 | This is an experiment in obtaining accessibility metrics across all 4 | 18F projects. 5 | 6 | ## Quick start 7 | 8 | You'll need [Docker][]. 9 | 10 | ``` 11 | docker-compose pull 12 | docker-compose run app yarn 13 | docker-compose run app yarn build 14 | ``` 15 | 16 | This will output the static website at `static/`, including an 17 | accompanying JS bundle for progressive enhancement. 18 | 19 | ## Developing the front-end 20 | 21 | To develop the front-end, run: 22 | 23 | ``` 24 | docker-compose up 25 | ``` 26 | 27 | Then visit `http://localhost:8080/` (or the same port on your Docker host) 28 | in your browser. 29 | 30 | Note that the front-end currently only regenerates its JS bundle when you 31 | edit files; it does not regenerate `index.html`, which means that you 32 | may see a warning in your console with a message like this: 33 | 34 | ``` 35 | React attempted to reuse markup in a container but the checksum was invalid. 36 | ``` 37 | 38 | Manually re-building the static site should get rid of this warning; if 39 | it *doesn't*, however, then you've got a problem. Consider delaying any 40 | functionality requiring JS (or specific browser features) until the 41 | React app is mounted in the DOM, so that the initial render of the app 42 | is identical to the static render present in `index.html`. 43 | 44 | To quickly see what the dashboard looks like without JavaScript enabled, 45 | add `nojs=on` to the querystring, e.g. `http://localhost:8080/?nojs=on`. 46 | 47 | ## Adding new 18F projects to track 48 | 49 | Currently, the list of 18F projects is actually automatically generated 50 | by iterating through all the GitHub repositories in the 18F organization 51 | (and possibly other related ones) and filtering for the ones that 52 | have a homepage set, along with a minimum number of open issues or 53 | stars. 54 | 55 | For more details on this criteria, and on tweaking its parameters, 56 | see [`lib/config.js`][]. 57 | 58 | We may add an explicit mechanism to allow specific projects to be 59 | tracked in the future. 60 | 61 | ## Environment-controlled configuration options 62 | 63 | Some configuration options can be modified via environment variables. 64 | 65 | During development, this is most easily done by creating a `.env` file 66 | containing name-value pairs, e.g.: 67 | 68 | ``` 69 | GITHUB_API_TOKEN=blarg 70 | ``` 71 | 72 | For more details on available configuration options, see [`lib/config.js`][]. 73 | 74 | ## Clearing cached data 75 | 76 | All cached data is placed in the `cache` subdirectory. You can delete it 77 | entirely to reset the whole cache, or delete individual subdirectories 78 | or files within it to reset a subset of the cache. 79 | 80 | ## Testing 81 | 82 | We use [Jest][] for tests; tests are in the `test` subdirectory. Run 83 | `docker-compose run app jest --watch` to run the tests and continuously 84 | watch for changes. 85 | 86 | We also use [Flow's comment syntax][flow] for strong typing, 87 | and `docker-compose run app yarn test` will fail if any errors are 88 | reported by Flow. 89 | 90 | For quick feedback on Flow's type checking, consider running 91 | `docker-compose run app yarn flow:watch`. 92 | 93 | ## Deployment 94 | 95 | Deployment is currently done by a [Travis cron job][] which rebuilds the 96 | dashboard on a daily basis and pushes the new site to the `static-site` 97 | branch. This push is then detected by [Federalist][], which re-deploys 98 | the live site. 99 | 100 | [Docker]: https://docker.com/ 101 | [flow]: https://flowtype.org/en/docs/types/comments/ 102 | [Jest]: http://facebook.github.io/jest/ 103 | [`lib/config.js`]: lib/config.js 104 | [Travis cron job]: https://docs.travis-ci.com/user/cron-jobs/ 105 | [Federalist]: https://federalist.18f.gov/ 106 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /** 4 | * Static configuration options 5 | */ 6 | 7 | // This is the GitHub query we make to figure out how many a11y-related 8 | // issues a repository has. 9 | exports.QUERY = 'accessibility OR a11y'; 10 | 11 | // The GitHub organization whose repositories we iterate through to 12 | // figure out what projects exist. 13 | exports.ORG = '18F'; 14 | 15 | // Some of the following configuration options use the word "interesting" 16 | // to describe a repository. By "interesting' we mean a repository worthy 17 | // of collecting statistics on and being displayed in the dashboard. 18 | // 19 | // We need to do this because our organization has hundreds of repositories, 20 | // most of which we don't actually need to collect statistics on. 21 | // 22 | // Some criteria are further qualified with the word "SHOULD"; these are 23 | // not *required* for a repository to be considered interesting, but at 24 | // least *one* of the "SHOULD" criteria must be met for it to be 25 | // considered interesting. 26 | // 27 | // Thus, for instance, a repository may have no stars and 1000 open 28 | // issues to be considered interesting, or it may have 1000 stars and 29 | // 0 open issues, but it cannot have no stars *and* no open issues. 30 | 31 | // The minimum number of open issues that a repository SHOULD have to 32 | // be considered interesting. 33 | exports.MIN_OPEN_ISSUES = 20; 34 | 35 | // The minimum number of stars that a repository SHOULD have to 36 | // be considered interesting. 37 | exports.MIN_STARS = 10; 38 | 39 | // The minimum year in which the most recent push to a repository MUST 40 | // have been made for it to be considered interesting. 41 | exports.MIN_LAST_PUSH_YEAR = 2016; 42 | 43 | // This is a blacklist of repositories, in `org/repo` format, that should 44 | // NEVER be considered interesting. 45 | exports.REPO_BLACKLIST = [ 46 | // This project's homepage points to a page behind HTTP auth. 47 | '18F/identity-idp', 48 | ]; 49 | 50 | // Repositories whose homepages match any of the regular expressions in 51 | // this blacklist should NEVER be considered interesting. 52 | exports.HOMEPAGE_RE_BLACKLIST = [ 53 | // Aside from not being useful to analyze from an accessibility standpoint, 54 | // we have trouble crawling these sites. 55 | /^https?:\/\/github\.com\//, 56 | /^https?:\/\/rubygems\.org\//, 57 | /^https?:\/\/www\.npmjs\.com\//, 58 | 59 | // Ignore homepages that just point to blog posts. 60 | /^https?:\/\/18f\.gsa\.gov\/20\d\d\// 61 | ]; 62 | 63 | // The title of the dashboard, shown on the web version. 64 | exports.TITLE = `${exports.ORG} accessibility dashboard`; 65 | 66 | // The element ID in which the dashboard is housed in the web version. 67 | exports.ELEMENT_ID = 'dashboard'; 68 | 69 | // The filename we save the CSV contents of the dashboard as. 70 | exports.CSV_FILENAME = 'stats.csv'; 71 | 72 | // The filename we save the JSON contents of the dashboard as. 73 | exports.JSON_FILENAME = 'records.json'; 74 | 75 | // The progressive enhancement JS bundle for the dashboard. 76 | exports.JS_FILENAME = 'bundle.js'; 77 | 78 | /** 79 | * Environment-controlled configuration options 80 | * 81 | * These configuration options can be controlled through environment 82 | * variables. If running via docker-compose, this can be done via 83 | * a `docker-compose.override.yml` file or by simply creating a `.env` 84 | * file in the root directory of the project. 85 | * 86 | * Note that some environment variables may be described as "boolean". 87 | * This means that they are true if the environment variable is 88 | * defined--even if it's defined to be the empty string. 89 | */ 90 | 91 | // This loads settings from any existing `.env` file into our environment. 92 | if (process.env.APP_ENV !== 'browser') require('dotenv').config(); 93 | 94 | // GITHUB_API_TOKEN 95 | // 96 | // This is a GitHub API personal access token. It's optional and 97 | // just makes collecting GitHub statistics more efficient, as 98 | // authenticated requests have higher rate limits. It only needs 99 | // `public_repo` scope. 100 | exports.GITHUB_API_TOKEN = process.env['GITHUB_API_TOKEN']; 101 | 102 | // ENABLE_INTERNET_TESTS 103 | // 104 | // This boolean determines whether we run tests that require internet 105 | // connectivity. 106 | exports.ENABLE_INTERNET_TESTS = 'ENABLE_INTERNET_TESTS' in process.env; 107 | 108 | // SELENIUM_REMOTE_URL 109 | // 110 | // This points to the remote Selenium WebDriver server, e.g. 111 | // 'http://localhost:4444/wd/hub'. 112 | // 113 | // This is defined by the default Docker setup, so you probably don't 114 | // need to set it yourself. 115 | exports.SELENIUM_REMOTE_URL = process.env['SELENIUM_REMOTE_URL']; 116 | 117 | // SELENIUM_BROWSER 118 | // 119 | // This is the name of the browser to use for Selenium WebDriver, 120 | // e.g. 'chrome'. 121 | // 122 | // This is defined by the default Docker setup, so you probably don't 123 | // need to set it yourself. 124 | exports.SELENIUM_BROWSER = process.env['SELENIUM_BROWSER']; 125 | -------------------------------------------------------------------------------- /flow-typed/npm/flow-typed_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: db575f34958efc4c94f56b8dfbce1e6e 2 | // flow-typed version: <>/flow-typed_v^2.0.0/flow_v0.42.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'flow-typed' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'flow-typed' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'flow-typed/dist/cli' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'flow-typed/dist/commands/create-stub' { 30 | declare module.exports: any; 31 | } 32 | 33 | declare module 'flow-typed/dist/commands/install' { 34 | declare module.exports: any; 35 | } 36 | 37 | declare module 'flow-typed/dist/commands/runTests' { 38 | declare module.exports: any; 39 | } 40 | 41 | declare module 'flow-typed/dist/commands/search' { 42 | declare module.exports: any; 43 | } 44 | 45 | declare module 'flow-typed/dist/commands/update-cache' { 46 | declare module.exports: any; 47 | } 48 | 49 | declare module 'flow-typed/dist/commands/update' { 50 | declare module.exports: any; 51 | } 52 | 53 | declare module 'flow-typed/dist/commands/validateDefs' { 54 | declare module.exports: any; 55 | } 56 | 57 | declare module 'flow-typed/dist/commands/version' { 58 | declare module.exports: any; 59 | } 60 | 61 | declare module 'flow-typed/dist/lib/codeSign' { 62 | declare module.exports: any; 63 | } 64 | 65 | declare module 'flow-typed/dist/lib/fileUtils' { 66 | declare module.exports: any; 67 | } 68 | 69 | declare module 'flow-typed/dist/lib/flowProjectUtils' { 70 | declare module.exports: any; 71 | } 72 | 73 | declare module 'flow-typed/dist/lib/git' { 74 | declare module.exports: any; 75 | } 76 | 77 | declare module 'flow-typed/dist/lib/github' { 78 | declare module.exports: any; 79 | } 80 | 81 | declare module 'flow-typed/dist/lib/libDefs' { 82 | declare module.exports: any; 83 | } 84 | 85 | declare module 'flow-typed/dist/lib/node' { 86 | declare module.exports: any; 87 | } 88 | 89 | declare module 'flow-typed/dist/lib/npmProjectUtils' { 90 | declare module.exports: any; 91 | } 92 | 93 | declare module 'flow-typed/dist/lib/semver' { 94 | declare module.exports: any; 95 | } 96 | 97 | declare module 'flow-typed/dist/lib/stubUtils' { 98 | declare module.exports: any; 99 | } 100 | 101 | // Filename aliases 102 | declare module 'flow-typed/dist/cli.js' { 103 | declare module.exports: $Exports<'flow-typed/dist/cli'>; 104 | } 105 | declare module 'flow-typed/dist/commands/create-stub.js' { 106 | declare module.exports: $Exports<'flow-typed/dist/commands/create-stub'>; 107 | } 108 | declare module 'flow-typed/dist/commands/install.js' { 109 | declare module.exports: $Exports<'flow-typed/dist/commands/install'>; 110 | } 111 | declare module 'flow-typed/dist/commands/runTests.js' { 112 | declare module.exports: $Exports<'flow-typed/dist/commands/runTests'>; 113 | } 114 | declare module 'flow-typed/dist/commands/search.js' { 115 | declare module.exports: $Exports<'flow-typed/dist/commands/search'>; 116 | } 117 | declare module 'flow-typed/dist/commands/update-cache.js' { 118 | declare module.exports: $Exports<'flow-typed/dist/commands/update-cache'>; 119 | } 120 | declare module 'flow-typed/dist/commands/update.js' { 121 | declare module.exports: $Exports<'flow-typed/dist/commands/update'>; 122 | } 123 | declare module 'flow-typed/dist/commands/validateDefs.js' { 124 | declare module.exports: $Exports<'flow-typed/dist/commands/validateDefs'>; 125 | } 126 | declare module 'flow-typed/dist/commands/version.js' { 127 | declare module.exports: $Exports<'flow-typed/dist/commands/version'>; 128 | } 129 | declare module 'flow-typed/dist/lib/codeSign.js' { 130 | declare module.exports: $Exports<'flow-typed/dist/lib/codeSign'>; 131 | } 132 | declare module 'flow-typed/dist/lib/fileUtils.js' { 133 | declare module.exports: $Exports<'flow-typed/dist/lib/fileUtils'>; 134 | } 135 | declare module 'flow-typed/dist/lib/flowProjectUtils.js' { 136 | declare module.exports: $Exports<'flow-typed/dist/lib/flowProjectUtils'>; 137 | } 138 | declare module 'flow-typed/dist/lib/git.js' { 139 | declare module.exports: $Exports<'flow-typed/dist/lib/git'>; 140 | } 141 | declare module 'flow-typed/dist/lib/github.js' { 142 | declare module.exports: $Exports<'flow-typed/dist/lib/github'>; 143 | } 144 | declare module 'flow-typed/dist/lib/libDefs.js' { 145 | declare module.exports: $Exports<'flow-typed/dist/lib/libDefs'>; 146 | } 147 | declare module 'flow-typed/dist/lib/node.js' { 148 | declare module.exports: $Exports<'flow-typed/dist/lib/node'>; 149 | } 150 | declare module 'flow-typed/dist/lib/npmProjectUtils.js' { 151 | declare module.exports: $Exports<'flow-typed/dist/lib/npmProjectUtils'>; 152 | } 153 | declare module 'flow-typed/dist/lib/semver.js' { 154 | declare module.exports: $Exports<'flow-typed/dist/lib/semver'>; 155 | } 156 | declare module 'flow-typed/dist/lib/stubUtils.js' { 157 | declare module.exports: $Exports<'flow-typed/dist/lib/stubUtils'>; 158 | } 159 | -------------------------------------------------------------------------------- /lib/components/table.jsx: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const React = require('react'); 4 | 5 | const { QUERY } = require('../config'); 6 | const { shortenUrl, cmpStr } = require('../util'); 7 | const { 8 | SORT_HOME, 9 | SORT_REPO, 10 | SORT_ISSU, 11 | SORT_AXEV, 12 | SORT_AXEP, 13 | } = require('./history-sync'); 14 | 15 | /*:: 16 | import type {Website} from '../websites'; 17 | import type {TableSort, TableSortConfig} from './history-sync'; 18 | 19 | export type BasicAxeViolation = { 20 | kind: string; 21 | nodeCount: number; 22 | helpUrl: string; 23 | }; 24 | 25 | type BasicAxeStats = { 26 | violations: Array; 27 | passes: number; 28 | }; 29 | 30 | export type Record = { 31 | website: Website; 32 | issueCount: number; 33 | axeStats: BasicAxeStats; 34 | }; 35 | */ 36 | 37 | function AxeViolationsCell(props /*: { violations: Array } */) { 38 | if (props.violations.length === 0) { 39 | return {props.violations.length}; 40 | } 41 | return ( 42 |
43 | {props.violations.length} 44 |
    45 | {props.violations.map(v => ( 46 |
  • 47 | {v.kind} 48 | {v.nodeCount > 1 ? : null} 49 |
  • 50 | ))} 51 |
52 |
53 | ); 54 | } 55 | 56 | class Row extends React.Component { 57 | /*:: 58 | props: { 59 | website: Website; 60 | issueCount: number; 61 | axeStats: BasicAxeStats; 62 | }; 63 | */ 64 | 65 | render() { 66 | const homepage = this.props.website.homepage; 67 | const shortHomepage = shortenUrl(homepage); 68 | const repo = this.props.website.repo; 69 | const repoUrl = `https://github.com/${repo}`; 70 | const q = encodeURIComponent(QUERY); 71 | const issuesUrl = `https://github.com/${repo}/search?q=${q}&type=Issues`; 72 | 73 | return ( 74 | 75 | {shortHomepage} 76 | {repo} 77 | {this.props.issueCount} 78 | 79 | 80 | 81 | {this.props.axeStats.passes} 82 | 83 | ); 84 | } 85 | } 86 | 87 | /*:: 88 | type HeaderProps = { 89 | sort: TableSort; 90 | currSort: TableSort; 91 | isDescending: boolean; 92 | onSort: (TableSort) => void; 93 | isEnhanced: boolean; 94 | children?: any; 95 | }; 96 | */ 97 | 98 | function Header(props /*: HeaderProps */) { 99 | if (!props.isEnhanced) { 100 | return {props.children}; 101 | } 102 | 103 | const handleClick = () => props.onSort(props.sort); 104 | const isSorted = props.sort === props.currSort; 105 | let ariaLabel; 106 | 107 | if (isSorted) { 108 | if (props.isDescending) { 109 | ariaLabel = "sorted descending, select to sort ascending"; 110 | } else { 111 | ariaLabel = "sorted ascending, select to sort descending"; 112 | } 113 | } else { 114 | ariaLabel = "unsorted, select to sort ascending"; 115 | } 116 | 117 | return ( 118 | 119 |
120 |
121 | 125 |
126 |
130 | 131 | ); 132 | } 133 | 134 | /*:: 135 | type TableProps = { 136 | records: Array; 137 | isEnhanced: boolean; 138 | sortBy: TableSort; 139 | isDescending: boolean; 140 | onSortChange: (TableSortConfig) => void; 141 | }; 142 | */ 143 | 144 | class Table extends React.Component { 145 | /*:: 146 | props: TableProps; 147 | 148 | handleSort: (TableSort) => void; 149 | */ 150 | 151 | constructor(props /*: TableProps */) { 152 | super(props); 153 | 154 | this.handleSort = sortBy => { 155 | if (this.props.sortBy === sortBy) { 156 | this.props.onSortChange({ 157 | sortBy, 158 | isDescending: !this.props.isDescending 159 | }); 160 | } else { 161 | this.props.onSortChange({ 162 | sortBy, 163 | isDescending: false 164 | }); 165 | } 166 | }; 167 | } 168 | 169 | sortRecords() { 170 | const records = this.props.records.slice(); 171 | 172 | switch (this.props.sortBy) { 173 | case SORT_HOME: 174 | records.sort((a, b) => cmpStr(a.website.homepage, 175 | b.website.homepage)); 176 | break; 177 | case SORT_REPO: 178 | records.sort((a, b) => cmpStr(a.website.repo, b.website.repo)); 179 | break; 180 | case SORT_ISSU: 181 | records.sort((a, b) => (a.issueCount - b.issueCount)); 182 | break; 183 | case SORT_AXEV: 184 | records.sort((a, b) => (a.axeStats.violations.length - 185 | b.axeStats.violations.length)); 186 | break; 187 | case SORT_AXEP: 188 | records.sort((a, b) => (a.axeStats.passes - 189 | b.axeStats.passes)); 190 | break; 191 | } 192 | 193 | if (this.props.isDescending) { 194 | records.reverse(); 195 | } 196 | 197 | return records; 198 | } 199 | 200 | render() { 201 | const records = this.sortRecords(); 202 | const headerProps = { 203 | currSort: this.props.sortBy, 204 | isDescending: this.props.isDescending, 205 | onSort: this.handleSort, 206 | isEnhanced: this.props.isEnhanced 207 | }; 208 | 209 | return ( 210 | 211 | 212 | 213 |
214 | Homepage 215 |
216 |
217 | GitHub repository 218 |
219 |
220 | GitHub a11y issues 221 |
222 |
223 | aXe violations 224 |
225 |
226 | aXe passes 227 |
228 | 229 | 230 | 231 | {records.map(record => 232 | 233 | )} 234 | 235 |
236 | ); 237 | } 238 | } 239 | 240 | module.exports = Table; 241 | -------------------------------------------------------------------------------- /flow-typed/npm/selenium-webdriver_v3.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: bfd4c76f1ba49cd92cdde14562ac7b6a 2 | // flow-typed version: 55f720db6c/selenium-webdriver_v3.x.x/flow_>=v0.42.x 3 | 4 | // @flow 5 | 6 | declare class webdriver$Capabilities { 7 | static android(): this; 8 | static chrome(): this; 9 | static edge(): this; 10 | static firefox(): this; 11 | static ie(): this; 12 | setScrollBehavior(behavior: number): this; 13 | setLoggingPrefs(prefs: webdriver$logging$Preferences): this; 14 | } 15 | 16 | declare class webdriver$Options { 17 | addCookie(spec: any): webdriver$Thenable; 18 | deleteAllCookies(): webdriver$Thenable; 19 | deleteCookie(name: string): webdriver$Thenable; 20 | getCookies(): webdriver$Thenable; 21 | getCookie(name: string): webdriver$Thenable; 22 | timeouts(): webdriver$Timeouts; 23 | window(): webdriver$Window; 24 | logs(): webdriver$Logs; 25 | } 26 | 27 | declare class webdriver$Logs { 28 | get(type: webdriver$logging$Type): webdriver$Thenable; 29 | getAvailableLogTypes(): webdriver$Thenable; 30 | } 31 | 32 | declare class webdriver$Window { 33 | maximize(): webdriver$Thenable; 34 | getSize(): webdriver$Thenable; 35 | setSize(width: number, height: number): webdriver$Thenable; 36 | } 37 | 38 | declare class webdriver$Timeouts { 39 | implicitlyWait(ms: number): webdriver$Thenable; 40 | setScriptTimeout(ms: number): webdriver$Thenable; 41 | pageLoadTimeout(ms: number): webdriver$Thenable; 42 | } 43 | 44 | declare class webdriver$Navigation { 45 | refresh(): webdriver$Thenable; 46 | } 47 | 48 | declare class webdriver$Builder { 49 | constructor(): this; 50 | withCapabilities(capabilities: webdriver$Capabilities): this; 51 | forBrowser(name: string, opt_version?: string, opt_platform?: string): this; 52 | 53 | // Added by AV on 4/7/2017 54 | usingServer(url: string): this; 55 | 56 | build(): webdriver$WebDriver; 57 | } 58 | 59 | declare class webdriver$WebDriver { 60 | close(): webdriver$Thenable; 61 | quit(): webdriver$Thenable; 62 | findElement(locator: webdriver$By|Function): webdriver$WebElementPromise; 63 | findElements(locator: webdriver$By|Function): webdriver$WebElementPromise; 64 | get(url: string): webdriver$Thenable; 65 | getTitle(): webdriver$Thenable; 66 | getCurrentUrl(): webdriver$Thenable; 67 | navigate(): webdriver$Navigation; 68 | manage(): webdriver$Options; 69 | executeScript(args: any): webdriver$Thenable; 70 | 71 | // Added by AV on 4/6/2017 72 | executeAsyncScript(args: any): any; 73 | 74 | takeScreenshot(): webdriver$Thenable; 75 | wait(condition: webdriver$Condition|Function, timeout: ?number, message: ?string): webdriver$Thenable; 76 | sleep(ms: number): webdriver$Thenable; 77 | } 78 | 79 | declare class webdriver$By { 80 | constructor(using: string, value: string): this; 81 | static className(name: string): this; 82 | static css(selector: string): this; 83 | static id(id: string): this; 84 | static linkText(text: string): this; 85 | static js(script: string, var_args: any): this; 86 | static name(name: string): this; 87 | static partialLinkText(text: string): this; 88 | static tagName(name: string): this; 89 | static xpath(xpath: string): this; 90 | } 91 | 92 | declare class webdriver$WebElement { 93 | getId(): webdriver$Thenable; 94 | click(): webdriver$Thenable; 95 | sendKeys(var_args: any): webdriver$Thenable; 96 | } 97 | 98 | declare class webdriver$WebElementPromise extends webdriver$WebElement { 99 | then(f: Function): this; 100 | catch(f: Function): this; 101 | } 102 | 103 | declare class webdriver$Thenable { 104 | then(f: Function): this; 105 | catch(f: Function): this; 106 | } 107 | 108 | declare class webdriver$Resolver { 109 | promise: webdriver$Thenable; 110 | fulfill(obj: any): void; 111 | reject(obj: any): void; 112 | } 113 | 114 | declare interface webdriver$promise { 115 | defer(): webdriver$Resolver; 116 | all(arr: Array):webdriver$Thenable; 117 | } 118 | 119 | declare class webdriver$Condition { 120 | } 121 | 122 | declare interface webdriver$until { 123 | ableToSwitchToFrame(frame: number|webdriver$WebElement|webdriver$By|Function): webdriver$Condition; 124 | alertIsPresent(): webdriver$Condition; 125 | titleIs(title: string): webdriver$Condition; 126 | titleContains(substr: string): webdriver$Condition; 127 | titleMatches(regex: RegExp): webdriver$Condition; 128 | urlIs(url: string): webdriver$Condition; 129 | urlContains(substrUrl: string): webdriver$Condition; 130 | urlMatches(regex: RegExp): webdriver$Condition; 131 | elementLocated(locator: webdriver$By|Function): webdriver$Condition; 132 | elementsLocated(locator: webdriver$By|Function): webdriver$Condition; 133 | stalenessOf(element: webdriver$WebElement): webdriver$Condition; 134 | elementIsVisible(element: webdriver$WebElement): webdriver$Condition; 135 | elementIsNotVisible(element: webdriver$WebElement): webdriver$Condition; 136 | elementIsEnabled(element: webdriver$WebElement): webdriver$Condition; 137 | elementIsDisabled(element: webdriver$WebElement): webdriver$Condition; 138 | elementIsSelected(element: webdriver$WebElement): webdriver$Condition; 139 | elementIsNotSelected(element: webdriver$WebElement): webdriver$Condition; 140 | elementTextIs(element: webdriver$WebElement, text: string): webdriver$Condition; 141 | elementTextContains(element: webdriver$WebElement, substr: string): webdriver$Condition; 142 | elementTextMatches(element: webdriver$WebElement, regex: RegExp): webdriver$Condition; 143 | } 144 | 145 | declare type webdriver$Key = { 146 | NULL: '', 147 | CANCEL: '', 148 | HELP: '', 149 | BACK_SPACE: '', 150 | TAB: '', 151 | CLEAR: '', 152 | RETURN: '', 153 | ENTER: '', 154 | SHIFT: '', 155 | CONTROL: '', 156 | ALT: '', 157 | PAUSE: '', 158 | ESCAPE: '', 159 | SPACE: '', 160 | PAGE_UP: '', 161 | PAGE_DOWN: '', 162 | END: '', 163 | HOME: '', 164 | ARROW_LEFT: '', 165 | LEFT: '', 166 | ARROW_UP: '', 167 | UP: '', 168 | ARROW_RIGHT: '', 169 | RIGHT: '', 170 | ARROW_DOWN: '', 171 | DOWN: '', 172 | INSERT: '', 173 | DELETE: '', 174 | SEMICOLON: '', 175 | EQUALS: '', 176 | NUMPAD0: '', 177 | NUMPAD1: '', 178 | NUMPAD2: '', 179 | NUMPAD3: '', 180 | NUMPAD4: '', 181 | NUMPAD5: '', 182 | NUMPAD6: '', 183 | NUMPAD7: '', 184 | NUMPAD8: '', 185 | NUMPAD9: '', 186 | MULTIPLY: '', 187 | ADD: '', 188 | SEPARATOR: '', 189 | SUBTRACT: '', 190 | DECIMAL: '', 191 | DIVIDE: '', 192 | F1: '', 193 | F2: '', 194 | F3: '', 195 | F4: '', 196 | F5: '', 197 | F6: '', 198 | F7: '', 199 | F8: '', 200 | F9: '', 201 | F10: '', 202 | F11: '', 203 | F12: '', 204 | COMMAND: '', 205 | META: '' 206 | } 207 | 208 | declare class webdriver$logging$Type { 209 | BROWSER: webdriver$logging$Type; 210 | CLIENT: webdriver$logging$Type; 211 | DRIVER: webdriver$logging$Type; 212 | PERFORMANCE: webdriver$logging$Type; 213 | SERVER: webdriver$logging$Type; 214 | } 215 | 216 | declare class webdriver$logging$Level { 217 | OFF: webdriver$logging$Level; 218 | SEVERE: webdriver$logging$Level; 219 | WARNING: webdriver$logging$Level; 220 | INFO: webdriver$logging$Level; 221 | DEBUG: webdriver$logging$Level; 222 | FINE: webdriver$logging$Level; 223 | FINER: webdriver$logging$Level; 224 | } 225 | 226 | declare class webdriver$logging$Preferences { 227 | constructor(): this; 228 | setLevel(type: webdriver$logging$Type|string, level: webdriver$logging$Level|string|number): void; 229 | toJSON(): Object; 230 | } 231 | 232 | declare interface webdriver$logging { 233 | Type: webdriver$logging$Type; 234 | Level: webdriver$logging$Level; 235 | Preferences: Class; 236 | } 237 | 238 | declare module 'selenium-webdriver' { 239 | declare export var Key: webdriver$Key; 240 | declare export var Capabilities: Class; 241 | declare export var Options: Class; 242 | declare export var Logs: Class; 243 | declare export var Window: Class; 244 | declare export var Timeouts: Class; 245 | declare export var Navigation: Class; 246 | declare export var Builder: Class; 247 | declare export var WebDriver: Class; 248 | declare export var By: Class; 249 | declare export var WebElement: Class; 250 | declare export var WebElementPromise: Class; 251 | declare export var Thenable: Class; 252 | 253 | declare export var until: webdriver$until; 254 | declare export var promise: webdriver$promise; 255 | declare export var logging: webdriver$logging; 256 | } 257 | -------------------------------------------------------------------------------- /flow-typed/npm/jest_v19.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: b3ed97c44539e6cdbaf9032b315a2b31 2 | // flow-typed version: ea7ac31527/jest_v19.x.x/flow_>=v0.33.x 3 | 4 | type JestMockFn = { 5 | (...args: Array): any, 6 | /** 7 | * An object for introspecting mock calls 8 | */ 9 | mock: { 10 | /** 11 | * An array that represents all calls that have been made into this mock 12 | * function. Each call is represented by an array of arguments that were 13 | * passed during the call. 14 | */ 15 | calls: Array>, 16 | /** 17 | * An array that contains all the object instances that have been 18 | * instantiated from this mock function. 19 | */ 20 | instances: mixed, 21 | }, 22 | /** 23 | * Resets all information stored in the mockFn.mock.calls and 24 | * mockFn.mock.instances arrays. Often this is useful when you want to clean 25 | * up a mock's usage data between two assertions. 26 | */ 27 | mockClear(): Function, 28 | /** 29 | * Resets all information stored in the mock. This is useful when you want to 30 | * completely restore a mock back to its initial state. 31 | */ 32 | mockReset(): Function, 33 | /** 34 | * Accepts a function that should be used as the implementation of the mock. 35 | * The mock itself will still record all calls that go into and instances 36 | * that come from itself -- the only difference is that the implementation 37 | * will also be executed when the mock is called. 38 | */ 39 | mockImplementation(fn: Function): JestMockFn, 40 | /** 41 | * Accepts a function that will be used as an implementation of the mock for 42 | * one call to the mocked function. Can be chained so that multiple function 43 | * calls produce different results. 44 | */ 45 | mockImplementationOnce(fn: Function): JestMockFn, 46 | /** 47 | * Just a simple sugar function for returning `this` 48 | */ 49 | mockReturnThis(): void, 50 | /** 51 | * Deprecated: use jest.fn(() => value) instead 52 | */ 53 | mockReturnValue(value: any): JestMockFn, 54 | /** 55 | * Sugar for only returning a value once inside your mock 56 | */ 57 | mockReturnValueOnce(value: any): JestMockFn, 58 | } 59 | 60 | type JestAsymmetricEqualityType = { 61 | /** 62 | * A custom Jasmine equality tester 63 | */ 64 | asymmetricMatch(value: mixed): boolean, 65 | } 66 | 67 | type JestCallsType = { 68 | allArgs(): mixed, 69 | all(): mixed, 70 | any(): boolean, 71 | count(): number, 72 | first(): mixed, 73 | mostRecent(): mixed, 74 | reset(): void, 75 | } 76 | 77 | type JestClockType = { 78 | install(): void, 79 | mockDate(date: Date): void, 80 | tick(): void, 81 | uninstall(): void, 82 | } 83 | 84 | type JestMatcherResult = { 85 | message?: string | ()=>string, 86 | pass: boolean, 87 | } 88 | 89 | type JestMatcher = (actual: any, expected: any) => JestMatcherResult; 90 | 91 | type JestExpectType = { 92 | not: JestExpectType, 93 | /** 94 | * If you have a mock function, you can use .lastCalledWith to test what 95 | * arguments it was last called with. 96 | */ 97 | lastCalledWith(...args: Array): void, 98 | /** 99 | * toBe just checks that a value is what you expect. It uses === to check 100 | * strict equality. 101 | */ 102 | toBe(value: any): void, 103 | /** 104 | * Use .toHaveBeenCalled to ensure that a mock function got called. 105 | */ 106 | toBeCalled(): void, 107 | /** 108 | * Use .toBeCalledWith to ensure that a mock function was called with 109 | * specific arguments. 110 | */ 111 | toBeCalledWith(...args: Array): void, 112 | /** 113 | * Using exact equality with floating point numbers is a bad idea. Rounding 114 | * means that intuitive things fail. 115 | */ 116 | toBeCloseTo(num: number, delta: any): void, 117 | /** 118 | * Use .toBeDefined to check that a variable is not undefined. 119 | */ 120 | toBeDefined(): void, 121 | /** 122 | * Use .toBeFalsy when you don't care what a value is, you just want to 123 | * ensure a value is false in a boolean context. 124 | */ 125 | toBeFalsy(): void, 126 | /** 127 | * To compare floating point numbers, you can use toBeGreaterThan. 128 | */ 129 | toBeGreaterThan(number: number): void, 130 | /** 131 | * To compare floating point numbers, you can use toBeGreaterThanOrEqual. 132 | */ 133 | toBeGreaterThanOrEqual(number: number): void, 134 | /** 135 | * To compare floating point numbers, you can use toBeLessThan. 136 | */ 137 | toBeLessThan(number: number): void, 138 | /** 139 | * To compare floating point numbers, you can use toBeLessThanOrEqual. 140 | */ 141 | toBeLessThanOrEqual(number: number): void, 142 | /** 143 | * Use .toBeInstanceOf(Class) to check that an object is an instance of a 144 | * class. 145 | */ 146 | toBeInstanceOf(cls: Class<*>): void, 147 | /** 148 | * .toBeNull() is the same as .toBe(null) but the error messages are a bit 149 | * nicer. 150 | */ 151 | toBeNull(): void, 152 | /** 153 | * Use .toBeTruthy when you don't care what a value is, you just want to 154 | * ensure a value is true in a boolean context. 155 | */ 156 | toBeTruthy(): void, 157 | /** 158 | * Use .toBeUndefined to check that a variable is undefined. 159 | */ 160 | toBeUndefined(): void, 161 | /** 162 | * Use .toContain when you want to check that an item is in a list. For 163 | * testing the items in the list, this uses ===, a strict equality check. 164 | */ 165 | toContain(item: any): void, 166 | /** 167 | * Use .toContainEqual when you want to check that an item is in a list. For 168 | * testing the items in the list, this matcher recursively checks the 169 | * equality of all fields, rather than checking for object identity. 170 | */ 171 | toContainEqual(item: any): void, 172 | /** 173 | * Use .toEqual when you want to check that two objects have the same value. 174 | * This matcher recursively checks the equality of all fields, rather than 175 | * checking for object identity. 176 | */ 177 | toEqual(value: any): void, 178 | /** 179 | * Use .toHaveBeenCalled to ensure that a mock function got called. 180 | */ 181 | toHaveBeenCalled(): void, 182 | /** 183 | * Use .toHaveBeenCalledTimes to ensure that a mock function got called exact 184 | * number of times. 185 | */ 186 | toHaveBeenCalledTimes(number: number): void, 187 | /** 188 | * Use .toHaveBeenCalledWith to ensure that a mock function was called with 189 | * specific arguments. 190 | */ 191 | toHaveBeenCalledWith(...args: Array): void, 192 | /** 193 | * Check that an object has a .length property and it is set to a certain 194 | * numeric value. 195 | */ 196 | toHaveLength(number: number): void, 197 | /** 198 | * 199 | */ 200 | toHaveProperty(propPath: string, value?: any): void, 201 | /** 202 | * Use .toMatch to check that a string matches a regular expression. 203 | */ 204 | toMatch(regexp: RegExp): void, 205 | /** 206 | * Use .toMatchObject to check that a javascript object matches a subset of the properties of an object. 207 | */ 208 | toMatchObject(object: Object): void, 209 | /** 210 | * This ensures that a React component matches the most recent snapshot. 211 | */ 212 | toMatchSnapshot(name?: string): void, 213 | /** 214 | * Use .toThrow to test that a function throws when it is called. 215 | */ 216 | toThrow(message?: string | Error): void, 217 | /** 218 | * Use .toThrowError to test that a function throws a specific error when it 219 | * is called. The argument can be a string for the error message, a class for 220 | * the error, or a regex that should match the error. 221 | */ 222 | toThrowError(message?: string | Error | RegExp): void, 223 | /** 224 | * Use .toThrowErrorMatchingSnapshot to test that a function throws a error 225 | * matching the most recent snapshot when it is called. 226 | */ 227 | toThrowErrorMatchingSnapshot(): void, 228 | } 229 | 230 | type JestObjectType = { 231 | /** 232 | * Disables automatic mocking in the module loader. 233 | * 234 | * After this method is called, all `require()`s will return the real 235 | * versions of each module (rather than a mocked version). 236 | */ 237 | disableAutomock(): JestObjectType, 238 | /** 239 | * An un-hoisted version of disableAutomock 240 | */ 241 | autoMockOff(): JestObjectType, 242 | /** 243 | * Enables automatic mocking in the module loader. 244 | */ 245 | enableAutomock(): JestObjectType, 246 | /** 247 | * An un-hoisted version of enableAutomock 248 | */ 249 | autoMockOn(): JestObjectType, 250 | /** 251 | * Clears the mock.calls and mock.instances properties of all mocks. 252 | * Equivalent to calling .mockClear() on every mocked function. 253 | */ 254 | clearAllMocks(): JestObjectType, 255 | /** 256 | * Resets the state of all mocks. Equivalent to calling .mockReset() on every 257 | * mocked function. 258 | */ 259 | resetAllMocks(): JestObjectType, 260 | /** 261 | * Removes any pending timers from the timer system. 262 | */ 263 | clearAllTimers(): void, 264 | /** 265 | * The same as `mock` but not moved to the top of the expectation by 266 | * babel-jest. 267 | */ 268 | doMock(moduleName: string, moduleFactory?: any): JestObjectType, 269 | /** 270 | * The same as `unmock` but not moved to the top of the expectation by 271 | * babel-jest. 272 | */ 273 | dontMock(moduleName: string): JestObjectType, 274 | /** 275 | * Returns a new, unused mock function. Optionally takes a mock 276 | * implementation. 277 | */ 278 | fn(implementation?: Function): JestMockFn, 279 | /** 280 | * Determines if the given function is a mocked function. 281 | */ 282 | isMockFunction(fn: Function): boolean, 283 | /** 284 | * Given the name of a module, use the automatic mocking system to generate a 285 | * mocked version of the module for you. 286 | */ 287 | genMockFromModule(moduleName: string): any, 288 | /** 289 | * Mocks a module with an auto-mocked version when it is being required. 290 | * 291 | * The second argument can be used to specify an explicit module factory that 292 | * is being run instead of using Jest's automocking feature. 293 | * 294 | * The third argument can be used to create virtual mocks -- mocks of modules 295 | * that don't exist anywhere in the system. 296 | */ 297 | mock(moduleName: string, moduleFactory?: any): JestObjectType, 298 | /** 299 | * Resets the module registry - the cache of all required modules. This is 300 | * useful to isolate modules where local state might conflict between tests. 301 | */ 302 | resetModules(): JestObjectType, 303 | /** 304 | * Exhausts the micro-task queue (usually interfaced in node via 305 | * process.nextTick). 306 | */ 307 | runAllTicks(): void, 308 | /** 309 | * Exhausts the macro-task queue (i.e., all tasks queued by setTimeout(), 310 | * setInterval(), and setImmediate()). 311 | */ 312 | runAllTimers(): void, 313 | /** 314 | * Exhausts all tasks queued by setImmediate(). 315 | */ 316 | runAllImmediates(): void, 317 | /** 318 | * Executes only the macro task queue (i.e. all tasks queued by setTimeout() 319 | * or setInterval() and setImmediate()). 320 | */ 321 | runTimersToTime(msToRun: number): void, 322 | /** 323 | * Executes only the macro-tasks that are currently pending (i.e., only the 324 | * tasks that have been queued by setTimeout() or setInterval() up to this 325 | * point) 326 | */ 327 | runOnlyPendingTimers(): void, 328 | /** 329 | * Explicitly supplies the mock object that the module system should return 330 | * for the specified module. Note: It is recommended to use jest.mock() 331 | * instead. 332 | */ 333 | setMock(moduleName: string, moduleExports: any): JestObjectType, 334 | /** 335 | * Indicates that the module system should never return a mocked version of 336 | * the specified module from require() (e.g. that it should always return the 337 | * real module). 338 | */ 339 | unmock(moduleName: string): JestObjectType, 340 | /** 341 | * Instructs Jest to use fake versions of the standard timer functions 342 | * (setTimeout, setInterval, clearTimeout, clearInterval, nextTick, 343 | * setImmediate and clearImmediate). 344 | */ 345 | useFakeTimers(): JestObjectType, 346 | /** 347 | * Instructs Jest to use the real versions of the standard timer functions. 348 | */ 349 | useRealTimers(): JestObjectType, 350 | /** 351 | * Creates a mock function similar to jest.fn but also tracks calls to 352 | * object[methodName]. 353 | */ 354 | spyOn(object: Object, methodName: string): JestMockFn, 355 | } 356 | 357 | type JestSpyType = { 358 | calls: JestCallsType, 359 | } 360 | 361 | /** Runs this function after every test inside this context */ 362 | declare function afterEach(fn: Function): void; 363 | /** Runs this function before every test inside this context */ 364 | declare function beforeEach(fn: Function): void; 365 | /** Runs this function after all tests have finished inside this context */ 366 | declare function afterAll(fn: Function): void; 367 | /** Runs this function before any tests have started inside this context */ 368 | declare function beforeAll(fn: Function): void; 369 | /** A context for grouping tests together */ 370 | declare function describe(name: string, fn: Function): void; 371 | 372 | /** An individual test unit */ 373 | declare var it: { 374 | /** 375 | * An individual test unit 376 | * 377 | * @param {string} Name of Test 378 | * @param {Function} Test 379 | */ 380 | (name: string, fn?: Function): ?Promise, 381 | /** 382 | * Only run this test 383 | * 384 | * @param {string} Name of Test 385 | * @param {Function} Test 386 | */ 387 | only(name: string, fn?: Function): ?Promise, 388 | /** 389 | * Skip running this test 390 | * 391 | * @param {string} Name of Test 392 | * @param {Function} Test 393 | */ 394 | skip(name: string, fn?: Function): ?Promise, 395 | /** 396 | * Run the test concurrently 397 | * 398 | * @param {string} Name of Test 399 | * @param {Function} Test 400 | */ 401 | concurrent(name: string, fn?: Function): ?Promise, 402 | }; 403 | declare function fit(name: string, fn: Function): ?Promise; 404 | /** An individual test unit */ 405 | declare var test: typeof it; 406 | /** A disabled group of tests */ 407 | declare var xdescribe: typeof describe; 408 | /** A focused group of tests */ 409 | declare var fdescribe: typeof describe; 410 | /** A disabled individual test */ 411 | declare var xit: typeof it; 412 | /** A disabled individual test */ 413 | declare var xtest: typeof it; 414 | 415 | /** The expect function is used every time you want to test a value */ 416 | declare var expect: { 417 | /** The object that you want to make assertions against */ 418 | (value: any): JestExpectType, 419 | /** Add additional Jasmine matchers to Jest's roster */ 420 | extend(matchers: {[name:string]: JestMatcher}): void, 421 | /** Add a module that formats application-specific data structures. */ 422 | addSnapshotSerializer(serializer: (input: Object) => string): void, 423 | assertions(expectedAssertions: number): void, 424 | any(value: mixed): JestAsymmetricEqualityType, 425 | anything(): void, 426 | arrayContaining(value: Array): void, 427 | objectContaining(value: Object): void, 428 | /** Matches any received string that contains the exact expected string. */ 429 | stringContaining(value: string): void, 430 | stringMatching(value: string | RegExp): void, 431 | }; 432 | 433 | // TODO handle return type 434 | // http://jasmine.github.io/2.4/introduction.html#section-Spies 435 | declare function spyOn(value: mixed, method: string): Object; 436 | 437 | /** Holds all functions related to manipulating test runner */ 438 | declare var jest: JestObjectType 439 | 440 | /** 441 | * The global Jamine object, this is generally not exposed as the public API, 442 | * using features inside here could break in later versions of Jest. 443 | */ 444 | declare var jasmine: { 445 | DEFAULT_TIMEOUT_INTERVAL: number, 446 | any(value: mixed): JestAsymmetricEqualityType, 447 | anything(): void, 448 | arrayContaining(value: Array): void, 449 | clock(): JestClockType, 450 | createSpy(name: string): JestSpyType, 451 | createSpyObj(baseName: string, methodNames: Array): {[methodName: string]: JestSpyType}, 452 | objectContaining(value: Object): void, 453 | stringMatching(value: string): void, 454 | } 455 | --------------------------------------------------------------------------------