-
Notifications
You must be signed in to change notification settings - Fork 146
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Run aXe on a few pages during CI #344
Changes from 11 commits
6d736a1
c1e786b
b7688db
5e001b2
4731a04
dc2a867
36070be
dcd5070
24c93cc
c863e0d
c1117d0
3c0c42c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
'use strict'; | ||
|
||
const fs = require('fs'); | ||
const urlParse = require('url').parse; | ||
const chromeLauncher = require('chrome-launcher'); | ||
const CDP = require('chrome-remote-interface'); | ||
const chalk = require('chalk'); | ||
const runServer = require('./static-server'); | ||
|
||
const REMOTE_CHROME_URL = process.env['REMOTE_CHROME_URL']; | ||
const AXE_JS = fs.readFileSync(__dirname + '/../node_modules/axe-core/axe.js'); | ||
const PAGES = [ | ||
'/', | ||
'/page-templates/landing/', | ||
'/page-templates/docs/', | ||
]; | ||
|
||
function launchChromeLocally(headless=true) { | ||
return chromeLauncher.launch({ | ||
chromeFlags: [ | ||
'--window-size=412,732', | ||
'--disable-gpu', | ||
headless ? '--headless' : '' | ||
] | ||
}); | ||
} | ||
|
||
function getRemoteChrome() { | ||
const info = urlParse(REMOTE_CHROME_URL); | ||
if (info.protocol !== 'http:') | ||
throw new Error(`Unsupported protocol: ${info.protocol}`); | ||
return new Promise(resolve => { | ||
resolve({ | ||
host: info.hostname, | ||
port: info.port, | ||
kill() { return Promise.resolve(); } | ||
}); | ||
}); | ||
} | ||
|
||
// This function is only here so it can be easily .toString()'d | ||
// and run in the context of a web page by Chrome. It will not | ||
// be run in the node context. | ||
function runAxe() { | ||
return new Promise((resolve, reject) => { | ||
window.axe.run((err, results) => { | ||
if (err) return reject(err); | ||
resolve(JSON.stringify(results.violations)); | ||
}); | ||
}); | ||
} | ||
|
||
let getChrome = REMOTE_CHROME_URL ? getRemoteChrome : launchChromeLocally; | ||
|
||
Promise.all([runServer(), getChrome()]).then(([server, chrome]) => { | ||
const chromeHost = chrome.host || 'localhost'; | ||
console.log(`Static file server is listening at ${server.url}.`); | ||
console.log(`Chrome is debuggable on http://${chromeHost}:${chrome.port}.`); | ||
console.log(`Running aXe on:`); | ||
|
||
CDP({ | ||
host: chrome.host, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Above, it sounds like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, it will just be I was hoping that the API for |
||
port: chrome.port, | ||
}, client => { | ||
const {Page, Network, Runtime} = client; | ||
const pagesLeft = PAGES.slice(); | ||
const loadNextPage = () => { | ||
if (pagesLeft.length === 0) { | ||
console.log(`Finished visiting ${PAGES.length} pages with no errors.`); | ||
terminate(0); | ||
} else { | ||
const page = pagesLeft.pop(); | ||
const url = `${server.url}${page}`; | ||
process.stdout.write(` ${page} `); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's this doing? Why isn't it a simple There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
Page.navigate({url}); | ||
} | ||
}; | ||
const terminate = exitCode => { | ||
client.close().then(() => { | ||
chrome.kill().then(() => { | ||
// Note that we're not killing the server; this is because | ||
// the remote chrome instance (if we're using one) may be | ||
// keeping some network connections to the server alive, which | ||
// makes it harder to kill, so it's easier to just terminate. | ||
const color = exitCode === 0 ? chalk.green : chalk.red; | ||
console.log(color(`Terminating with exit code ${exitCode}.`)); | ||
process.exit(exitCode); | ||
}); | ||
}); | ||
}; | ||
|
||
process.on('unhandledRejection', (reason, p) => { | ||
console.log('Unhandled Rejection at:', p, 'reason:', reason); | ||
terminate(1); | ||
}); | ||
|
||
Promise.all([ | ||
Page.enable(), | ||
Network.enable(), | ||
]).then(() => { | ||
Network.responseReceived(({response}) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this when any page resource is received? Or just the main HTML document? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe it's when any page resource is received. So this effectively overlaps a bit with our crawler from #321. It was easier to do it that way than it was to check the URL and see if it matched the page we were requesting, but I could add such a conditional if you think it'd be better that way. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems OK to have this extra check There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, so I thought about it a bit, and realized that maybe it's actually good that it's counting any sub-resource failure as a 404 too. For example, if it's 404'ing on CSS or JS, then that changes the accessibility profile of the page--calculated color contrast ratios may be incorrect, or certain ARIA attributes may not be present on the evaluated page. Another thing is that the crawler from #321 isn't actually 100% accurate--the library we're using uses regular expressions to "scrape" resources for URLs, so it's actually possible that it might miss sub-resources that are noticed by actually loading a page in Chrome. So it might still be good to hard fail on sub-resource failures for the sake of redundancy, too. What do you think? At the very least, I think we should log warnings on sub-resource fails. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea I agree that it's good to consider sub-resource 404s as failures. |
||
if (response.status < 400) return; | ||
console.log(`${response.url} returned HTTP ${response.status}!`); | ||
terminate(1); | ||
}); | ||
Network.loadingFailed(details => { | ||
console.log("A network request failed to load."); | ||
console.log(details); | ||
terminate(1); | ||
}); | ||
Page.loadEventFired(() => { | ||
Runtime.evaluate({ | ||
expression: AXE_JS + ';(' + runAxe.toString() + ')()', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this possible to write using a template string so it's easier to parse? |
||
awaitPromise: true, | ||
}).then(details => { | ||
let errorFound = false; | ||
if (details.result.type === 'string') { | ||
const viols = JSON.parse(details.result.value); | ||
if (viols.length === 0) { | ||
console.log(chalk.green('OK')); | ||
} else { | ||
console.log(chalk.red(`Found ${viols.length} aXe violations.`)); | ||
console.log(viols); | ||
console.log(chalk.cyan( | ||
`\nTo debug these violations, install aXe at:\n\n` + | ||
` https://www.deque.com/products/axe/\n` | ||
)); | ||
errorFound = true; | ||
} | ||
} else { | ||
console.log('Unexpected result from CDP!'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's CDP? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, it stands for Chrome DevTools Protocol... I'll expand it to just say that! |
||
console.log(details.result); | ||
errorFound = true; | ||
} | ||
// Note that this means we're terminating on the first error we | ||
// find, rather than reporting the error and moving on to the | ||
// next page. We're doing this because it's possible that the | ||
// error is caused by a layout or include, which may result | ||
// in the same errors being reported across multiple pages, so | ||
// we'd rather avoid the potential spam and just exit early. | ||
if (errorFound) { | ||
terminate(1); | ||
} else { | ||
loadNextPage(); | ||
} | ||
}); | ||
}); | ||
|
||
loadNextPage(); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
const fs = require('fs'); | ||
const os = require('os'); | ||
const path = require('path'); | ||
const express = require('express'); | ||
|
||
const app = express(); | ||
|
||
const SITE_PATH = path.normalize(`${__dirname}/../_site`); | ||
|
||
if (fs.existsSync(SITE_PATH)) { | ||
app.use(express.static(SITE_PATH)); | ||
} else { | ||
console.log(`Please build the site before running me.`); | ||
process.exit(1); | ||
} | ||
|
||
module.exports = () => { | ||
return new Promise((resolve, reject) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this need a rejection handler somewhere? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah good CATCH, will add! |
||
const server = app.listen(() => { | ||
const hostname = os.hostname().toLowerCase(); | ||
const port = server.address().port; | ||
resolve({ | ||
hostname, | ||
port, | ||
url: `http://${hostname}:${port}`, | ||
httpServer: server, | ||
}); | ||
}); | ||
}); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was there more text that was supposed to go here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nah, it's just the heading text that appears before each individual page that's visited. The final output looks like this: