Implemented Gemini screenshot testing base

This commit is contained in:
squidfunk
2017-01-28 20:25:30 +01:00
parent 8f46cc6f09
commit 3783eceb09
22 changed files with 521 additions and 151 deletions

50
lib/servers/ecstatic.js Normal file
View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2016-2017 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import ecstatic from "ecstatic"
import * as http from "http"
/* ----------------------------------------------------------------------------
* Locals
* ------------------------------------------------------------------------- */
/* Static file server */
let server = null
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
export const start = (directory, port, done) => {
server = http.createServer(ecstatic({
root: directory
}))
/* Listen and register signal handlers */
server.listen(port, "127.0.0.1", done)
for (const signal of ["SIGTERM", "SIGINT", "exit"])
process.on(signal, stop)
}
export const stop = done => {
server.close(done)
}

View File

@@ -35,17 +35,26 @@ let server = null
export const start = done => {
selenium.start({}, (err, proc) => {
/* Register signal handlers */
for (const signal of ["SIGTERM", "SIGINT", "exit"])
process.on(signal, stop)
if (err) {
/* Install selenium, if not present */
if (/^Missing(.*)chromedriver$/.test(err.message)) {
selenium.install(done)
/* Start selenium again */
selenium.start({}, (err_, proc_) => {
server = proc_
new Promise(resolve => {
selenium.install({}, resolve)
})
/* Start selenium again */
.then(() => {
selenium.start({}, (err_, proc_) => {
server = proc_
done()
})
})
/* Otherwise, throw error */
} else {
throw err
@@ -53,19 +62,18 @@ export const start = done => {
}
/* Remember process handle */
server = server || proc
server = proc
done()
})
}
export const stop = () => {
if (server)
export const stop = done => {
if (server) {
if (typeof done === "function")
server.on("exit", done)
server.kill()
/* Unset, so we don't try to kill the server twice */
server = null
}
}
/* ----------------------------------------------------------------------------
* Signals
* ------------------------------------------------------------------------- */
for (const signal of ["SIGTERM", "SIGINT", "exit"])
process.on(signal, stop)

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2016-2017 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import clean from "del"
import vinyl from "vinyl-paths"
/* ----------------------------------------------------------------------------
* Task: clean files generated by visual tests
* ------------------------------------------------------------------------- */
export default (gulp, config) => {
return () => {
return gulp.src([
`${config.tests.visual}/data`,
"./gemini-report"
])
.pipe(vinyl(clean))
}
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (c) 2016-2017 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import child from "child_process"
import path from "path"
import through from "through2"
import util from "gulp-util"
/* ----------------------------------------------------------------------------
* Task: generate visual tests
* ------------------------------------------------------------------------- */
export default (gulp, config) => {
const theme = path.resolve(process.cwd(), config.views.build)
return () => {
return gulp.src(`${config.tests.visual}/suites/**/mkdocs.yml`)
.pipe(
through.obj(function(file, enc, done) {
if (file.isNull() || file.isStream())
return done()
/* Resolve test name and destination */
const name = path.relative(`${config.tests.visual}/suites`,
path.dirname(file.path))
const site = path.resolve(process.cwd(),
`${config.tests.visual}/data`, name, "_")
/* Generate test fixtures with freshly built theme */
const proc = child.spawnSync("mkdocs", [
"build", "--site-dir", site, "--theme-dir", theme
], {
cwd: path.dirname(file.path)
})
/* Emit error, if any */
if (proc.status)
this.emit("error", new util.PluginError("mkdocs",
`Terminated with errors: ${proc.stderr.toString()}`))
/* Terminate */
done()
}))
}
}

View File

@@ -20,62 +20,91 @@
* IN THE SOFTWARE.
*/
import child from "child_process"
import path from "path"
import * as ecstatic from "~/lib/servers/ecstatic"
import * as selenium from "~/lib/servers/selenium"
import Gemini from "gemini"
/* ----------------------------------------------------------------------------
* Task: start test runner
* Test runner: Selenium
* ------------------------------------------------------------------------- */
/* MkDocs server */
let server = null
class SeleniumTestRunner {
/**
* Start Selenium
*
* @param {Function} done - Resolve callback
*/
start(done) {
selenium.start(done)
}
/**
* Stop Selenium
*
* @param {Function} done - Resolve callback
*/
stop(done) {
selenium.stop(done)
}
}
/* ----------------------------------------------------------------------------
* Task: start test runner
* Task: run visual tests
* ------------------------------------------------------------------------- */
export default (gulp, config) => {
return () => {
export default (gulp, config, args) => {
return done => {
/* Start MkDocs server */
return new Promise(resolve => {
server = child.spawn("mkdocs", [
"serve", "--dev-addr", "127.0.0.1:8000"
], {
cwd: config.tests.visual,
stdio: [process.stdin, process.stdout, "pipe"]
})
/* Start static file server */
new Promise(resolve => {
ecstatic.start(`${config.tests.visual}/data`, 8000, resolve)
/* Wait for MkDocs server and resolve promise */
server.stderr.on("data", data => {
if (data.toString().match("Serving")) {
server.stderr.removeAllListeners("data")
resolve()
}
})
/* Start Selenium */
/* Create and start test runner */
}).then(() => {
return new Promise(resolve => {
selenium.start(() => resolve())
/* Start Gemini test runner depending on environment */
}).then(() => {
const gemini = require(path.join(
process.cwd(), `${config.tests.visual}/.gemini-local.json`))
return new Gemini(gemini).test("tests/visual/suites", {
reporters: ["html"]
return new Promise((resolve, reject) => {
const runner = new SeleniumTestRunner()
runner.start(err => {
return err ? reject(err) : resolve(runner)
})
})
.then(() => {
selenium.stop()
/* Setup and run Gemini */
.then(runner => {
const gemini = require(
path.join(process.cwd(), `${config.tests.visual}/config`,
process.env.CI || process.env.SAUCE
? "gemini-sauce.json"
: "gemini-local.json"))
/* Start Gemini and return runner upon finish */
return new Gemini(gemini).test(`${config.tests.visual}/suites`, {
reporters: [process.env.CI ? "flat" : "html"],
grep: args.grep ? new RegExp(args.grep, "i") : null,
browsers: args.browsers ? [].concat(args.browsers) : null
})
/* Return runner for graceful stop */
.then(() => {
return runner
})
})
/* Stop test runner */
.then(runner => {
return new Promise(resolve => {
runner.stop(resolve)
})
})
/* Stop static file server */
})
.then(() => {
server.kill()
ecstatic.stop(done)
}, err => {
return done(err)
})
}
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright (c) 2016-2017 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import fs from "fs"
import path from "path"
import through from "through2"
/* ----------------------------------------------------------------------------
* Task: update reference images for visual tests
* ------------------------------------------------------------------------- */
export default (gulp, config) => {
return () => {
const base = path.join(
process.cwd(), `${config.tests.visual}/config`)
/* Read Gemini configs and map browsers to screenshot directories */
const mapping = fs.readdirSync(base)
.reduce((result, filename) => {
return Object.assign(result, (gemini => {
return Object.keys(gemini.browsers)
.reduce((browsers, name) => {
browsers[name] = gemini.screenshotsDir
return browsers
}, {})
})(require(path.join(base, filename))))
}, {})
/* Prepare filenames */
const dest = path.join(process.cwd(), `${config.tests.visual}/baseline`)
return gulp.src("gemini-report/images/**/*~current.png")
.pipe(
through.obj(function(file, enc, done) {
if (file.isNull() || file.isStream())
return done()
/* Remove the state from the filename */
file.path = file.path.replace("~current", "")
/* Retrieve the folder for the environment of the baseline */
const folder = path.relative(dest,
mapping[path.basename(file.path, ".png")])
file.path = file.path.replace("images", `images/${folder}`)
/* Push file to next stage */
this.push(file)
done()
}))
/* Update reference images */
.pipe(gulp.dest(dest))
}
}