diff --git a/.circleci/config.yml b/.circleci/config.yml index 3e627f3c1c..444dab6fce 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,7 +2,7 @@ version: 2 jobs: build: docker: - - image: circleci/node:10-browsers + - image: circleci/node:latest-browsers working_directory: ~/repo diff --git a/package.json b/package.json index 0af8897237..1011c86629 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "scripts": { "lint": "eslint tasks test src/ol examples config", "pretest": "npm run lint", - "test": "npm run karma -- --single-run --log-level error", + "test-rendering": "node rendering/test.js", + "test": "npm run karma -- --single-run --log-level error && npm run test-rendering -- --force", "karma": "karma start test/karma.config.js", "serve-examples": "webpack-dev-server --config examples/webpack/config.js --mode development --watch", "build-examples": "webpack --config examples/webpack/config.js --mode production", @@ -56,6 +57,7 @@ "front-matter": "^3.0.0", "fs-extra": "^7.0.0", "glob": "^7.1.2", + "globby": "^8.0.1", "handlebars": "4.0.11", "istanbul": "0.4.5", "jquery": "3.3.1", @@ -68,11 +70,14 @@ "karma-mocha": "1.3.0", "karma-sourcemap-loader": "^0.3.7", "karma-webpack": "^4.0.0-rc.2", + "loglevelnext": "^3.0.0", "marked": "0.5.1", "mocha": "5.2.0", "mustache": "^3.0.0", "pixelmatch": "^4.0.2", + "pngjs": "^3.3.3", "proj4": "2.5.0", + "puppeteer": "^1.10.0", "rollup": "0.66.6", "sinon": "^6.0.0", "typescript": "^3.1.0-dev.20180905", @@ -81,7 +86,9 @@ "walk": "^2.3.9", "webpack": "4.25.1", "webpack-cli": "^3.0.8", - "webpack-dev-server": "^3.1.4" + "webpack-dev-middleware": "^3.4.0", + "webpack-dev-server": "^3.1.10", + "yargs": "^12.0.2" }, "eslintConfig": { "extends": "openlayers", diff --git a/rendering/.eslintrc b/rendering/.eslintrc new file mode 100644 index 0000000000..07e46d0c56 --- /dev/null +++ b/rendering/.eslintrc @@ -0,0 +1,11 @@ +{ + "env": { + "node": true + }, + "parserOptions": { + "ecmaVersion": 2017 + }, + "globals": { + "render": false + } +} diff --git a/rendering/.gitignore b/rendering/.gitignore new file mode 100644 index 0000000000..9afaa0563f --- /dev/null +++ b/rendering/.gitignore @@ -0,0 +1,2 @@ +actual.png +pass diff --git a/rendering/cases/linestring-style/expected.png b/rendering/cases/linestring-style/expected.png new file mode 100644 index 0000000000..68797650fc Binary files /dev/null and b/rendering/cases/linestring-style/expected.png differ diff --git a/rendering/cases/linestring-style/index.html b/rendering/cases/linestring-style/index.html new file mode 100644 index 0000000000..96cfb5a582 --- /dev/null +++ b/rendering/cases/linestring-style/index.html @@ -0,0 +1,22 @@ + + + + + + +
+ + + + diff --git a/rendering/cases/linestring-style/main.js b/rendering/cases/linestring-style/main.js new file mode 100644 index 0000000000..800659c76f --- /dev/null +++ b/rendering/cases/linestring-style/main.js @@ -0,0 +1,69 @@ +import Map from '../../../src/ol/Map.js'; +import View from '../../../src/ol/View.js'; +import Feature from '../../../src/ol/Feature.js'; +import LineString from '../../../src/ol/geom/LineString.js'; +import VectorLayer from '../../../src/ol/layer/Vector.js'; +import VectorSource from '../../../src/ol/source/Vector.js'; +import Style from '../../../src/ol/style/Style.js'; +import Stroke from '../../../src/ol/style/Stroke.js'; + + +const vectorSource = new VectorSource(); +let feature; + +feature = new Feature({ + geometry: new LineString([[-60, 60], [45, 60]]) +}); +vectorSource.addFeature(feature); + +feature = new Feature({ + geometry: new LineString([[-60, -50], [30, 10]]) +}); +feature.setStyle(new Style({ + stroke: new Stroke({color: '#f00', width: 3}) +})); +vectorSource.addFeature(feature); + +feature = new Feature({ + geometry: new LineString([[-110, -100], [0, 100], [100, -90]]) +}); +feature.setStyle(new Style({ + stroke: new Stroke({ + color: 'rgba(55, 55, 55, 0.75)', + width: 5, + lineCap: 'square', + lineDash: [4, 8], + lineJoin: 'round' + }) +})); +vectorSource.addFeature(feature); + +feature = new Feature({ + geometry: new LineString([[-80, 80], [80, 80], [-40, -90]]) +}); +feature.setStyle([ + new Style({ + stroke: new Stroke({color: '#F2F211', width: 5}) + }), + new Style({ + stroke: new Stroke({color: '#292921', width: 1}) + }) +]); +vectorSource.addFeature(feature); + + +new Map({ + pixelRatio: 1, + layers: [ + new VectorLayer({ + source: vectorSource + }) + ], + target: 'map', + view: new View({ + center: [0, 0], + resolution: 1 + }) +}); + +render(); diff --git a/rendering/cases/multiple-layers/expected.png b/rendering/cases/multiple-layers/expected.png new file mode 100644 index 0000000000..60ccc74409 Binary files /dev/null and b/rendering/cases/multiple-layers/expected.png differ diff --git a/rendering/cases/multiple-layers/index.html b/rendering/cases/multiple-layers/index.html new file mode 100644 index 0000000000..96cfb5a582 --- /dev/null +++ b/rendering/cases/multiple-layers/index.html @@ -0,0 +1,22 @@ + + + + + + +
+ + + + diff --git a/rendering/cases/multiple-layers/main.js b/rendering/cases/multiple-layers/main.js new file mode 100644 index 0000000000..dcce3a0d06 --- /dev/null +++ b/rendering/cases/multiple-layers/main.js @@ -0,0 +1,33 @@ +import Map from '../../../src/ol/Map.js'; +import View from '../../../src/ol/View.js'; +import {Vector as VectorLayer, Tile as TileLayer} from '../../../src/ol/layer.js'; +import {Vector as VectorSource, OSM} from '../../../src/ol/source.js'; +import Point from '../../../src/ol/geom/Point.js'; +import Feature from '../../../src/ol/Feature.js'; +import {fromLonLat} from '../../../src/ol/proj.js'; + +const center = fromLonLat([-111, 45.7]); + +new Map({ + layers: [ + new TileLayer({ + source: new OSM() + }), + new VectorLayer({ + source: new VectorSource({ + features: [ + new Feature( + new Point(center) + ) + ] + }) + }) + ], + target: 'map', + view: new View({ + center: center, + zoom: 4 + }) +}); + +render(); diff --git a/rendering/cases/single-layer/expected.png b/rendering/cases/single-layer/expected.png new file mode 100644 index 0000000000..f3747f13a1 Binary files /dev/null and b/rendering/cases/single-layer/expected.png differ diff --git a/rendering/cases/single-layer/index.html b/rendering/cases/single-layer/index.html new file mode 100644 index 0000000000..8ee3c8d0a6 --- /dev/null +++ b/rendering/cases/single-layer/index.html @@ -0,0 +1,22 @@ + + + + + + +
+ + + + \ No newline at end of file diff --git a/rendering/cases/single-layer/main.js b/rendering/cases/single-layer/main.js new file mode 100644 index 0000000000..0fe6335004 --- /dev/null +++ b/rendering/cases/single-layer/main.js @@ -0,0 +1,19 @@ +import Map from '../../../src/ol/Map.js'; +import View from '../../../src/ol/View.js'; +import TileLayer from '../../../src/ol/layer/Tile.js'; +import OSM from '../../../src/ol/source/OSM.js'; + +new Map({ + layers: [ + new TileLayer({ + source: new OSM() + }) + ], + target: 'map', + view: new View({ + center: [0, 0], + zoom: 0 + }) +}); + +render(); diff --git a/rendering/test.js b/rendering/test.js new file mode 100755 index 0000000000..4d2c915f43 --- /dev/null +++ b/rendering/test.js @@ -0,0 +1,325 @@ +#! /usr/bin/env node +const puppeteer = require('puppeteer'); +const webpack = require('webpack'); +const config = require('./webpack.config'); +const middleware = require('webpack-dev-middleware'); +const http = require('http'); +const path = require('path'); +const png = require('pngjs'); +const fs = require('fs'); +const fse = require('fs-extra'); +const pixelmatch = require('pixelmatch'); +const yargs = require('yargs'); +const log = require('loglevelnext'); +const globby = require('globby'); + +const compiler = webpack(Object.assign({mode: 'development'}, config)); + +function getHref(entry) { + return path.dirname(entry).slice(1) + '/'; +} + +function notFound(req, res) { + return () => { + if (req.url === '/favicon.ico') { + res.writeHead(204); + res.end(); + return; + } + + const items = []; + for (const key in config.entry) { + const href = getHref(config.entry[key]); + items.push(`
  • ${href}
  • `); + } + const markup = ``; + + res.writeHead(404, { + 'Content-Type': 'text/html' + }); + res.end(markup); + }; +} + +function serve(options) { + const handler = middleware(compiler, { + lazy: true, + logger: options.log, + stats: 'minimal' + }); + + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + handler(req, res, notFound(req, res)); + }); + + server.listen(options.port, options.host, err => { + if (err) { + return reject(err); + } + const address = server.address(); + options.log.info(`test server listening http://${address.address}:${address.port}/`); + resolve(() => server.close()); + }); + }); +} + +function getActualScreenshotPath(entry) { + return path.join(__dirname, path.dirname(entry), 'actual.png'); +} + +function getExpectedScreenshotPath(entry) { + return path.join(__dirname, path.dirname(entry), 'expected.png'); +} + +function getPassFilePath(entry) { + return path.join(__dirname, path.dirname(entry), 'pass'); +} + +function parsePNG(filepath) { + return new Promise((resolve, reject) => { + const stream = fs.createReadStream(filepath); + stream.on('error', err => { + if (err.code === 'ENOENT') { + return reject(new Error(`File not found: ${filepath}`)); + } + reject(err); + }); + + const image = stream.pipe(new png.PNG()); + image.on('parsed', () => resolve(image)); + image.on('error', reject); + }); +} + +async function match(actual, expected) { + const actualImage = await parsePNG(actual); + const expectedImage = await parsePNG(expected); + const width = expectedImage.width; + const height = expectedImage.height; + if (actualImage.width != width) { + throw new Error(`Unexpected width for ${actual}: expected ${width}, got ${actualImage.width}`); + } + if (actualImage.height != height) { + throw new Error(`Unexpected height for ${actual}: expected ${height}, got ${actualImage.height}`); + } + const count = pixelmatch(actualImage.data, expectedImage.data, null, width, height); + return count / (width * height); +} + +async function assertScreenshotsMatch(entry) { + const actual = getActualScreenshotPath(entry); + const expected = getExpectedScreenshotPath(entry); + let mismatch, error; + try { + mismatch = await match(actual, expected); + } catch (err) { + error = err; + } + if (error) { + return error; + } + if (mismatch) { + return new Error(`${entry} mistmatch: ${mismatch}`); + } +} + +let handleRender; +async function exposeRender(page) { + await page.exposeFunction('render', () => { + if (!handleRender) { + throw new Error('No render handler set for current page'); + } + handleRender(); + }); +} + +async function renderPage(page, entry, options) { + const renderCalled = new Promise(resolve => { + handleRender = () => { + handleRender = null; + resolve(); + }; + }); + await page.goto(`http://${options.host}:${options.port}${getHref(entry)}`, {waitUntil: 'networkidle0'}); + await renderCalled; + await page.screenshot({path: getActualScreenshotPath(entry)}); +} + +async function touch(filepath) { + const fd = await fse.open(filepath, 'w'); + await fse.close(fd); +} + +async function copyActualToExpected(entry) { + const actual = getActualScreenshotPath(entry); + const expected = getExpectedScreenshotPath(entry); + await fse.copy(actual, expected); + await touch(getPassFilePath(entry)); +} + +async function renderEach(page, entries, options) { + let fail = false; + for (const entry of entries) { + await renderPage(page, entry, options); + if (options.fix) { + await copyActualToExpected(entry); + continue; + } + const error = await assertScreenshotsMatch(entry); + if (error) { + process.stderr.write(`${error.message}\n`); + fail = true; + continue; + } + + await touch(getPassFilePath(entry)); + } + return fail; +} + +async function render(entries, options) { + const browser = await puppeteer.launch({ + args: options.puppeteerArgs, + headless: !process.env.CI + }); + + let fail = false; + + try { + const page = await browser.newPage(); + page.on('error', err => { + options.log.error('page crash', err); + }); + page.on('pageerror', err => { + options.log.error('uncaught exception', err); + }); + page.on('console', message => { + const type = message.type(); + if (options.log[type]) { + options.log[type](message.text()); + } + }); + + page.setDefaultNavigationTimeout(options.timeout); + await exposeRender(page); + await page.setViewport({width: 256, height: 256}); + fail = await renderEach(page, entries, options); + } finally { + browser.close(); + } + + if (fail) { + throw new Error('RENDERING TESTS FAILED'); + } +} + +async function getLatest(patterns) { + const stats = await globby(patterns, {stats: true}); + let latest = 0; + for (const stat of stats) { + if (stat.mtime > latest) { + latest = stat.mtime; + } + } + return latest; +} + +async function getOutdated(entries, options) { + const libTime = await getLatest(path.join(__dirname, '..', 'src', 'ol', '**', '*')); + options.log.debug('library time', libTime); + const outdated = []; + for (const entry of entries) { + const passPath = getPassFilePath(entry); + const passTime = await getLatest(passPath); + options.log.debug(entry, 'pass time', passTime); + if (passTime < libTime) { + outdated.push(entry); + continue; + } + + const caseTime = await getLatest(path.join(__dirname, path.dirname(entry), '**', '*')); + options.log.debug(entry, 'case time', caseTime); + if (passTime < caseTime) { + outdated.push(entry); + continue; + } + + options.log.info('skipping', entry); + } + return outdated; +} + +async function main(entries, options) { + if (!options.force) { + entries = await getOutdated(entries, options); + } + if (entries.length === 0) { + return; + } + + const done = await serve(options); + try { + await render(entries, options); + } finally { + if (!options.interactive) { + done(); + } + } +} + +if (require.main === module) { + + const options = yargs. + option('fix', { + describe: 'Accept all screenshots as accepted', + default: false + }). + option('host', { + describe: 'The host for serving rendering cases', + default: '127.0.0.1' + }). + option('port', { + describe: 'The port for serving rendering cases', + type: 'number', + default: 3000 + }). + option('timeout', { + describe: 'The timeout for loading pages (in milliseconds)', + type: 'number', + default: 60000 + }). + option('force', { + describe: 'Run all tests (instead of just outdated tests)', + type: 'boolean', + default: false + }). + option('interactive', { + describe: 'Run all tests and keep the test server running (this option will be reworked later)', + type: 'boolean', + default: false + }). + option('log-level', { + describe: 'The level for logging', + choices: ['trace', 'debug', 'info', 'warn', 'error', 'silent'], + default: 'error' + }). + option('puppeteer-args', { + describe: 'Args of for puppeteer.launch()', + type: 'array', + default: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [] + }). + parse(); + + const entries = Object.keys(config.entry).map(key => config.entry[key]); + + if (options.interactive) { + options.force = true; + } + options.log = log.create({name: 'rendering', level: options.logLevel}); + + main(entries, options).catch(err => { + options.log.error(err.message); + process.exit(1); + }); +} diff --git a/rendering/webpack.config.js b/rendering/webpack.config.js new file mode 100644 index 0000000000..2541c394f5 --- /dev/null +++ b/rendering/webpack.config.js @@ -0,0 +1,36 @@ +const CopyPlugin = require('copy-webpack-plugin'); +const fs = require('fs'); +const path = require('path'); + +const cases = path.join(__dirname, 'cases'); + +const caseDirs = fs.readdirSync(cases); + +const entry = {}; +caseDirs.forEach(c => { + entry[`cases/${c}/main`] = `./cases/${c}/main.js`; +}); + +module.exports = { + context: __dirname, + target: 'web', + entry: entry, + module: { + rules: [{ + use: { + loader: 'buble-loader' + }, + test: /\.js$/, + include: [ + path.join(__dirname, '..', 'src') + ] + }] + }, + plugins: [ + new CopyPlugin([ + {from: '../src/ol/ol.css', to: 'css'}, + {from: 'cases/**/*.html'} + ]) + ], + devtool: 'source-map' +};