This is an automated email from the ASF dual-hosted git repository. shenyi pushed a commit to branch test-autorun in repository https://gitbox.apache.org/repos/asf/incubator-echarts.git
commit 9921edfa40bbdc58049144d7bc016c665a3e195a Author: pissang <bm2736...@gmail.com> AuthorDate: Sat Sep 7 00:47:57 2019 +0800 test: add interaction record tool, add headless control. --- package.json | 1 + test/runTest/blacklist.js | 6 +- test/runTest/cli.js | 21 ++++-- test/runTest/client/client.css | 28 ++++---- test/runTest/client/client.js | 22 ++++-- test/runTest/client/index.html | 16 +++-- test/runTest/recorder/index.html | 57 +++++++++++++++ test/runTest/recorder/recorder.css | 110 ++++++++++++++++++++++++++++ test/runTest/recorder/recorder.js | 142 +++++++++++++++++++++++++++++++++++++ test/runTest/server.js | 70 +++++++++++++----- test/runTest/store.js | 2 +- test/runTest/util.js | 4 ++ 12 files changed, 430 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index 16fde6c..463980d 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "rollup-plugin-node-resolve": "3.0.0", "rollup-plugin-uglify": "2.0.1", "seedrandom": "^3.0.3", + "semver": "^6.3.0", "serve-handler": "^6.1.1", "slugify": "^1.3.4", "socket.io": "^2.2.0" diff --git a/test/runTest/blacklist.js b/test/runTest/blacklist.js index 9e9ad44..90982d8 100644 --- a/test/runTest/blacklist.js +++ b/test/runTest/blacklist.js @@ -1,4 +1,8 @@ module.exports = [ '-cases.html', - 'geo-random-stream.html' + 'geo-random-stream.html', + 'chord.html', + 'lines-ny.html', + 'lines-ny-appendData.html', + 'linesGL-ny-appendData.html' ]; diff --git a/test/runTest/cli.js b/test/runTest/cli.js index e44ebe7..574cda8 100644 --- a/test/runTest/cli.js +++ b/test/runTest/cli.js @@ -6,6 +6,8 @@ const path = require('path'); const compareScreenshot = require('./compareScreenshot'); const {getTestName, getVersionDir, buildRuntimeCode} = require('./util'); const {origin} = require('./config'); +const program = require('commander'); + function getScreenshotDir() { return 'tmp/__screenshot__'; @@ -231,8 +233,8 @@ async function runTest(browser, testOpt, runtimeCode) { } -async function runTests(pendingTests) { - const browser = await puppeteer.launch({ headless: true }); +async function runTests(pendingTests, headless) { + const browser = await puppeteer.launch({ headless: headless }); // TODO Not hardcoded. // let runtimeCode = fs.readFileSync(path.join(__dirname, 'tmp/testRuntime.js'), 'utf-8'); let runtimeCode = await buildRuntimeCode(); @@ -260,12 +262,21 @@ async function runTests(pendingTests) { } // Handling input arguments. -const testsFileUrlList = process.argv[2] || ''; -runTests(testsFileUrlList.split(',').map(fileUrl => { +program + .option('-t, --tests <tests>', 'Tests names list') + .option('--no-headless', 'Not headless'); + +program.parse(process.argv); + +if (!program.tests) { + throw new Error('Tests are required'); +} + +runTests(program.tests.split(',').map(fileUrl => { return { fileUrl, name: getTestName(fileUrl), results: [], status: 'unsettled' }; -})); \ No newline at end of file +}), program.headless); \ No newline at end of file diff --git a/test/runTest/client/client.css b/test/runTest/client/client.css index cfaad1a..1991711 100644 --- a/test/runTest/client/client.css +++ b/test/runTest/client/client.css @@ -140,24 +140,28 @@ ::-webkit-scrollbar { - height:8px; - width:8px; - transition:all 0.3s ease-in-out; - border-radius:2px; - background: transparent; + height: 8px; + width: 8px; + -webkit-transition: all 0.3s ease-in-out; + transition: all 0.3s ease-in-out; + -webkit-border-radius: 2px; + border-radius: 2px } ::-webkit-scrollbar-button { - display:none; + display: none } ::-webkit-scrollbar-thumb { - width:8px; - min-height:15px; - background:rgba(50, 50, 50, 0.6) !important; - transition:all 0.3s ease-in-out;border-radius:2px; + width: 8px; + min-height: 15px; + background: rgba(50,50,50,0.6) !important; + -webkit-transition: all 0.3s ease-in-out; + transition: all 0.3s ease-in-out; + -webkit-border-radius: 2px; + border-radius: 2px } ::-webkit-scrollbar-thumb:hover { - background:rgba(0, 0, 0, 0.5) !important; -} \ No newline at end of file + background: rgba(0,0,0,0.5) !important +} diff --git a/test/runTest/client/client.js b/test/runTest/client/client.js index 0dc2607..38f4a75 100644 --- a/test/runTest/client/client.js +++ b/test/runTest/client/client.js @@ -1,4 +1,4 @@ -const socket = io(); +const socket = io('/client'); function processTestsData(tests, oldTestsData) { tests.forEach((test, idx) => { @@ -44,7 +44,9 @@ socket.on('connect', () => { running: false, allSelected: false, - lastSelectedIndex: -1 + lastSelectedIndex: -1, + + noHeadless: false, }, computed: { tests() { @@ -76,7 +78,11 @@ socket.on('connect', () => { }, currentTestUrl() { - return window.location.origin + '/test/' + this.currentTest.fileUrl; + return window.location.origin + '/test/' + this.currentTestName + '.html'; + }, + + currentTestRecordUrl() { + return window.location.origin + '/test/runTest/recorder/index.html#' + this.currentTestName; }, isSelectAllIndeterminate: { @@ -132,7 +138,7 @@ socket.on('connect', () => { }); if (tests.length > 0) { this.running = true; - socket.emit('run', tests); + socket.emit('run', {tests, noHeadless: this.noHeadless}); } }, stopTests() { @@ -154,7 +160,10 @@ socket.on('connect', () => { center: true }).then(value => { app.running = true; - socket.emit('run', msg.tests.map(test => test.name)); + socket.emit('run', { + tests: msg.tests.map(test => test.name), + noHeadless: this.noHeadless + }); }).catch(() => {}); } // TODO @@ -172,8 +181,7 @@ socket.on('connect', () => { }); function updateTestHash() { - let testName = window.location.hash.slice(1); - app.currentTestName = testName; + app.currentTestName = window.location.hash.slice(1); } updateTestHash(); diff --git a/test/runTest/client/index.html b/test/runTest/client/index.html index c45b49d..07880e0 100644 --- a/test/runTest/client/index.html +++ b/test/runTest/client/index.html @@ -20,15 +20,18 @@ <el-input v-model="searchString" size="mini" placeholder="Filter Tests"></el-input> <div class="controls"> <el-checkbox :indeterminate="isSelectAllIndeterminate" v-model="allSelected" @change="handleSelectAllChange"></el-checkbox> - <el-button-group> + <el-button + title="Sort By Failue Percentage" @click="toggleSort" circle size="mini" type="primary" icon="el-icon-sort" + ></el-button> + + <el-button-group style="margin-left: 10px"> <el-button title="Run Selected" @click="runSelectedTests" :loading="running" circle size="mini" type="primary" icon="el-icon-caret-right"></el-button> <el-button v-if="running" title="Run Selected" @click="stopTests" circle size="mini" type="primary" icon="el-icon-close"></el-button> </el-button-group> - <el-button - style="margin-left: 10px" - title="Sort By Failue Percentage" @click="toggleSort" circle size="mini" type="primary" icon="el-icon-sort" - ></el-button> + <el-checkbox v-model="noHeadless" label="Playback"></el-checkbox> + + <!-- <el-button-group> <el-button title="Select All" @click="selectAllTests" circle size="mini" type="primary" icon="el-icon-check"></el-button> <el-button title="Unselect All" @click="unselectAllTests" circle size="mini" type="primary" icon="el-icon-close"></el-button> @@ -55,7 +58,6 @@ :status="test.summary" ></el-progress> <a :href="'#' + test.name" class="menu-link">{{test.name}}</a> - </li> </ul> </el-aside> @@ -64,6 +66,7 @@ <div class="title"> <h3>{{currentTest.name}}</h3> <a target="_blank" :href="currentTestUrl"><i class="el-icon-link"></i>Open Demo</a> + <a target="_blank" :href="currentTestRecordUrl"><i class="el-icon-video-camera"></i>Record Interaction</a> </div> <div class="test-screenshots" v-for="result in currentTest.results"> @@ -162,7 +165,6 @@ <script src="client.js"></script> <link rel="stylesheet" href="client.css"> -<link rel="stylesheet" href=""> </body> </html> \ No newline at end of file diff --git a/test/runTest/recorder/index.html b/test/runTest/recorder/index.html new file mode 100644 index 0000000..883828d --- /dev/null +++ b/test/runTest/recorder/index.html @@ -0,0 +1,57 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta http-equiv="X-UA-Compatible" content="ie=edge"> +</head> +<body> +<div id="app" style="display: none"> + <iframe :src="url"></iframe> + <div id="recording-status" v-if="currentAction"> + <el-button + :icon="recordingAction ? 'el-icon-video-camera' : 'el-icon-video-camera'" + class="recording-button" circle :type="recordingAction ? 'danger' : 'info'" + > + </el-button> + <div class="hint"><span class="emphasis">SHIFT + R</span>to {{recordingAction ? 'stop' : 'start'}} recording</div> + </div> + <div id="actions"> + <el-card class="box-card"> + <div slot="header" class="clearfix"> + <span>Actions</span> + <el-button style="float: right;" type="primary" size="mini" icon="el-icon-circle-plus" @click="newAction">New</el-button> + </div> + <div v-for="action in actions" :class="{'action-item': true, 'active': action.name === currentAction.name}" @click.self="select(action.name)"> + {{action.name}} + + <div style="float:right" class="operations"> + <span>Data ({{action.ops.length}})</span> + <el-popover> + <div style="text-align: center">{ scrollX: {{action.scrollX}}, scrollY: {{action.scrollY}} }</div> + <div v-for="op in action.ops" style="text-align: center">{{op}}</div> + <!-- <el-button slot="reference" size="mini">OP ({{action.ops.length}})</el-button> --> + <i slot="reference" class="el-icon-caret-bottom"></i> + </el-popover> + <i slot="reference" class="el-icon-delete" @click="doDelete(action.name)"></i> + </div> + </div> + </el-card> + + </div> +</div> + +<script src="../../../node_modules/socket.io-client/dist/socket.io.js"></script> +<script src="https://unpkg.com/vue@2.6.10/dist/vue.js"></script> + +<!-- Element UI --> +<link rel="stylesheet" href="https://unpkg.com/element-ui@2.11.1/lib/theme-chalk/index.css"> +<script src="https://unpkg.com/element-ui@2.11.1/lib/index.js"></script> +<script src="https://unpkg.com/lodash@4.17.15/lodash.js"></script> + +<script src="recorder.js"></script> + +<link rel="stylesheet" href="recorder.css"> + +</body> +</html> \ No newline at end of file diff --git a/test/runTest/recorder/recorder.css b/test/runTest/recorder/recorder.css new file mode 100644 index 0000000..66d50f0 --- /dev/null +++ b/test/runTest/recorder/recorder.css @@ -0,0 +1,110 @@ +* { + font-family: "Helvetica Neue",Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","微软雅黑",Arial,sans-serif; +} + +iframe { + border: none; + position: absolute; + top: 50px; + left: 50%; + + width: 800px; + height: 600px; + margin-left: -400px; + + box-shadow: 0 0 30px rgba(0, 0, 0, 0.2); + overflow-x: hidden; +} + +#recording-status { + position: absolute; + bottom: 30px; + width: 100%; + border-radius: 20px; + text-align: center; +} + +#recording-status .recording-button { + width: 80px;; + height: 80px;; + border-radius: 50px; + font-size: 50px; +} + +#recording-status .hint { + font-size: 26px; + font-weight: 200; + margin: 20px; +} + +#recording-status .hint .emphasis { + color: #F56C6C; + font-weight: 400; + margin: 0 10px; +} + +#actions { + position: fixed; + right: 10px; + width: 300px; +} + +#actions .action-item { + line-height: 40px; + padding: 0 20px; + margin: 0 -20px; + cursor: pointer; +} + + +#actions .action-item:hover { + background: #eee; +} + +#actions .action-item.active { + background: #409Eff; + color: #ffffff; +} + +#actions .action-item .operations { + height: 30px; + font-size: 14px; +} +#actions .action-item .operations>* { + display: inline-block; + vertical-align: middle; +} + +#actions .action-item .operations .el-icon-delete { + color: #F56C6C; + margin-left: 10px; +} + + + +::-webkit-scrollbar { + height: 8px; + width: 8px; + -webkit-transition: all 0.3s ease-in-out; + transition: all 0.3s ease-in-out; + -webkit-border-radius: 2px; + border-radius: 2px +} + +::-webkit-scrollbar-button { + display: none +} + +::-webkit-scrollbar-thumb { + width: 8px; + min-height: 15px; + background: rgba(50,50,50,0.6) !important; + -webkit-transition: all 0.3s ease-in-out; + transition: all 0.3s ease-in-out; + -webkit-border-radius: 2px; + border-radius: 2px +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(0,0,0,0.5) !important +} diff --git a/test/runTest/recorder/recorder.js b/test/runTest/recorder/recorder.js new file mode 100644 index 0000000..73d3f22 --- /dev/null +++ b/test/runTest/recorder/recorder.js @@ -0,0 +1,142 @@ +const socket = io('/recorder'); + +const app = new Vue({ + el: '#app', + data: { + currentTestName: '', + actions: [], + currentAction: null, + recordingAction: null, + + deletePopoverVisible: false + }, + computed: { + url() { + if (!this.currentTestName) { + return ''; + } + return window.location.origin + '/test/' + this.currentTestName + '.html'; + } + }, + methods: { + newAction() { + this.currentAction = { + name: 'Action ' + (this.actions.length + 1), + ops: [] + }; + this.actions.push(this.currentAction); + }, + select(actionName) { + this.currentAction = this.actions.find(action => { + return action.name === actionName; + }); + }, + + doDelete(actionName) { + app.$confirm('Aure you sure?', 'Delete this action', { + confirmButtonText: 'Yes', + cancelButtonText: 'No', + type: 'warning' + }).then(() => { + this.deletePopoverVisible = false; + let idx = _.findIndex(this.actions, action => action.name === actionName); + if (idx >= 0) { + this.actions.splice(idx, 1); + saveData(); + } + }).catch(e => {}); + } + } +}); + +function saveData() { + // Save + if (app.currentTestName) { + socket.emit('save', { + testName: app.currentTestName, + actions: app.actions + }); + } +} + +function recordIframeEvents(iframe, app) { + let innerDocument = iframe.contentWindow.document; + + let startTime; + innerDocument.addEventListener('keyup', e => { + if (e.key.toLowerCase() === 'r' && e.shiftKey) { + if (!app.recordingAction) { + app.recordingAction = app.currentAction; + if (app.recordingAction) { + app.recordingAction.ops = []; + app.recordingAction.scrollY = iframe.contentWindow.scrollY; + app.recordingAction.scrollX = iframe.contentWindow.scrollX; + startTime = app.recordingAction.timestamp = Date.now(); + } + } + else { + app.recordingAction = null; + saveData(); + } + // Get scroll + } + }); + + function addMouseOp(type, e) { + if (app.recordingAction) { + app.recordingAction.ops.push({ + type, + time: Date.now() - startTime, + x: e.clientX, + y: e.clientY + }); + app.$notify.info({ + title: type, + message: `{x: ${e.clientX}, y: ${e.clientY}}`, + position: 'top-left' + }); + } + } + + innerDocument.body.addEventListener('mousemove', _.throttle(e => { + addMouseOp('mousemove', e); + }, 200), true); + + ['mouseup', 'mousedown', 'click'].forEach(eventType => { + innerDocument.body.addEventListener(eventType, e => { + addMouseOp(eventType, e); + }, true); + }); +} + +function init() { + app.$el.style.display = 'block'; + + socket.on('update', data => { + if (data.testName === app.currentTestName) { + app.actions = data.actions; + if (!app.currentAction) { + app.currentAction = app.actions[0]; + } + } + }); + + let $iframe = document.body.querySelector('iframe'); + $iframe.onload = () => { + console.log('loaded:' + app.currentTestName); + recordIframeEvents($iframe, app); + }; + + function updateTestHash() { + app.currentTestName = window.location.hash.slice(1); + socket.emit('changeTest', {testName: app.currentTestName}); + + } + updateTestHash(); + window.addEventListener('hashchange', updateTestHash); +} + +socket.on('connect', () => { + console.log('Connected'); + init(); +}); \ No newline at end of file diff --git a/test/runTest/server.js b/test/runTest/server.js index 8900f94..aa51450 100644 --- a/test/runTest/server.js +++ b/test/runTest/server.js @@ -2,11 +2,13 @@ const handler = require('serve-handler'); const http = require('http'); const path = require('path'); // const open = require('open'); -const fse = require('fs-extra'); const {fork} = require('child_process'); +const semver = require('semver'); const {port, origin} = require('./config'); -const {getTestsList, prepareTestsList, saveTestsList, mergeTestsResults} = require('./store'); -const {prepareEChartsVersion, buildRuntimeCode} = require('./util'); +const {getTestsList, updateTestsList, saveTestsList, mergeTestsResults} = require('./store'); +const {prepareEChartsVersion, getActionsFullPath} = require('./util'); +const fse = require('fs-extra'); +const fs = require('fs'); function serve() { const server = http.createServer((request, response) => { @@ -18,7 +20,7 @@ function serve() { }); server.listen(port, () => { - console.log(`Server started. ${origin}`); + // console.log(`Server started. ${origin}`); }); @@ -46,7 +48,7 @@ function stopRunningTests() { } } -function startTests(testsNameList, socket) { +function startTests(testsNameList, socket, noHeadless) { console.log(testsNameList.join(',')); stopRunningTests(); @@ -64,7 +66,9 @@ function startTests(testsNameList, socket) { socket.emit('update', {tests: getTestsList()}); testProcess = fork(path.join(__dirname, 'cli.js'), [ - pendingTests.map(testOpt => testOpt.fileUrl) + '--tests', + pendingTests.map(testOpt => testOpt.fileUrl).join(','), + ...(noHeadless ? ['--no-headless'] : []) ]); // Finished one test testProcess.on('message', testOpt => { @@ -82,36 +86,44 @@ function startTests(testsNameList, socket) { function checkPuppeteer() { try { - return require('puppeteer'); + const packageConfig = require('puppeteer/package.json'); + return semver.satisfies(packageConfig.version, '>=1.19.0'); } catch (e) { - return null; + return false; } } async function start() { if (!checkPuppeteer()) { // TODO Check version. - console.error(`Can't find puppeteer, use 'npm install puppeteer' to install`); + console.error(`Can't find puppeteer >= 1.19.0, use 'npm install puppeteer' to install or update`); return; } await prepareEChartsVersion('4.2.1'); // Expected version. await prepareEChartsVersion(); // Version to test - let runtimeCode = await buildRuntimeCode(); - fse.outputFileSync(path.join(__dirname, 'tmp/testRuntime.js'), runtimeCode, 'utf-8'); + // let runtimeCode = await buildRuntimeCode(); + // fse.outputFileSync(path.join(__dirname, 'tmp/testRuntime.js'), runtimeCode, 'utf-8'); // Start a static server for puppeteer open the html test cases. let {io} = serve(); - io.on('connect', async socket => { - await prepareTestsList(); + io.of('/client').on('connect', async socket => { + await updateTestsList(); socket.emit('update', {tests: getTestsList()}); - // TODO Stop previous? - socket.on('run', async testsNameList => { - await startTests(testsNameList, socket); + + socket.on('run', async data => { + // TODO Should broadcast to all sockets. + try { + console.log(data); + await startTests(data.tests, socket, data.noHeadless); + } + catch (e) { + console.error(e); + } socket.emit('finish'); }); socket.on('stop', () => { @@ -119,6 +131,32 @@ async function start() { }); }); + io.of('/recorder').on('connect', async socket => { + // await updateTestsList(); + socket.on('save', data => { + if (data.testName) { + fse.outputFile( + getActionsFullPath(data.testName), + JSON.stringify(data.actions, null, 2), + 'utf-8' + ); + } + // TODO Broadcast the change? + }); + socket.on('changeTest', data => { + try { + const actionData = fs.readFileSync(getActionsFullPath(data.testName), 'utf-8'); + socket.emit('update', { + testName: data.testName, + actions: JSON.parse(actionData) + }); + } + catch(e) { + // Can't find file. + } + }); + }); + console.log(`Dashboard: ${origin}/test/runTest/client/index.html`); // open(`${origin}/test/runTest/client/index.html`); diff --git a/test/runTest/store.js b/test/runTest/store.js index 4cd4fc9..9851b02 100644 --- a/test/runTest/store.js +++ b/test/runTest/store.js @@ -21,7 +21,7 @@ module.exports.getTestByFileUrl = function (url) { return _testsMap[url]; }; -module.exports.prepareTestsList = async function () { +module.exports.updateTestsList = async function () { let tmpFolder = path.join(__dirname, 'tmp'); fse.ensureDirSync(tmpFolder); _tests = []; diff --git a/test/runTest/util.js b/test/runTest/util.js index 95cab47..91c5819 100644 --- a/test/runTest/util.js +++ b/test/runTest/util.js @@ -17,6 +17,10 @@ function getVersionDir(version) { }; module.exports.getVersionDir = getVersionDir; +module.exports.getActionsFullPath = function (testName) { + return path.join(__dirname, 'actions', testName + '.json'); +}; + module.exports.prepareEChartsVersion = function (version) { let versionFolder = path.join(__dirname, getVersionDir(version)); --------------------------------------------------------------------- To unsubscribe, e-mail: commits-unsubscr...@echarts.apache.org For additional commands, e-mail: commits-h...@echarts.apache.org