From cfb4d0d767fd56b3f6367cfb4218ef4692396e70 Mon Sep 17 00:00:00 2001 From: a9t9 Date: Fri, 26 Jan 2018 15:02:20 +0100 Subject: [PATCH] V2.3.0 --- extension/csv_editor.html | 13 + extension/manifest.json | 5 +- src/actions/action_types.js | 7 +- src/actions/index.js | 229 ++++++-- src/app.js | 77 ++- src/app.scss | 27 + src/common/backup.js | 67 +++ src/common/capture_screenshot.js | 165 +++++- src/common/clipboard.js | 51 ++ src/common/command_runner.js | 70 +++ src/common/constant.js | 5 + src/common/convert_suite_utils.js | 61 ++ src/common/convert_utils.js | 3 +- src/common/csv_man.js | 78 +-- src/common/debugger.js | 108 ++++ src/common/file_man.js | 84 +++ src/common/inspector.js | 3 +- src/common/interpreter.js | 104 +++- src/common/ipc/ipc_promise.js | 21 +- src/common/player.js | 111 +++- src/common/screenshot_man.js | 30 + src/common/utils.js | 58 ++ src/common/variables.js | 6 +- src/common/web_extension.js | 4 +- src/components/edit_test_suite.js | 89 +++ src/components/editable_text.js | 122 ++++ src/components/header.js | 646 +++++++++++++++++++-- src/components/header.scss | 74 ++- src/config/preinstall_macros.js | 338 ++++++++++- src/config/preinstall_suites.js | 92 +++ src/containers/dashboard/bottom.js | 80 ++- src/containers/dashboard/dashboard.scss | 45 +- src/containers/dashboard/editor.js | 48 +- src/containers/dashboard/index.js | 2 - src/containers/sidebar/index.js | 97 ++++ src/containers/sidebar/sidebar.scss | 239 ++++++++ src/containers/sidebar/test_cases.js | 585 +++++++++++++++++++ src/containers/sidebar/test_suites.js | 598 +++++++++++++++++++ src/csv_editor.js | 103 ++++ src/csv_editor.scss | 36 ++ src/ext/bg.js | 175 ++++-- src/ext/content_script.js | 24 + src/index.js | 428 +++----------- src/init_player.js | 727 ++++++++++++++++++++++++ src/models/db.js | 5 + src/models/test_suite_model.js | 48 ++ src/reducers/index.js | 23 +- 47 files changed, 5311 insertions(+), 700 deletions(-) create mode 100644 extension/csv_editor.html create mode 100644 src/common/backup.js create mode 100644 src/common/clipboard.js create mode 100644 src/common/convert_suite_utils.js create mode 100644 src/common/debugger.js create mode 100644 src/common/file_man.js create mode 100644 src/common/screenshot_man.js create mode 100644 src/components/edit_test_suite.js create mode 100644 src/components/editable_text.js create mode 100644 src/config/preinstall_suites.js create mode 100644 src/containers/sidebar/index.js create mode 100644 src/containers/sidebar/sidebar.scss create mode 100644 src/containers/sidebar/test_cases.js create mode 100644 src/containers/sidebar/test_suites.js create mode 100644 src/csv_editor.js create mode 100644 src/csv_editor.scss create mode 100644 src/init_player.js create mode 100644 src/models/test_suite_model.js diff --git a/extension/csv_editor.html b/extension/csv_editor.html new file mode 100644 index 0000000..411118e --- /dev/null +++ b/extension/csv_editor.html @@ -0,0 +1,13 @@ + + + + + + + CSV Editor - Kantu for Chrome 🤖 Selenium IDE Light + + +
+ + + diff --git a/extension/manifest.json b/extension/manifest.json index 3f15e05..c19203c 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -4,7 +4,7 @@ "name": "Kantu Browser Automation", "description": "Web Browser Automation plus Selenium IDE Light", "short_name": "Kantu Macros", - "version": "2.0.2", + "version": "2.3.0", "icons": { "128": "logo.png" @@ -42,6 +42,9 @@ "tabs", "notifications", "cookies", + "debugger", + "clipboardRead", + "clipboardWrite", "file:///*", "http://*/*", "https://*/*", diff --git a/src/actions/action_types.js b/src/actions/action_types.js index d32e773..db73923 100644 --- a/src/actions/action_types.js +++ b/src/actions/action_types.js @@ -37,11 +37,15 @@ const simpleTypes = [ 'EDIT_NEW_TEST_CASE', 'ADD_TEST_CASES', 'RENAME_TEST_CASE', - 'REMOVE_CURRENT_TEST_CASE', + 'REMOVE_TEST_CASE', 'UPDATE_TEST_CASE_STATUS', 'SET_PLAYER_STATE', + 'SET_PLAYER_MODE', 'PLAYER_ADD_ERROR_COMMAND_INDEX', + 'SET_TEST_SUITES', + 'UPDATE_TEST_SUITE', + 'CUT_COMMAND', 'COPY_COMMAND', 'PASTE_COMMAND', @@ -56,6 +60,7 @@ const simpleTypes = [ 'STOP_PLAYING', 'SET_CSV_LIST', + 'SET_SCREENSHOT_LIST', 'UPDATE_CONFIG' ].reduce((prev, cur) => { diff --git a/src/actions/index.js b/src/actions/index.js index 6efeb8e..cc92a47 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -3,8 +3,11 @@ import { pick, until, on, map, compose } from '../common/utils' import csIpc from '../common/ipc/ipc_cs' import storage from '../common/storage' import testCaseModel, { normalizeCommand } from '../models/test_case_model' +import testSuiteModel from '../models/test_suite_model' import { getPlayer } from '../common/player' import { getCSVMan } from '../common/csv_man' +import { getScreenshotMan } from '../common/screenshot_man' +import backup from '../common/backup' import log from '../common/log' let recordedCount = 0 @@ -23,13 +26,11 @@ const saveConfig = (function () { let { config } = getState() config = config || {} - storage.set('config', config) - const savedSize = config.size ? config.size[config.showSidebar ? 'with_sidebar' : 'standard'] : null const finalSize = savedSize || ( config.showSidebar ? { - width: 720, + width: 850, height: 775 } : { width: 520, @@ -39,27 +40,18 @@ const saveConfig = (function () { if (finalSize.width !== lastSize.width || finalSize.height !== lastSize.height) { - until('find app dom', () => { - const $app = document.querySelector('.app') - return { - pass: !!$app, - result: $app - } - }, 100) - .then($app => { - if (config.showSidebar) { - $app.classList.add('with-sidebar') - } else { - $app.classList.remove('with-sidebar') + storage.get('config') + .then(oldConfig => { + if (oldConfig.showSidebar === config.showSidebar) return + + if (finalSize.width !== window.outerWidth || finalSize.height !== window.outerHeight) { + csIpc.ask('PANEL_RESIZE_WINDOW', { size: finalSize }) } }) - - if (finalSize.width !== window.outerWidth || finalSize.height !== window.outerHeight) { - csIpc.ask('PANEL_RESIZE_WINDOW', { size: finalSize }) - } - - lastSize = finalSize } + + storage.set('config', config) + lastSize = finalSize } })() @@ -158,8 +150,8 @@ export function insertCommand (cmdObj, index) { return { type: T.INSERT_COMMAND, data: { - command: cmdObj, - index: index + index, + command: cmdObj }, post: saveEditing } @@ -337,38 +329,53 @@ export function addTestCases (tcs) { } } -export function renameTestCase (name) { +export function renameTestCase (name, tcId) { return (dispatch, getState) => { - const state = getState() - const id = state.editor.editing.meta.src.id - const tc = state.editor.testCases.find(tc => tc.id === id) - const sameName = state.editor.testCases.find(tc => tc.id !== id && tc.name === name) + const state = getState() + const editingId = state.editor.editing.meta.src.id + const tc = state.editor.testCases.find(tc => tc.id === tcId) + const sameName = state.editor.testCases.find(tc => tc.name === name) + + if (!tc) { + return Promise.reject(new Error(`No test case found with id '${tcId}'!`)) + } if (sameName) { return Promise.reject(new Error('The test case name already exists!')) } - return testCaseModel.update(id, {...tc, name}) - .then(() => { + return testCaseModel.update(tcId, {...tc, name}) + .then(() => { + if (editingId === tcId) { dispatch({ type: T.RENAME_TEST_CASE, data: name, post: saveEditing }) - }) + } + }) } } -export function removeCurrentTestCase () { +export function removeTestCase (tcId) { return (dispatch, getState) => { const state = getState() - const id = state.editor.editing.meta.src.id + const curId = state.editor.editing.meta.src.id + const tss = state.editor.testSuites.filter(ts => { + return ts.cases.find(m => m.testCaseId === tcId) + }) - return testCaseModel.remove(id) + if (tss.length > 0) { + return Promise.reject(new Error(`Can't delete this macro for now, it's currently used in following test suites: ${tss.map(item => item.name)}`)) + } + + return testCaseModel.remove(tcId) .then(() => { dispatch({ - type: T.REMOVE_CURRENT_TEST_CASE, - data: null, + type: T.REMOVE_TEST_CASE, + data: { + isCurrent: curId === tcId + }, post: saveEditing }) }) @@ -376,9 +383,32 @@ export function removeCurrentTestCase () { } } +export function removeCurrentTestCase () { + return (dispatch, getState) => { + const state = getState() + const id = state.editor.editing.meta.src.id + + return removeTestCase(id)(dispatch, getState) + } +} + // Note: duplicate current editing and save to another -export function duplicateTestCase (newTestCaseName) { - return saveEditingAsNew(newTestCaseName) +export function duplicateTestCase (newTestCaseName, tcId) { + return (dispatch, getState) => { + const state = getState() + const tc = state.editor.testCases.find(tc => tc.id === tcId) + const sameName = state.editor.testCases.find(tc => tc.name === newTestCaseName) + + if (!tc) { + return Promise.reject(new Error(`No test case found with id '${tcId}'!`)) + } + + if (sameName) { + return Promise.reject(new Error('The test case name already exists!')) + } + + return testCaseModel.insert({ ...tc, name: newTestCaseName }) + } } export function setPlayerState (obj) { @@ -463,6 +493,7 @@ export function playerPlay (options) { '!MACRONAME': macroName, '!TIMEOUT_PAGELOAD': parseInt(config.timeoutPageLoad, 10), '!TIMEOUT_WAIT': parseInt(config.timeoutElement, 10), + '!TIMEOUT_MACRO': parseInt(config.timeoutMacro, 10), '!REPLAYSPEED': ({ '0': 'FAST', '0.3': 'MEDIUM', @@ -499,3 +530,125 @@ export function listCSV () { }) } } + +export function listScreenshots () { + return (dispatch, getState) => { + const man = getScreenshotMan() + + man.list().then(list => { + list.reverse() + + return list.map(item => ({ + name: item.fileName, + url: man.getLink(item.fileName), + createTime: new Date(item.lastModified) + })) + }).then(list => { + dispatch({ + type: T.SET_SCREENSHOT_LIST, + data: list + }) + }) + } +} + +export function setTestSuites (tss) { + return { + type: T.SET_TEST_SUITES, + data: tss + } +} + +export function addTestSuite (ts) { + return (dispatch, getState) => { + return testSuiteModel.insert(ts) + } +} + +export function addTestSuites (tss) { + return (dispatch, getState) => { + const state = getState() + // const testCases = state.editor.testCases + const validTss = tss + // const failTcs = tcs.filter(tc => testCases.find(tcc => tcc.name === tc.name)) + + const passCount = validTss.length + const failCount = tss.length - passCount + + if (passCount === 0) { + return Promise.resolve({ passCount, failCount, failTss: [] }) + } + + return testSuiteModel.bulkInsert(validTss) + .then(() => ({ passCount, failCount, failTss: [] })) + } +} + +export function updateTestSuite (id, data) { + return (dispatch, getState) => { + const state = getState() + const ts = state.editor.testSuites.find(ts => ts.id === id) + + const revised = { + ...ts, + ...(typeof data === 'function' ? data(ts) : data) + } + + dispatch({ + type: T.UPDATE_TEST_SUITE, + data: { + id: id, + updated: revised + } + }) + + return testSuiteModel.update(id, revised) + } +} + +export function removeTestSuite (id) { + return (dispatch, getState) => { + return testSuiteModel.remove(id) + } +} + +export function setPlayerMode (mode) { + return { + type: T.SET_PLAYER_STATE, + data: { mode } + } +} + +export function runBackup () { + return (dispatch, getState) => { + const { config, editor } = getState() + const { + autoBackupTestCases, + autoBackupTestSuites, + autoBackupScreenshots, + autoBackupCSVFiles + } = config + + return Promise.all([ + getCSVMan().list(), + getScreenshotMan().list() + ]) + .then(([csvs, screenshots]) => { + return backup({ + csvs, + screenshots, + testCases: editor.testCases, + testSuites: editor.testSuites, + backup: { + testCase: autoBackupTestCases, + testSuite: autoBackupTestSuites, + screenshot: autoBackupScreenshots, + csv: autoBackupCSVFiles + } + }) + }) + .catch(e => { + log.error(e.stack) + }) + } +} diff --git a/src/app.js b/src/app.js index 307885e..b0ed9b2 100644 --- a/src/app.js +++ b/src/app.js @@ -1,32 +1,75 @@ import React, { Component } from 'react' +import { connect } from 'react-redux' +import { bindActionCreators } from 'redux' import { HashHistory as Router, Route, Link, Switch, Redirect } from 'react-router-dom' +import { Button } from 'antd' -import routes from './routes' +import * as actions from './actions' +import csIpc from './common/ipc/ipc_cs' import Header from './components/header' -import Sidebar from './components/sidebar' +import Sidebar from './containers/sidebar' +import DashboardPage from './containers/dashboard' import 'antd/dist/antd.css' import './app.scss' class App extends Component { + hideBackupAlert = () => { + this.props.updateConfig({ + lastBackupActionTime: new Date() * 1 + }) + this.$app.classList.remove('with-alert') + } + + onClickBackup = () => { + this.props.runBackup() + this.hideBackupAlert() + } + + onClickNoBackup = () => { + this.hideBackupAlert() + } + + componentDidMount () { + const run = () => { + csIpc.ask('PANEL_TIME_FOR_BACKUP', {}) + .then(isTime => { + if (!isTime) return + this.$app.classList.add('with-alert') + }) + } + + // Note: check whether it's time for backup every 5 minutes + this.timer = setInterval(run, 5 * 60000) + run() + } + + componentWillUnmount () { + clearInterval(this.timer) + } + render () { return ( -
- -
-
- - ( - - )} /> - - {routes.map((route) => ( - - ))} - -
+
{ this.$app = el }}> +
+ Do you want to run the automated backup? + + + + +
+
+ +
+
+ +
+
); } } -export default App; +export default connect( + state => ({}), + dispatch => bindActionCreators({...actions}, dispatch) +)(App) diff --git a/src/app.scss b/src/app.scss index 62369c3..0583c95 100644 --- a/src/app.scss +++ b/src/app.scss @@ -17,6 +17,33 @@ body { left: 0; right: 0; display: flex; + flex-direction: column; + + .app-inner { + flex: 1; + display: flex; + flex-direction: row; + } + + &.with-alert .backup-alert { + display: block; + } + + .backup-alert { + display: none; + padding: 5px 0; + text-align: center; + font-size: 14px; + background: rgb(253, 253, 194); + + .backup-actions { + margin-left: 20px; + + button { + margin-right: 10px; + } + } + } .content { @include flexcol(); diff --git a/src/common/backup.js b/src/common/backup.js new file mode 100644 index 0000000..147c4a7 --- /dev/null +++ b/src/common/backup.js @@ -0,0 +1,67 @@ +import FileSaver from 'file-saver' +import JSZip from 'jszip' +import { nameFactory } from './utils' +import { toJSONString } from './convert_utils' +import { stringifyTestSuite } from './convert_suite_utils' +import { getScreenshotMan } from './screenshot_man' +import { getCSVMan } from './csv_man' + +export default function backup ({ backup, testCases, testSuites, screenshots, csvs }) { + const zip = new JSZip() + const ps = [] + + if (backup.testCase && testCases && testCases.length) { + const folder = zip.folder('test_cases') + + testCases.forEach(tc => { + folder.file(`${tc.name}.json`, toJSONString({ + name: tc.name, + commands: tc.data.commands + })) + }) + } + + if (backup.testSuite && testCases && testSuites && testSuites.length) { + const folder = zip.folder('test_suites') + const genName = nameFactory() + + testSuites.forEach(ts => { + const name = genName(ts.name) + folder.file(`${name}.json`, stringifyTestSuite(ts, testCases)) + }) + } + + if (backup.screenshot && screenshots && screenshots.length) { + const folder = zip.folder('screenshots') + const man = getScreenshotMan() + + screenshots.forEach(ss => { + ps.push( + man.read(ss.fileName) + .then(buffer => { + folder.file(ss.fileName, buffer, { binary: true }) + }) + ) + }) + } + + if (backup.csv && csvs && csvs.length) { + const folder = zip.folder('csvs') + const man = getCSVMan() + + csvs.forEach(csv => { + ps.push( + man.read(csv.fileName) + .then(text => folder.file(csv.fileName, text)) + ) + }) + } + + return Promise.all(ps) + .then(() => { + zip.generateAsync({ type: 'blob' }) + .then(function (blob) { + FileSaver.saveAs(blob, 'kantu_backup.zip'); + }) + }) +} diff --git a/src/common/capture_screenshot.js b/src/common/capture_screenshot.js index 2dab3d8..20e183f 100644 --- a/src/common/capture_screenshot.js +++ b/src/common/capture_screenshot.js @@ -1,5 +1,7 @@ import Ext from './web_extension' import fs from './filesystem' +import { getScreenshotMan } from '../common/screenshot_man' +import { delay } from '../common/utils' // refer to https://stackoverflow.com/questions/12168909/blob-from-dataurl function dataURItoBlob (dataURI) { @@ -39,15 +41,172 @@ function captureScreenBlob () { .then(dataURItoBlob) } -export default function saveScreen () { +export function saveScreen () { return Promise.all([ getActiveTabInfo(), captureScreenBlob() ]) .then(([tabInfo, screenBlob]) => { - const fileName = `${Date.now()}_${tabInfo.title.replace(/\s/g, '_')}.png` + const fileName = `${Date.now()}_${encodeURIComponent(tabInfo.title)}.png` - return fs.writeFile(fileName, screenBlob) + return getScreenshotMan().write(fileName, screenBlob) + .then(url => url) + }) +} + +function pCompose (list) { + return list.reduce((prev, fn) => { + return prev.then(fn) + }, Promise.resolve()) +} + +function getAllScrollOffsets ({ pageWidth, pageHeight, windowWidth, windowHeight, topPadding = 150 }) { + const topPad = windowHeight > topPadding ? topPadding : 0 + const xStep = windowWidth + const yStep = windowHeight - topPad + const result = [] + + // Note: bottom comes first so that when we render those screenshots one by one to the final canvas, + // those at top will overwrite top padding part of those at bottom, it is useful if that page has some fixed header + for (let y = pageHeight - windowHeight; y > -1 * yStep; y -= yStep) { + for (let x = 0; x < pageWidth; x += xStep) { + result.push({ x, y }) + } + } + + return result +} + +function createCanvas (width, height, pixelRatio = 1) { + const canvas = document.createElement('canvas') + canvas.width = width * pixelRatio + canvas.height = height * pixelRatio + return canvas +} + +function drawOnCanvas ({ canvas, dataURI, x, y }) { + return new Promise((resolve, reject) => { + const image = new Image() + + image.onload = () => { + canvas.getContext('2d').drawImage(image, x, y) + resolve({ + x, + y, + width: image.width, + heigth: image.height + }) + } + + image.src = dataURI + }) +} + +function captureFullScreenBlob ({ startCapture, scrollPage, endCapture }) { + return startCapture() + .then(pageInfo => { + const canvas = createCanvas(pageInfo.pageWidth, pageInfo.pageHeight, pageInfo.devicePixelRatio) + const scrollOffsets = getAllScrollOffsets(pageInfo) + const todos = scrollOffsets.map((offset) => () => { + return scrollPage(offset) + .then(realOffset => { + return Ext.tabs.captureVisibleTab(null, { format: 'png' }) + .then(dataURI => drawOnCanvas({ + canvas, + dataURI, + x: realOffset.x * pageInfo.devicePixelRatio, + y: realOffset.y * pageInfo.devicePixelRatio + })) + }) + }) + + return pCompose(todos) + .then(() => dataURItoBlob(canvas.toDataURL())) + .then(result => { + endCapture(pageInfo) + return result + }) + }) +} + +export const captureClientAPI = { + startCapture: () => { + const body = document.body + const widths = [ + document.documentElement.clientWidth, + document.documentElement.scrollWidth, + document.documentElement.offsetWidth, + body ? body.scrollWidth : 0, + body ? body.offsetWidth : 0 + ] + const heights = [ + document.documentElement.clientHeight, + document.documentElement.scrollHeight, + document.documentElement.offsetHeight, + body ? body.scrollHeight : 0, + body ? body.offsetHeight : 0 + ] + + const data = { + pageWidth: Math.max(...widths), + pageHeight: Math.max(...heights), + windowWidth: window.innerWidth, + windowHeight: window.innerHeight, + hasBody: !!body, + originalX: window.scrollX, + originalY: window.scrollY, + originalOverflowStyle: document.documentElement.style.overflow, + originalBodyOverflowYStyle: body && body.style.overflowY, + devicePixelRatio: window.devicePixelRatio + } + + // Note: try to make pages with bad scrolling work, e.g., ones with + // `body { overflow-y: scroll; }` can break `window.scrollTo` + if (body) { + body.style.overflowY = 'visible' + } + + // Disable all scrollbars. We'll restore the scrollbar state when we're done + // taking the screenshots. + document.documentElement.style.overflow = 'hidden' + + return Promise.resolve(data) + }, + scrollPage: ({ x, y }) => { + window.scrollTo(x, y) + + return delay(() => ({ + x: window.scrollX, + y: window.scrollY + }), 100) + }, + endCapture: (pageInfo) => { + const { + originalX, originalY, hasBody, + originalOverflowStyle, + originalBodyOverflowYStyle + } = pageInfo + + if (hasBody) { + document.body.style.overflowY = originalBodyOverflowYStyle + } + + document.documentElement.style.overflow = originalOverflowStyle + window.scrollTo(originalX, originalY) + + return Promise.resolve(true) + } +} + +export function saveFullScreen (clientAPI) { + return Promise.all([ + getActiveTabInfo(), + captureFullScreenBlob(clientAPI) + ]) + .then(([tabInfo, screenBlob]) => { + const fileName = `${Date.now()}_${encodeURIComponent(tabInfo.title)}_full.png` + + return getScreenshotMan().write(fileName, screenBlob) .then(url => url) }) } diff --git a/src/common/clipboard.js b/src/common/clipboard.js new file mode 100644 index 0000000..87c3f22 --- /dev/null +++ b/src/common/clipboard.js @@ -0,0 +1,51 @@ + +const setStyle = ($dom, obj) => { + Object.keys(obj).forEach(key => { + $dom.style[key] = obj[key] + }) +} + +const withInput = (fn) => { + const $input = document.createElement('textarea') + + setStyle($input, { + position: 'aboslute', + top: '-9999px', + left: '-9999px' + }) + + document.body.appendChild($input) + + let ret + + try { + ret = fn($input) + } finally { + document.body.removeChild($input) + } + + return ret +} + +const api = { + set: (text) => { + withInput($input => { + $input.value = text + $input.select() + document.execCommand('copy') + }) + }, + get: () => { + return withInput($input => { + $input.select() + + if (document.execCommand('Paste')) { + return $input.value + } + + return 'no luck' + }) + } +} + +export default api diff --git a/src/common/command_runner.js b/src/common/command_runner.js index 76280e6..7430bbb 100644 --- a/src/common/command_runner.js +++ b/src/common/command_runner.js @@ -212,6 +212,10 @@ export const run = (command, csIpc, helpers) => { switch (cmd) { case 'open': + if (window.noCommandsYet) { + return true + } + return until('document.body', () => { return { pass: !!document.body, @@ -332,6 +336,14 @@ export const run = (command, csIpc, helpers) => { if (extra.playScrollElementsIntoView) el.scrollIntoView() if (extra.playHighlightElements) helpers.highlightDom(el, HIGHLIGHT_TIMEOUT) + // Note: need the help of chrome.debugger to set file path to file input + if (el.type && el.type.toLowerCase() === 'file') { + return csIpc.ask('CS_SET_FILE_INPUT_FILES', { + files: value.split(';'), + selector: inspector.selector(el) + }) + } + el.value = value el.dispatchEvent(new Event('change')) @@ -563,6 +575,53 @@ export const run = (command, csIpc, helpers) => { } } + case 'storeValue': { + return __getElementByLocator(target) + .then(el => { + const text = el.value + + if (!text) { + throw new Error('value not found') + } + + return { + vars: { + [value]: text + } + } + }) + } + + case 'verifyValue': { + return __getElementByLocator(target) + .then(el => { + const text = el.value + + if (text !== value) { + return { + log: { + error: `value not matched, \n\texpected: "${value}", \n\tactual: "${text}"` + } + } + } + + return true + }) + } + + case 'assertValue': { + return __getElementByLocator(target) + .then(el => { + const text = el.value + + if (text !== value) { + throw new Error(`value not matched, \n\texpected: "${value}", \n\tactual: "${text}"`) + } + + return true + }) + } + case 'echo': { return { log: { @@ -599,6 +658,16 @@ export const run = (command, csIpc, helpers) => { })) } + case 'captureEntirePageScreenshot': { + return csIpc.ask('CS_CAPTURE_FULL_SCREENSHOT', { target }) + .then(url => ({ + screenshot: { + url, + name: target || url.split('/').slice(-1)[0] + } + })) + } + case 'deleteAllCookies': { return csIpc.ask('CS_DELETE_ALL_COOKIES', { url: window.location.origin @@ -606,6 +675,7 @@ export const run = (command, csIpc, helpers) => { .then(() => true) } + case 'if': case 'while': case 'gotoIf': { try { diff --git a/src/common/constant.js b/src/common/constant.js index 74ba0e0..918a433 100644 --- a/src/common/constant.js +++ b/src/common/constant.js @@ -29,6 +29,11 @@ export const PLAYER_STATUS = mk([ 'STOPPED' ]) +export const PLAYER_MODE = mk([ + 'TEST_CASE', + 'TEST_SUITE' +]) + export const CONTENT_SCRIPT_STATUS = mk([ 'NORMAL', 'RECORDING', diff --git a/src/common/convert_suite_utils.js b/src/common/convert_suite_utils.js new file mode 100644 index 0000000..690a293 --- /dev/null +++ b/src/common/convert_suite_utils.js @@ -0,0 +1,61 @@ +import parseJson from 'parse-json' +import { formatDate } from './utils' + +export const stringifyTestSuite = (testSuite, testCases) => { + const obj = { + creationDate: formatDate(new Date()), + name: testSuite.name, + macros: testSuite.cases.map(item => { + const loops = parseInt(item.loops, 10) + const tcId = item.testCaseId + const tc = testCases.find(tc => tc.id === tcId) + const tcName = tc.name || '(Test case not found)' + + return { + macro: tcName, + loops: loops + } + }) + } + + return JSON.stringify(obj, null, 2) +} + +export const parseTestSuite = (text, testCases) => { + const obj = parseJson(text) + + if (typeof obj.name !== 'string' || obj.name.length === 0) { + throw new Error('name must be a string') + } + + if (!Array.isArray(obj.macros)) { + throw new Error('macros must be an array') + } + + const cases = obj.macros.map(item => { + const tc = testCases.find(tc => tc.name === item.macro) + + if (!tc) { + throw new Error(`No macro found with name '${item.macro}'`) + } + + if (typeof item.loops !== 'number' || item.loops < 1) { + item.loops = 1 + } + + return { + testCaseId: tc.id, + loops: item.loops + } + }) + + const ts = { + name: obj.name, + fold: obj.fold, + cases + } + + return ts +} + +export const validateTestSuiteText = parseTestSuite diff --git a/src/common/convert_utils.js b/src/common/convert_utils.js index 52f99de..fde5fd9 100644 --- a/src/common/convert_utils.js +++ b/src/common/convert_utils.js @@ -1,6 +1,7 @@ import $ from 'jquery' import parseJson from 'parse-json' import URL from 'url-parse' +import normalizeUrl from 'normalize-url' import { pick } from './utils' // HTML template from test case @@ -105,7 +106,7 @@ export function fromHtml (html) { } if (cmd === 'open') { - target = (baseUrl + '/' + target).replace(/\/+/g, '/') + target = normalizeUrl(baseUrl + '/' + target) } return { cmd, target, value } diff --git a/src/common/csv_man.js b/src/common/csv_man.js index a8c2a8f..0b00286 100644 --- a/src/common/csv_man.js +++ b/src/common/csv_man.js @@ -1,84 +1,14 @@ -import fs from './filesystem' -import Ext from './web_extension' +import FileMan from './file_man' -const readableSize = (size) => { - const kb = 1024 - const mb = kb * kb - - if (size < kb) { - return size + ' byte' - } - - if (size < mb) { - return (size / kb).toFixed(1) + ' KB' - } - - return (size / mb).toFixed(1) + ' MB' -} - -export class CSVMan { +export class CSVMan extends FileMan { constructor (opts = {}) { - const { baseDir = 'csv' } = opts - - if (!baseDir || baseDir === '/') { - throw new Error(`Invalid baseDir, ${baseDir}`) - } - - this.baseDir = baseDir - - // Note: create the folder in which we will store csv files - fs.getDirectory(baseDir, true) - } - - getLink (fileName) { - const tmp = Ext.extension.getURL('temporary') - return `filesystem:${tmp}/${this.__filePath(fileName)}` - } - - list () { - return fs.list(this.baseDir) - .then(fileEntries => { - const ps = fileEntries.map(fileEntry => { - return fs.getMetadata(fileEntry) - .then(meta => ({ - dir: this.baseDir, - fileName: fileEntry.name, - size: readableSize(meta.size), - lastModified: meta.modificationTime - })) - }) - return Promise.all(ps) - }) - } - - exists (fileName) { - return fs.exists(this.__filePath(fileName), { type: 'file' }) - } - - read (fileName) { - return fs.readFile(this.__filePath(fileName), 'Text') - } - - write (fileName, text) { - return fs.writeFile(this.__filePath(fileName), new Blob([text])) - } - - remove (fileName) { - return fs.removeFile(this.__filePath(fileName)) - } - - metadata (fileName) { - return fs.getMetadata(this.__filePath(fileName)) - } - - __filePath (fileName) { - return this.baseDir + '/' + fileName + super({ ...opts, baseDir: 'spreadsheets' }) } } let man -export function getCSVMan (opts) { +export function getCSVMan (opts = {}) { if (opts) { man = new CSVMan(opts) } diff --git a/src/common/debugger.js b/src/common/debugger.js new file mode 100644 index 0000000..605b841 --- /dev/null +++ b/src/common/debugger.js @@ -0,0 +1,108 @@ +import Ext from './web_extension' +import { partial, composePromiseFn } from './utils' + +const PROTOCOL_VERSION = '1.2' +const ClEANUP_TIMEOUT = 0 + +export const withDebugger = (function () { + const state = { + connected: null, + cleanupTimer: null + } + + const setState = (obj) => { + Object.assign(state, obj) + } + + const cancelCleanup = () => { + if (state.cleanupTimer) clearTimeout(state.cleanupTimer) + setState({ cleanupTimer: null }) + } + + const isSameDebuggee = (a, b) => { + return a && b && a.tabId && b.tabId && a.tabId === b.tabId + } + + return (debuggee, fn) => { + const attach = (debuggee) => { + if (isSameDebuggee(state.connected, debuggee)) { + cancelCleanup() + return Promise.resolve() + } + + return detach(state.connected) + .then(() => Ext.debugger.attach(debuggee, PROTOCOL_VERSION)) + .then(() => setState({ connected: debuggee })) + } + const detach = (debuggee) => { + if (!debuggee) return Promise.resolve() + + return Ext.debugger.detach(debuggee) + .then(() => { + if (state.cleanupTimer) clearTimeout(state.cleanupTimer) + + setState({ + connected: null, + cleanupTimer: null + }) + }, e => console.error('error in detach', e.stack)) + } + const scheduleDetach = () => { + const timer = setTimeout(() => detach(debuggee), ClEANUP_TIMEOUT) + setState({ cleanupTimer: timer }) + } + const sendCommand = (cmd, params) => { + return Ext.debugger.sendCommand(debuggee, cmd, params) + } + const onEvent = (callback) => { + Ext.debugger.onEvent.addListener(callback) + } + const onDetach = (callback) => { + Ext.debugger.onDetach.addListener(callback) + } + + return new Promise((resolve, reject) => { + const done = (error, result) => { + scheduleDetach() + + if (error) return reject(error) + else return resolve(result) + } + + return attach(debuggee).then( + () => { + fn({ sendCommand, onEvent, onDetach, done }) + }, + e => reject(e) + ) + }) + } +})() + +const __getDocument = ({ sendCommand, done }) => () => { + return sendCommand('DOM.getDocument') + .then(obj => obj.root) +} + +const __querySelector = ({ sendCommand, done }) => partial((selector, nodeId) => { + return sendCommand('DOM.querySelector', { nodeId, selector }) + .then(res => res && res.nodeId) +}) + +const __setFileInputFiles = ({ sendCommand, done }) => partial((files, nodeId) => { + return sendCommand('DOM.setFileInputFiles', { nodeId, files }) + .then(() => true) +}) + +export const setFileInputFiles = ({ tabId, selector, files }) => { + return withDebugger({ tabId }, api => { + const go = composePromiseFn( + __setFileInputFiles(api)(files), + __querySelector(api)(selector), + node => node.nodeId, + __getDocument(api) + ) + + return go().then(res => api.done(null, res)) + }) +} diff --git a/src/common/file_man.js b/src/common/file_man.js new file mode 100644 index 0000000..0a4d0ad --- /dev/null +++ b/src/common/file_man.js @@ -0,0 +1,84 @@ +import fs from './filesystem' +import Ext from './web_extension' + +const readableSize = (size) => { + const kb = 1024 + const mb = kb * kb + + if (size < kb) { + return size + ' byte' + } + + if (size < mb) { + return (size / kb).toFixed(1) + ' KB' + } + + return (size / mb).toFixed(1) + ' MB' +} + +export default class FileMan { + constructor (opts = {}) { + const { baseDir = 'share' } = opts + + if (!baseDir || baseDir === '/') { + throw new Error(`Invalid baseDir, ${baseDir}`) + } + + this.baseDir = baseDir + + // Note: create the folder in which we will store csv files + fs.getDirectory(baseDir, true) + } + + getLink (fileName) { + const tmp = Ext.extension.getURL('temporary') + return `filesystem:${tmp}/${this.__filePath(encodeURIComponent(fileName))}` + } + + list () { + return fs.list(this.baseDir) + .then(fileEntries => { + const ps = fileEntries.map(fileEntry => { + return fs.getMetadata(fileEntry) + .then(meta => ({ + dir: this.baseDir, + fileName: fileEntry.name, + size: readableSize(meta.size), + lastModified: meta.modificationTime + })) + }) + return Promise.all(ps) + }) + } + + exists (fileName) { + return fs.exists(this.__filePath(fileName), { type: 'file' }) + } + + read (fileName) { + return fs.readFile(this.__filePath(fileName), 'Text') + } + + write (fileName, text) { + return fs.writeFile(this.__filePath(fileName), new Blob([text])) + } + + // Note: when you try to write on an existing file with file system api, + // it won't clear old content, so we have to do it mannually + overwrite (fileName, text) { + return this.remove(fileName).catch(() => { /* Ignore any error */ }) + .then(() => this.write(fileName, text)) + } + + remove (fileName) { + return fs.removeFile(this.__filePath(fileName)) + } + + metadata (fileName) { + return fs.getMetadata(this.__filePath(fileName)) + } + + __filePath (fileName) { + return this.baseDir + '/' + fileName + } +} diff --git a/src/common/inspector.js b/src/common/inspector.js index 5659cd0..7581b97 100644 --- a/src/common/inspector.js +++ b/src/common/inspector.js @@ -253,7 +253,8 @@ var selector = function (dom) { // Note: browser will add an extra 'tbody' when tr directly in table, which will cause an wrong selector, // so the hack is to remove all tbody here var ret = selector(dom.parentNode) + ' > ' + me - return ret.replace(/\s*>\s*tbody\s*>?/g, ' ') + return ret + // return ret.replace(/\s*>\s*tbody\s*>?/g, ' ') } var xpath = function (dom, cur, list) { diff --git a/src/common/interpreter.js b/src/common/interpreter.js index 906948e..ebf7e27 100644 --- a/src/common/interpreter.js +++ b/src/common/interpreter.js @@ -30,7 +30,7 @@ export default class Interpreter { preprocess (commands) { let nextState = { commands, tags: [] } - let halfTag = null + let halfTags = [] let errorAtIndex = (i, msg) => { const e = new Error(msg) e.errorIndex = i @@ -40,33 +40,78 @@ export default class Interpreter { commands.forEach((c, i) => { if (this.__customPre && this.__customPre(c, i)) return + const topHalfTag = halfTags[halfTags.length - 1] + switch (c.cmd) { + // Commands for WHILE statements case 'while': { - if (halfTag && halfTag.type === 'while') { + if (halfTags.find(tag => tag.type === 'while')) { throw errorAtIndex(i, `No nested while allowed (at command #${i + 1})`) } - halfTag = { + halfTags.push({ type: 'while', start: { index: i, command: c } - } + }) break } case 'endWhile': { - if (!halfTag || halfTag.type !== 'while') { + if (!topHalfTag || topHalfTag.type !== 'while') { throw errorAtIndex(i, `No matching while for this endWhile (at command #${i + 1})`) } nextState.tags.push({ - ...halfTag, + ...topHalfTag, end: { index: i, command: c } }) - halfTag = null + halfTags.pop() break } + // ----------------------------- + + // Commands for IF statements + case 'if': { + if (halfTags.find(tag => tag.type === 'if')) { + throw errorAtIndex(i, `No nested if allowed (at command #${i + 1})`) + } + + halfTags.push({ + type: 'if', + start: { index: i, command: c } + }) + + break + } + + case 'else': { + if (!topHalfTag || topHalfTag.type !== 'if') { + throw errorAtIndex(i, `No matching if for this else (at command #${i + 1})`) + } + + Object.assign(topHalfTag, { + fork: { index: i, command: c } + }) + + break + } + + case 'endif': { + if (!topHalfTag || topHalfTag.type !== 'if') { + throw errorAtIndex(i, `No matching if for this endif (at command #${i + 1})`) + } + + nextState.tags.push({ + ...topHalfTag, + end: { index: i, command: c } + }) + + halfTags.pop() + break + } + // ----------------------------- case 'label': { if (!c.target || !c.target.length) { @@ -85,8 +130,9 @@ export default class Interpreter { } }) - if (halfTag) { - throw errorAtIndex(halfTag.start.index, `Unclosed '${halfTag.type}' (at command #${halfTag.start.index + 1})`) + if (halfTags.length > 0) { + const topHalfTag = halfTags[halfTags.length - 1] + throw errorAtIndex(topHalfTag.start.index, `Unclosed '${topHalfTag.type}' (at command #${topHalfTag.start.index + 1})`) } this.__setState(nextState) @@ -117,6 +163,25 @@ export default class Interpreter { }) } + case 'else': { + // Note: 'else' command itself will be skipped if condition is false, + // But it will be run as the ending command of 'if-else' when condition is true + const tag = this.state.tags.find(tag => tag.type === 'if' && tag.fork.index === index) + + if (!tag) { + throw new Error(`tag not found for this else (at command #${index + 1})`) + } + + return Promise.resolve({ + isFlowLogic: true, + nextIndex: tag.end.index + 1 + }) + } + + case 'endif': { + return Promise.resolve({ isFlowLogic: true }) + } + case 'endWhile': { const tag = this.state.tags.find(tag => tag.type === 'while' && tag.end.index === index) @@ -134,9 +199,10 @@ export default class Interpreter { case 'label': return Promise.resolve({ isFlowLogic: true }) - // Note: both gotoIf and while needs to run eval, which is not allowed in extension scope, + // Note: gotoIf, if and while need to run eval, which is not allowed in extension scope, // so we have to run eval in content script case 'gotoIf': + case 'if': case 'while': default: return Promise.resolve({ isFlowLogic: false }) @@ -153,7 +219,7 @@ export default class Interpreter { switch (cmd) { case 'gotoIf': { - // short-circuit the check on value + // short-circuit the check on value if (!result.condition) return Promise.resolve() if (!value || !value.length) { @@ -169,6 +235,22 @@ export default class Interpreter { }) } + case 'if': { + const cond = result.condition + const tag = this.state.tags.find(tag => tag.type === 'if' && tag.start.index === index) + + if (!tag) { + throw new Error(`tag not found for this if (at command #${index + 1})`) + } + + const forkIndex = tag.fork && (tag.fork.index + 1) + const endIndex = tag.end && (tag.end.index + 1) + + return Promise.resolve({ + nextIndex: cond ? (index + 1) : (forkIndex || endIndex) + }) + } + case 'while': { const cond = result.condition const tag = this.state.tags.find(tag => tag.type === 'while' && tag.start.index === index) diff --git a/src/common/ipc/ipc_promise.js b/src/common/ipc/ipc_promise.js index a0d1361..b107d5e 100644 --- a/src/common/ipc/ipc_promise.js +++ b/src/common/ipc/ipc_promise.js @@ -113,15 +113,18 @@ function ipcPromise (options) { var uid = 'ipcp_' + new Date() * 1 + '_' + Math.round(Math.random() * 1000); var finalTimeout = timeoutToOverride || timeout - setTimeout(function () { - var reject; - - if (askCache && askCache[uid]) { - reject = askCache[uid][1]; - askCache[uid] = TO_BE_REMOVED; - reject(new Error('ipcPromise: onAsk timeout ' + finalTimeout + ' for cmd "' + cmd + '", args "' + args + '"')); - } - }, finalTimeout); + // Note: make it possible to disable timeout + if (finalTimeout > 0) { + setTimeout(function () { + var reject; + + if (askCache && askCache[uid]) { + reject = askCache[uid][1]; + askCache[uid] = TO_BE_REMOVED; + reject(new Error('ipcPromise: onAsk timeout ' + finalTimeout + ' for cmd "' + cmd + '", args "' + args + '"')); + } + }, finalTimeout); + } ask(uid, cmd, args || []); diff --git a/src/common/player.js b/src/common/player.js index 895061a..bffb357 100644 --- a/src/common/player.js +++ b/src/common/player.js @@ -136,7 +136,8 @@ export class Player { loopsEnd: 1, resources: config.resources, status: STATUS.PLAYING, - public: config.public || {} + public: config.public || {}, + callback: config.callback || function () {} } ;['preDelay', 'postDelay'].forEach(key => { @@ -171,7 +172,11 @@ export class Player { break } - this.emit('START', { title }) + this.emit('START', { + title, + loopsCursor: this.state.loopsCursor, + extra: this.state.extra + }) return Promise.resolve() .then(() => this.__prepare(this.state)) @@ -186,7 +191,7 @@ export class Player { status: STATUS.PAUSED }) - this.emit('PAUSED', {}) + this.emit('PAUSED', { extra: this.state.extra }) } resume () { @@ -194,12 +199,16 @@ export class Player { status: STATUS.PLAYING }) - this.emit('RESUMED', {}) + this.emit('RESUMED', { extra: this.state.extra }) this.__go() } - stop () { - this.__end(END_REASON.MANUAL) + stop (opts) { + this.__end(END_REASON.MANUAL, opts) + } + + stopWithError (error) { + this.__errLog(error) } jumpTo (nextIndex) { @@ -222,7 +231,20 @@ export class Player { }) } - __go () { + __go (token) { + // Note: in case it is returned from previous call + + if (token === undefined) { + this.token = token = Math.random() + } else if (token !== this.token) { + return + } + + const guardToken = (fn) => (...args) => { + if (token !== this.token) throw new Error('token expired') + return fn(...args) + } + const { resources, nextIndex, preDelay } = this.state const pre = preDelay > 0 ? this.__delay(() => undefined, preDelay) : Promise.resolve() @@ -246,42 +268,54 @@ export class Player { } = this.state // Note: when we're running loops - if (loopsCursor !== loopsStart && nextIndex === startIndex) { - this.emit('LOOP_RESTART', { + if (nextIndex === startIndex) { + const obj = { + loopsCursor, index: nextIndex, currentLoop: loopsCursor - loopsStart + 1, loops: loopsEnd - loopsStart + 1, - resource: resources[nextIndex] - }) + resource: resources[nextIndex], + extra: this.state.extra + } + + this.emit('LOOP_START', obj) + + if (loopsCursor !== loopsStart) { + this.emit('LOOP_RESTART', obj) + } } this.emit('TO_PLAY', { index: nextIndex, currentLoop: loopsCursor - loopsStart + 1, loops: loopsEnd - loopsStart + 1, - resource: resources[nextIndex] + resource: resources[nextIndex], + extra: this.state.extra }) + // Note: Check whether token expired or not after each async operations + // Also also in the final catch to prevent unnecessary invoke of __errLog return this.__run(resources[nextIndex], this.state) - .then(res => { + .then(guardToken(res => { const { postDelay } = this.state // Note: allow users to handle the result return this.__handle(res, resources[nextIndex], this.state) - .then(nextIndex => { + .then(guardToken(nextIndex => { // Note: __handle has the chance to return a `nextIndex`, mostly when it's // from a flow logic. But still, it could be undefined for normal commands this.__setNext(nextIndex) this.emit('PLAYED_LIST', { - indices: this.state.doneIndices + indices: this.state.doneIndices, + extra: this.state.extra }) - }) + })) .then( () => postDelay > 0 ? this.__delay(() => undefined, postDelay) : Promise.resolve() ) - .then(() => this.__go()) - }) - .catch(err => this.__errLog(err)) + .then(() => this.__go(token)) + })) + .catch(guardToken(err => this.__errLog(err))) }) } @@ -304,20 +338,35 @@ export class Player { return Promise.resolve(ret) } - __end (reason) { + __end (reason, opts) { + // Note: CANNOT end the player twice + if (this.state.status === STATUS.STOPPED) return + if (Object.keys(END_REASON).indexOf(reason) === -1) { throw new Error('Player - __end: invalid reason, ' + reason) } - this.emit('END', { reason, extra: this.state.extra }) + if (!opts || !opts.silent) { + this.emit('END', { opts, reason, extra: this.state.extra }) + + if (reason !== END_REASON.ERROR) { + this.state.callback(null, reason) + } + } + this.__setState(initialState) } __errLog (err, errorIndex) { + // Note: CANNOT log error if player is already stopped + if (this.state.status === STATUS.STOPPED) return + this.emit('ERROR', { errorIndex: errorIndex !== undefined ? errorIndex : this.state.nextIndex, - msg: err && err.message + msg: err && err.message, + extra: this.state.extra }) + this.state.callback(err, null) this.__end(END_REASON.ERROR) } @@ -373,6 +422,7 @@ export class Player { const timer = setInterval(() => { past += 1000 this.emit('DELAY', { + extra: this.state.extra, total: timeout, past }) @@ -388,23 +438,26 @@ export class Player { ee(Player.prototype) -Player.prototype.C = { +Player.C = Player.prototype.C = { MODE, STATUS, END_REASON } -let player +let playerPool = {} // factory function to return a player singleton -export const getPlayer = (opts, state) => { - if (opts) { - player = new Player(opts, state) +export const getPlayer = (opts = {}, state) => { + const name = opts.name || 'testCase' + delete opts.name + + if (Object.keys(opts).length > 0) { + playerPool[name] = new Player(opts, state) } - if (!player) { + if (!playerPool[name]) { throw new Error('player not initialized') } - return player + return playerPool[name] } diff --git a/src/common/screenshot_man.js b/src/common/screenshot_man.js new file mode 100644 index 0000000..04f002b --- /dev/null +++ b/src/common/screenshot_man.js @@ -0,0 +1,30 @@ +import fs from './filesystem' +import FileMan from './file_man' + +export class ScreenshotMan extends FileMan { + constructor (opts = {}) { + super({ ...opts, baseDir: 'screenshots' }) + } + + write (fileName, blob) { + return fs.writeFile(this.__filePath(fileName), blob) + } + + read (fileName) { + return fs.readFile(this.__filePath(fileName), 'ArrayBuffer') + } +} + +let man + +export function getScreenshotMan (opts = {}) { + if (opts) { + man = new ScreenshotMan(opts) + } + + if (!man) { + throw new Error('screenshot manager not initialized') + } + + return man +} diff --git a/src/common/utils.js b/src/common/utils.js index 06f0559..5961aa3 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -155,6 +155,28 @@ export const splitIntoTwo = (pattern, str) => { ] } +export const cn = (...args) => { + return args.reduce((prev, cur) => { + if (typeof cur === 'string') { + prev.push(cur) + } else { + Object.keys(cur).forEach(key => { + if (cur[key]) { + prev.push(key) + } + }) + } + + return prev + }, []) + .join(' ') +} + +export const formatDate = (d) => { + const pad = (n) => n >= 10 ? ('' + n) : ('0' + n) + return [d.getFullYear(), d.getMonth() + 1, d.getDate()].map(pad).join('-') +} + export const splitKeep = (pattern, str) => { const result = [] let startIndex = 0 @@ -190,3 +212,39 @@ export const splitKeep = (pattern, str) => { return result } + +export const nameFactory = () => { + const all = {} + + return (str) => { + if (!all[str]) { + all[str] = true + return str + } + + let n = 2 + while (all[str + '-' + n]) { + n++ + } + + all[str + '-' + n] = true + return str + '-' + n + } +} + +export const composePromiseFn = (...list) => { + return reduceRight((cur, prev) => { + return x => prev(x).then(cur) + }, x => Promise.resolve(x), list) +} + +export const parseQuery = (query) => { + return query.slice(1).split('&').reduce((prev, cur) => { + const index = cur.indexOf('=') + const key = cur.substring(0, index) + const val = cur.substring(index + 1) + + prev[key] = decodeURIComponent(val) + return prev + }, {}) +} diff --git a/src/common/variables.js b/src/common/variables.js index be3964e..01d65f4 100644 --- a/src/common/variables.js +++ b/src/common/variables.js @@ -5,6 +5,7 @@ export default function varsFactory (options = {}, initial = {}) { return key.indexOf('!') === 0 && key !== '!TIMEOUT_PAGELOAD' && key !== '!TIMEOUT_WAIT' && + key !== '!TIMEOUT_MACRO' && key !== '!REPLAYSPEED' && key !== '!LOOP' && key !== '!URL' && @@ -14,10 +15,13 @@ export default function varsFactory (options = {}, initial = {}) { key !== '!CSVLINE' && key !== '!LASTCOMMANDOK' && key !== '!ERRORIGNORE' && + key !== '!CSVREADLINENUMBER' && + key !== '!CSVREADSTATUS' && + key !== '!CLIPBOARD' && !/^!COL\d+$/i.test(key) }, readonly: [ - '!LOOP', '!URL', '!MACRONAME', '!RUNTIME', '!LASTCOMMANDOK', + '!LOOP', '!URL', '!MACRONAME', '!RUNTIME', '!LASTCOMMANDOK', '!CSVREADSTATUS', 'KEY_LEFT', 'KEY_UP', 'KEY_RIGHT', 'KEY_DOWN', 'KEY_PGUP', 'KEY_PAGE_UP', 'KEY_PGDN', 'KEY_PAGE_DOWN', 'KEY_BKSP', 'KEY_BACKSPACE', 'KEY_DEL', 'KEY_DELETE', diff --git a/src/common/web_extension.js b/src/common/web_extension.js index 0a84b70..4305b60 100644 --- a/src/common/web_extension.js +++ b/src/common/web_extension.js @@ -79,6 +79,7 @@ cookies: ['get', 'getAll', 'set', 'remove'], notifications: ['create'], browserAction: ['getBadgeText'], + debugger: ['attach', 'detach', 'sendCommand', 'getTargets'], 'storage.local': ['get', 'set'] }, toCopy: { @@ -86,7 +87,8 @@ runtime: ['onMessage', 'onInstalled'], storage: ['onChanged'], browserAction: ['setBadgeText', 'setBadgeBackgroundColor', 'onClicked'], - extension: ['getURL'] + extension: ['getURL'], + debugger: ['onEvent', 'onDetach'] } } diff --git a/src/components/edit_test_suite.js b/src/components/edit_test_suite.js new file mode 100644 index 0000000..106c819 --- /dev/null +++ b/src/components/edit_test_suite.js @@ -0,0 +1,89 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Modal } from 'antd' +import { UnControlled as CodeMirror } from 'react-codemirror2' +import 'codemirror/lib/codemirror' +import 'codemirror/mode/javascript/javascript' +import 'codemirror/addon/edit/matchbrackets' +import 'codemirror/addon/edit/closebrackets' +import 'codemirror/lib/codemirror.css' + +export default class EditTestSuite extends React.Component { + static propTypes = { + value: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + visible: PropTypes.bool, + validate: PropTypes.func, + onChange: PropTypes.func + } + + static defaultProps = { + visible: false, + validate: () => true, + onChange: () => {} + } + + state = { + value: '', + valueModified: null, + errMsg: null, + } + + onSave = () => { + let errMsg = null + + try { + this.props.validate(this.state.valueModified) + this.props.onChange(this.state.valueModified) + } catch (e) { + errMsg = e.message + } finally { + this.setState({ errMsg }) + } + } + + componentDidMount () { + this.setState({ + value: this.props.value, + valueModified: this.props.value + }) + } + + componentWillReceiveProps (nextProps) { + if (nextProps.value !== this.props.value) { + this.setState({ + value: nextProps.value, + valueModified: nextProps.value + }) + } + } + + render () { + return ( + +
{this.state.errMsg}
+ {/* + Note: have to use UnControlled CodeMirror, and thus have to use two state : + sourceText and sourceTextModified + */} + this.setState({ valueModified: text })} + options={{ + mode: { name: 'javascript', json: true }, + lineNumbers: true, + matchBrackets: true, + autoCloseBrackets: true + }} + /> +
+ ) + } +} \ No newline at end of file diff --git a/src/components/editable_text.js b/src/components/editable_text.js new file mode 100644 index 0000000..82dff4d --- /dev/null +++ b/src/components/editable_text.js @@ -0,0 +1,122 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Input, Icon } from 'antd' + +export default class EditableText extends React.Component { + static propTypes = { + value: PropTypes.string, + isEditing: PropTypes.bool, + onChange: PropTypes.func, + inputProps: PropTypes.object, + textProps: PropTypes.object, + className: PropTypes.any, + clickToEdit: PropTypes.bool + } + + state = { + isEditing: false + } + + onChange = (e) => { + this.setState({ + value: e.target.value + }) + } + + onKeyDown = (e) => { + if (e.keyCode === 13) { + this.submit() + } else if (e.keyCode === 27) { + this.setState({ + value: this.props.value + }, this.submit) + } + } + + onBlur = (e) => { + this.submit() + } + + onClickText = () => { + if (this.props.clickToEdit) { + this.setState({ isEditing: true }) + } + } + + submit = () => { + this.setState({ + isEditing: false + }) + + if (this.props.onChange) { + this.props.onChange(this.state.value) + } + } + + componentDidMount () { + this.setState({ + isEditing: this.props.isEditing, + value: this.props.value + }) + + if (this.props.isEditing) { + this.focusOnInput() + } + } + + componentWillReceiveProps (nextProps) { + const nextState = {} + + if (this.props.isEditing !== nextProps.isEditing) { + nextState.isEditing = nextProps.isEditing + + if (nextState.isEditing) { + this.focusOnInput() + } + } + + if (this.props.value !== nextProps.value) { + nextState.value = nextProps.value + } + + this.setState(nextState) + } + + focusOnInput () { + setTimeout(() => { + const $input = this.input.refs.input + + if ($input) { + $input.focus() + $input.selectionStart = 0 + $input.selectionEnd = $input.value.length + } + }, 200) + } + + render () { + const { isEditing, value } = this.state + + return ( +
+ {isEditing ? ( + { this.input = ref }} + {...(this.props.inputProps || {})} + /> + ) : ( + + {value} + {this.props.clickToEdit ? ( + + ) : null} + + )} +
+ ) + } +} \ No newline at end of file diff --git a/src/components/header.js b/src/components/header.js index 49f7d0c..906f2dc 100644 --- a/src/components/header.js +++ b/src/components/header.js @@ -2,13 +2,35 @@ import React from 'react' import { connect } from 'react-redux' import { bindActionCreators } from 'redux' import { withRouter, Link } from 'react-router-dom' -import { Button, Checkbox, Dropdown, Menu, Icon, Modal } from 'antd' +import { Button, Checkbox, Dropdown, Menu, Icon, Modal, Row, Col, Form, Input, Select, Tabs, message } from 'antd' import './header.scss' +import { getPlayer, Player } from '../common/player' import * as actions from '../actions' import * as C from '../common/constant' class Header extends React.Component { + state = { + showPlayLoops: false, + loopsStart: 1, + loopsEnd: 3, + + showEnterFileName: false, + saveAsName: '', + + showReplaySettings: false + } + + getPlayer = () => { + switch (this.props.player.mode) { + case C.PLAYER_MODE.TEST_CASE: + return getPlayer({ name: 'testCase' }) + + case C.PLAYER_MODE.TEST_SUITE: + return getPlayer({ name: 'testSuite' }) + } + } + changeTestCase = ({ key }) => { const { src, hasUnsaved } = this.props.editing.meta const go = () => { @@ -30,9 +52,175 @@ class Header extends React.Component { go() } - onChangeSidebar = (e) => { - this.props.updateConfig({ - showSidebar: e.target.checked + getTestCaseName = () => { + const { src } = this.props.editing.meta + return src && src.name && src.name.length ? src.name : 'Untitled' + } + + togglePlayLoopsModal = (toShow) => { + this.setState({ + showPlayLoops: toShow + }) + } + + onToggleRecord = () => { + if (this.props.status === C.APP_STATUS.RECORDER) { + this.props.stopRecording() + // Note: remove targetOptions from all commands + this.props.normalizeCommands() + } else { + this.props.startRecording() + } + + this.setState({ lastOperation: 'record' }) + } + + // Play loops relative + onClickPlayLoops = () => { + const { loopsStart, loopsEnd } = this.state + + if (loopsStart < 0) { + return message.error('Start value must be no less than zero', 1.5) + } + + if (loopsEnd < loopsStart) { + return message.error('Max value must be greater than start value', 1.5) + } + + const player = this.getPlayer() + const { commands } = this.props.editing + const { src } = this.props.editing.meta + const openTc = commands.find(tc => tc.cmd.toLowerCase() === 'open') + + this.props.playerPlay({ + loopsEnd, + loopsStart, + title: this.getTestCaseName(), + extra: { + id: src && src.id + }, + mode: player.C.MODE.LOOP, + startIndex: 0, + startUrl: openTc ? openTc.target : null, + resources: this.props.editing.commands, + postDelay: this.props.config.playCommandInterval * 1000 + }) + + this.setState({ lastOperation: 'play' }) + this.togglePlayLoopsModal(false) + } + + onCancelPlayLoops = () => { + this.togglePlayLoopsModal(false) + this.setState({ + loopsToPlay: 2 + }) + } + + onChangePlayLoops = (field, value) => { + this.setState({ + [field]: parseInt(value, 10) + }) + } + + // Save as relative + onClickSaveAs = () => { + this.props.saveEditingAsNew(this.state.saveAsName) + .then(() => { + message.success('successfully saved!', 1.5) + this.toggleSaveAsModal(false) + }) + .catch((e) => { + message.error(e.message, 1.5) + }) + } + + onCancelSaveAs = () => { + this.toggleSaveAsModal(false) + this.setState({ + saveAsName: null + }) + } + + onChangeSaveAsName = (e) => { + this.setState({ + saveAsName: e.target.value + }) + } + + onClickSave = () => { + const meta = this.props.editing.meta + const { src, hasUnsaved } = meta + + if (!hasUnsaved) return + + if (src) { + this.props.saveEditingAsExisted() + .then(() => { + message.success('successfully saved!', 1.5) + }) + } else { + this.toggleSaveAsModal(true) + } + } + + toggleSaveAsModal = (toShow) => { + this.setState({ + showEnterFileName: toShow + }) + + if (toShow) { + setTimeout(() => { + const input = this.inputSaveTestCase.refs.input + input.focus() + input.selectionStart = input.selectionEnd = input.value.length; + }, 100) + } + } + + playCurrentMacro = () => { + const { commands } = this.props.editing + const { src } = this.props.editing.meta + const openTc = commands.find(tc => tc.cmd.toLowerCase() === 'open') + + this.setState({ lastOperation: 'play' }) + + this.props.playerPlay({ + title: this.getTestCaseName(), + extra: { + id: src && src.id + }, + mode: getPlayer().C.MODE.STRAIGHT, + startIndex: 0, + startUrl: openTc ? openTc.target : null, + resources: commands, + postDelay: this.props.config.playCommandInterval * 1000 + }) + } + + playCurrentLine = () => { + const { commands } = this.props.editing + const { src, selectedIndex } = this.props.editing.meta + const commandIndex = selectedIndex === -1 ? 0 : (selectedIndex || 0) + + return this.props.playerPlay({ + title: this.getTestCaseName(), + extra: { + id: src && src.id + }, + mode: Player.C.MODE.SINGLE, + startIndex: commandIndex, + startUrl: null, + resources: commands, + postDelay: this.props.config.playCommandInterval * 1000, + callback: (err, res) => { + if (err) return + + // Note: auto select next command + if (commandIndex + 1 < commands.length) { + this.props.selectCommand(commandIndex + 1, true) + } + } }) } @@ -45,49 +233,422 @@ class Header extends React.Component { }) } + renderPlayLoopModal () { + return ( + + + + + { if (e.keyCode === 13) this.onClickPlayLoops() }} + onChange={e => this.onChangePlayLoops('loopsStart', e.target.value)} + /> + + + + + { if (e.keyCode === 13) this.onClickPlayLoops() }} + onChange={e => this.onChangePlayLoops('loopsEnd', e.target.value)} + /> + + + + +

+ The value of the loop counter is available in ${'{'}!LOOP{'}'} variable +

+
+ ) + } + + renderSaveAsModal () { + return ( + + { if (e.keyCode === 13) this.onClickSaveAs() }} + onChange={this.onChangeSaveAsName} + placeholder="test case name" + ref={el => { this.inputSaveTestCase = el }} + /> + + ) + } + + renderSettingModal () { + const onConfigChange = (key, val) => { + this.props.updateConfig({ [key]: val }) + } + + const displayConfig = { + labelCol: { span: 8 }, + wrapperCol: { span : 16 } + } + + return ( + this.setState({ showReplaySettings: false })} + > + + +
+ + onConfigChange('playScrollElementsIntoView', e.target.checked)} + checked={this.props.config.playScrollElementsIntoView} + > + Scroll elements into view during replay + + + onConfigChange('playHighlightElements', e.target.checked)} + checked={this.props.config.playHighlightElements} + > + Highlight elements during replay + + + + + + + + + onConfigChange('timeoutPageLoad', e.target.value)} + placeholder="in seconds" + /> + + + + onConfigChange('timeoutElement', e.target.value)} + placeholder="in seconds" + /> + + + + onConfigChange('timeoutMacro', e.target.value)} + placeholder="in seconds" + /> + + + + onConfigChange('recordNotification', e.target.checked)} + checked={this.props.config.recordNotification} + > + Record notifications + + +
+
+ +

Automatic Backup

+
+ onConfigChange('enableAutoBackup', e.target.checked)} + checked={this.props.config.enableAutoBackup} + /> + Show backup reminder every + onConfigChange('autoBackupInterval', e.target.value)} + style={{ width: '40px' }} + /> + days +
+
+

Backup includes

+
    +
  • + onConfigChange('autoBackupTestCases', e.target.checked)} + checked={this.props.config.autoBackupTestCases} + /> + Test cases +
  • +
  • + onConfigChange('autoBackupTestSuites', e.target.checked)} + checked={this.props.config.autoBackupTestSuites} + /> + Test suites +
  • +
  • + onConfigChange('autoBackupScreenshots', e.target.checked)} + checked={this.props.config.autoBackupScreenshots} + /> + Screenshots +
  • +
  • + onConfigChange('autoBackupCSVFiles', e.target.checked)} + checked={this.props.config.autoBackupCSVFiles} + /> + CSV files +
  • +
+
+
+ And you can also + +
+
+
+
+ ) + } + + renderMainMenu () { + const { htmlUri, jsonUri } = this.state + const { status, editing } = this.props + const { commands, meta } = editing + const { src } = meta + const canPlay = this.props.player.status === C.PLAYER_STATUS.STOPPED + const downloadNamePrefix = src ? src.name : 'Untitled' + + const onClickMenuItem = ({ key }) => { + switch (key) { + case 'play_settings': { + this.setState({ showReplaySettings: true }) + break + } + } + } + + return ( + + + Replay settings.. + + + ) + } + renderStatus () { const { status, player } = this.props + const renderInner = () => { + switch (status) { + case C.APP_STATUS.RECORDER: + return 'Recording' - switch (status) { - case C.APP_STATUS.RECORDER: - return 'Recording' + case C.APP_STATUS.PLAYER: { + switch (player.status) { + case C.PLAYER_STATUS.PLAYING: { + const { nextCommandIndex, loops, currentLoop, timeoutStatus } = player - case C.APP_STATUS.PLAYER: { - switch (player.status) { - case C.PLAYER_STATUS.PLAYING: { - const { nextCommandIndex, loops, currentLoop, timeoutStatus } = player + if (nextCommandIndex === null || + loops === null || currentLoop === 0) { + return '' + } - if (nextCommandIndex === null || - loops === null || currentLoop === 0) { - return '' - } + const parts = [ + `Line ${nextCommandIndex + 1}`, + `Round ${currentLoop}/${loops}` + ] - const parts = [ - `Line ${nextCommandIndex + 1}`, - `Round ${currentLoop}/${loops}` - ] + if (timeoutStatus && timeoutStatus.type && timeoutStatus.total) { + const { type, total, past } = timeoutStatus + parts.unshift(`${type} ${past / 1000}s (${total / 1000})`) + } - if (timeoutStatus && timeoutStatus.type && timeoutStatus.total) { - const { type, total, past } = timeoutStatus - parts.unshift(`${type} ${past / 1000}s (${total / 1000})`) + return parts.join(' | ') } - return parts.join(' | ') + case C.PLAYER_STATUS.PAUSED: + return 'Player paused' + + default: + return '' } + } - case C.PLAYER_STATUS.PAUSED: - return 'Player paused' + default: + return '' + } + } - default: - return '' + return
{renderInner()}
+ } + + renderActions () { + const { testCases, editing, player, status } = this.props + + const onClickMenuItem = ({key}) => { + switch (key) { + case 'play_loop': { + this.togglePlayLoopsModal(true) + break } } + } - default: - return '' + const playMenu = ( + + + Play loop.. + + + ) + + if (status === C.APP_STATUS.RECORDER) { + return ( +
+ +
+ ) + } + + switch (player.status) { + case C.PLAYER_STATUS.PLAYING: { + return ( +
+ + + + +
+ ) + } + + case C.PLAYER_STATUS.PAUSED: { + return ( +
+ + + + +
+ ) + } + + case C.PLAYER_STATUS.STOPPED: { + return ( +
+ + + + + + Play Macro + + + + +
+ ) + } } } + renderMacro () { + const { testCases, editing, player } = this.props + const { src, hasUnsaved } = editing.meta + const isPlayerStopped = player.status === C.PLAYER_STATUS.STOPPED + const klass = hasUnsaved ? 'unsaved' : '' + + const saveBtnState = { + text: src ? 'Save' : 'Save..', + disabled: !hasUnsaved + } + + return ( +
+ {src ? src.name : 'Untitled'} + + {!isPlayerStopped ? null : ( + + )} +
+ ) + } + render () { const { testCases, editing, player } = this.props const { src, hasUnsaved } = editing.meta @@ -122,27 +683,12 @@ class Header extends React.Component { return (
-
- {this.renderStatus()} -
- -
- - - -
- -
- - Show sidebar - -
+ {this.renderMacro()} + {this.renderStatus()} + {this.renderActions()} + {this.renderPlayLoopModal()} + {this.renderSaveAsModal()} + {this.renderSettingModal()}
) } diff --git a/src/components/header.scss b/src/components/header.scss index e128ce6..dd9cc86 100644 --- a/src/components/header.scss +++ b/src/components/header.scss @@ -1,4 +1,7 @@ .header { + display: flex; + flex-direction: row; + justify-content: space-between; overflow: hidden; padding: 0 20px; width: 100%; @@ -19,27 +22,40 @@ } .select-case { - display: inline-block; - margin-top: 7px; + display: flex; + align-items: center; + line-height: 44px; + font-size: 13px; - .unsaved { - color: orange; + .test-case-name { + margin-right: 15px; + line-height: 35px; + max-width: 120px; + overflow: hidden; + display: inline-block; + white-space: nowrap; + text-overflow: ellipsis; - &::after { - content: '*'; - margin-left: 5px; + &.unsaved { + color: orange; + + &::after { + content: '*'; + margin-left: 3px; + } } } } - .show-sidebar { - display: none; - } + .actions { + margin-top: 6px; - &.normal { - .show-sidebar { - display: inline-block; - margin-left: 15px; + .ant-btn-group > .ant-btn-group { + float: none; + } + + .play-actions { + margin: 0 10px; } } } @@ -52,3 +68,33 @@ color: blue !important; } } + +.settings-modal { + .ant-checkbox-wrapper + .ant-checkbox-wrapper { + margin-left: 0; + } + + .backup-pane { + padding: 0 20px; + + h4 { + font-size: 16px; + margin-bottom: 10px; + } + + .row { + margin-bottom: 10px; + + } + + p { + margin-bottom: 5px; + } + + ul { + li { + margin-bottom: 5px; + } + } + } +} \ No newline at end of file diff --git a/src/config/preinstall_macros.js b/src/config/preinstall_macros.js index 52b6051..41feb49 100644 --- a/src/config/preinstall_macros.js +++ b/src/config/preinstall_macros.js @@ -292,6 +292,36 @@ DemoExtract: { "Target": "page title = ${mytitle}", "Value": "" }, + { + "Command": "echo", + "Target": "Now test some extraction with storeValue", + "Value": "" + }, + { + "Command": "storeValue", + "Target": "id=sometext", + "Value": "mytext" + }, + { + "Command": "select", + "Target": "id=tesla", + "Value": "label=Model Y" + }, + { + "Command": "storeValue", + "Target": "id=tesla", + "Value": "mytesla" + }, + { + "Command": "echo", + "Target": "The text box contains [${mytext}] and the select box has the value [${mytesla}] selected", + "Value": "" + }, + { + "Command": "verifyValue", + "Target": "id=tesla", + "Value": "y" + }, { "Command": "echo", "Target": "Last but not least, taking a screenshot is another way to extract data", @@ -304,9 +334,9 @@ DemoExtract: { }, { "Command": "echo", - "Target": "Need to save data to CSV files? Contact us for a pre-beta of the next version ;-)", + "Target": "Tip: To save extracted data to CSV files use the csvSave command. See the DemoCsvSave macro for details.", "Value": "" - } + } ] }, DemoFrames: { @@ -474,9 +504,60 @@ DemoFrames: { } ] }, -DemoGotoIf: { - "CreationDate": "2017-11-23", + +DemoTakeScreenshots: { + "CreationDate": "2018-1-25", + "Commands": [ + { + "Command": "open", + "Target": "https://a9t9.com/blog/", + "Value": "" + }, + { + "Command": "captureEntirePageScreenshot", + "Target": "a9t9 blog", + "Value": "" + }, + { + "Command": "clickAndWait", + "Target": "link=read more@POS=1", + "Value": "" + }, + { + "Command": "captureEntirePageScreenshot", + "Target": "article1", + "Value": "" + }, + { + "Command": "open", + "Target": "https://a9t9.com/blog/", + "Value": "" + }, + { + "Command": "clickAndWait", + "Target": "link=read more@POS=2", + "Value": "" + }, + { + "Command": "captureEntirePageScreenshot", + "Target": "article2", + "Value": "" + }, + { + "Command": "captureScreenshot", + "Target": "article2-just-viewport", + "Value": "" + } + ] +}, +DemoIfElse: { + "CreationDate": "2018-01-12", "Commands": [ + { + "Command": "store", + "Target": "fast", + "Value": "!replayspeed" + }, { "Command": "open", "Target": "https://a9t9.com/kantu/demo/storeeval", @@ -487,11 +568,86 @@ DemoGotoIf: { "Target": "How to use gotoIf and label(s) for flow control. For a while/endWhile demo, see the DemoSaveCSV macro.", "Value": "" }, + { + "Command": "storeEval", + "Target": "(new Date().getHours())", + "Value": "mytime" + }, + { + "Command": "echo", + "Target": "mytime = ${mytime}", + "Value": "" + }, + { + "Command": "if", + "Target": "${mytime} > 16", + "Value": "" + }, + { + "Command": "echo", + "Target": "Good afternoon!", + "Value": "" + }, + { + "Command": "else", + "Target": "", + "Value": "" + }, + { + "Command": "echo", + "Target": "Good morning!", + "Value": "" + }, + { + "Command": "endif", + "Target": "", + "Value": "" + }, + { + "Command": "store", + "Target": "true", + "Value": "!errorignore" + }, + { + "Command": "storeAttribute", + "Target": "//input[@id='sometext-WRONG-ID-TEST']@size", + "Value": "boxsize" + }, + { + "Command": "if", + "Target": "${!LastCommandOK}", + "Value": "" + }, + { + "Command": "echo", + "Target": "Boxsize is ${boxsize}", + "Value": "" + }, + { + "Command": "else", + "Target": "", + "Value": "" + }, { "Command": "storeAttribute", "Target": "//input[@id='sometext']@size", "Value": "boxsize" }, + { + "Command": "echo", + "Target": "Old ID not found, with new ID we have: Boxsize = ${boxsize}", + "Value": "" + }, + { + "Command": "endif", + "Target": "", + "Value": "" + }, + { + "Command": "store", + "Target": "false", + "Value": "!errorignore" + }, { "Command": "echo", "Target": "input box size =${boxsize}", @@ -704,7 +860,7 @@ DemoImplicitWaiting: { } ] }, -DemoCsvRead: { +DemoCsvReadWithLoop: { "CreationDate": "2017-11-23", "Commands": [ { @@ -819,6 +975,171 @@ DemoCsvRead: { } ] }, +DemoCsvReadWithWhile: { + "CreationDate": "2018-1-25", + "Commands": [ + { + "Command": "store", + "Target": "180", + "Value": "!timeout_macro" + }, + { + "Command": "store", + "Target": "fast", + "Value": "!replayspeed" + }, + { + "Command": "echo", + "Target": "First create some test data CSV file (3 lines)", + "Value": "!csvLine" + }, + { + "Command": "store", + "Target": "Donald", + "Value": "!csvLine" + }, + { + "Command": "store", + "Target": "Knuth", + "Value": "!csvLine" + }, + { + "Command": "store", + "Target": "team@a9t9.com", + "Value": "!csvLine" + }, + { + "Command": "csvSave", + "Target": "ReadCSVTestData.csv", + "Value": "" + }, + { + "Command": "store", + "Target": "Ashu", + "Value": "!csvLine" + }, + { + "Command": "store", + "Target": "Zarathushtra", + "Value": "!csvLine" + }, + { + "Command": "store", + "Target": "Zarathushtra2018@gmail.com", + "Value": "!csvLine" + }, + { + "Command": "csvSave", + "Target": "ReadCSVTestData.csv", + "Value": "" + }, + { + "Command": "store", + "Target": "Yasna", + "Value": "!csvLine" + }, + { + "Command": "store", + "Target": "Haptanghaiti", + "Value": "!csvLine" + }, + { + "Command": "store", + "Target": "Happy123456@unknownstartup.com", + "Value": "!csvLine" + }, + { + "Command": "csvSave", + "Target": "ReadCSVTestData.csv", + "Value": "" + }, + { + "Command": "echo", + "Target": "--- Read CSV Test starts here ---", + "Value": "" + }, + { + "Command": "label", + "Target": "TESTSTART", + "Value": "" + }, + { + "Command": "csvRead", + "Target": "ReadCSVTestData.csv", + "Value": "" + }, + { + "Command": "echo", + "Target": "Status = ${!csvReadStatus}, line = ${!csvReadLineNumber}", + "Value": "" + }, + { + "Command": "while", + "Target": "\"${!csvReadStatus}\" == \"OK\"", + "Value": "" + }, + { + "Command": "echo", + "Target": "status = ${!csvReadStatus}, line = ${!csvReadLineNumber}", + "Value": "" + }, + { + "Command": "open", + "Target": "https://docs.google.com/forms/d/e/1FAIpQLScGWVjexH2FNzJqPACzuzBLlTWMJHgLUHjxehtU-2cJxtu6VQ/viewform", + "Value": "" + }, + { + "Command": "type", + "Target": "name=entry.933434489", + "Value": "${!COL1}_${!csvReadLineNumber}" + }, + { + "Command": "type", + "Target": "name=entry.2004105717", + "Value": "${!COL2}" + }, + { + "Command": "type", + "Target": "name=entry.1382578664", + "Value": "${!COL3}" + }, + { + "Command": "clickAndWait", + "Target": "//*[@id=\"mG61Hd\"]/div/div[2]/div[3]/div[1]/div/div/content/span", + "Value": "" + }, + { + "Command": "storeEval", + "Target": "${!csvReadLineNumber}+1", + "Value": "!csvReadLineNumber" + }, + { + "Command": "store", + "Target": "true", + "Value": "!errorIgnore" + }, + { + "Command": "echo", + "Target": "Reading CSV line No. ${!csvReadLineNumber} ", + "Value": "!errorIgnore" + }, + { + "Command": "csvRead", + "Target": "ReadCSVTestData.csv", + "Value": "" + }, + { + "Command": "store", + "Target": "false", + "Value": "!errorIgnore" + }, + { + "Command": "endWhile", + "Target": "", + "Value": "" + } + ] +}, DemoCsvSave: { "CreationDate": "2017-11-23", "Commands": [ @@ -900,8 +1221,13 @@ DemoCsvSave: { ] }, DemoStoreEval: { - "CreationDate": "2017-10-15", + "CreationDate": "2018-1-25", "Commands": [ + { + "Command": "store", + "Target": "fast", + "Value": "!replayspeed" + }, { "Command": "open", "Target": "https://a9t9.com/kantu/demo/storeeval", diff --git a/src/config/preinstall_suites.js b/src/config/preinstall_suites.js new file mode 100644 index 0000000..c154e96 --- /dev/null +++ b/src/config/preinstall_suites.js @@ -0,0 +1,92 @@ +export default [ + { + "creationDate": "2017-12-12", + "name": "DemoLoopsInsideTestSuite", + "fold": true, + "macros": [ + { + "macro": "DemoDragDrop", + "loops": 3 + }, + { + "macro": "DemoIfElse", + "loops": 3 + }, + { + "macro": "DemoStoreEval", + "loops": 3 + } + ] + }, + { + "creationDate": "2017-12-15", + "name": "DemoTestSuite", + "fold": true, + "macros": [ + { + "macro": "DemoAutofill", + "loops": 1 + }, + { + "macro": "DemoCsvReadWithLoop", + "loops": 3 + }, + { + "macro": "DemoCsvReadWithWhile", + "loops": 1 + }, + { + "macro": "DemoCsvSave", + "loops": 1 + }, + { + "macro": "DemoDialogboxes", + "loops": 1 + }, + { + "macro": "DemoDragDrop", + "loops": 1 + }, + { + "macro": "DemoExtract", + "loops": 1 + }, + { + "macro": "DemoFrames", + "loops": 1 + }, + { + "macro": "DemoTakeScreenshots", + "loops": 1 + }, + { + "macro": "DemoIfElse", + "loops": 1 + }, + { + "macro": "DemoIframe", + "loops": 1 + }, + { + "macro": "DemoImplicitWaiting", + "loops": 1 + }, + { + "macro": "DemoPOS", + "loops": 1 + }, + { + "macro": "DemoStoreEval", + "loops": 1 + }, + { + "macro": "DemoTabs", + "loops": 1 + }, + { + "macro": "DemoTimeout", + "loops": 1 + } + ] +} +] \ No newline at end of file diff --git a/src/containers/dashboard/bottom.js b/src/containers/dashboard/bottom.js index aa7029b..f4bfda8 100644 --- a/src/containers/dashboard/bottom.js +++ b/src/containers/dashboard/bottom.js @@ -6,6 +6,7 @@ import { Form, Select, Modal, message, Row, Col, Tabs, Popconfirm } from 'antd' +import { cn, setIn } from '../../common/utils' import { getCSVMan } from '../../common/csv_man' import * as actions from '../../actions' import * as C from '../../common/constant' @@ -17,7 +18,47 @@ class DashboardBottom extends React.Component { showCSVModal: false, csvText: '', - csvFile: '' + csvFile: '', + + drag: { + isDragging: false, + startY: 0, + lastHeight: 220, + currentMinHeight: 220 + } + } + + getBottomMinHeight = () => { + const { isDragging, lastHeight, currentMinHeight } = this.state.drag + return (isDragging ? currentMinHeight : lastHeight) + 'px' + } + + onResizeDragStart = (e) => { + const style = window.getComputedStyle(this.$dom) + const height = parseInt(style.height) + + this.setState( + setIn(['drag'], { + isDragging: true, + startY: e.clientY, + lastHeight: height, + currentHeight: height + }, this.state) + ) + } + + onResizeDragEnd = (e) => { + const diff = e.clientY - this.state.drag.startY + const height = this.state.drag.lastHeight - diff + + this.setState( + setIn(['drag'], { + isDragging: false, + startY: 0, + lastHeight: height, + currentMinHeight: height + }) + ) } onFileChange = (e) => { @@ -66,26 +107,7 @@ class DashboardBottom extends React.Component { } viewCSV = (csv) => { - const csvMan = getCSVMan() - - csvMan.read(csv.fileName) - .then(text => { - const win = window.open('', '', 'width=600, height=500, scrollbars=yes'); - win.document.write(` - - - `) - }) + window.open(`./csv_editor.html?csv=${csv.fileName}`, '', 'width=600,height=500,scrollbars=true') } componentWillReceiveProps (nextProps) { @@ -211,9 +233,21 @@ class DashboardBottom extends React.Component { const logs = this.props.logs.filter(filters[logFilter]) return ( -
+
{ this.$dom = el }} + style={{ height: this.getBottomMinHeight() }} + > {this.renderCSVModal()} +
this.setState(setIn(['drag', 'isDragging'], true, this.state))} + /> + this.setState({ activeTabForLogScreenshot: key })} @@ -233,7 +267,7 @@ class DashboardBottom extends React.Component { {this.props.screenshots.map((ss, i) => (
  • - {ss.createTime.toLocaleString()} - {decodeURIComponent(ss.name)} + {ss.createTime && ss.createTime.toLocaleString()} - {decodeURIComponent(ss.name)} diff --git a/src/containers/dashboard/dashboard.scss b/src/containers/dashboard/dashboard.scss index ee0be3b..e0c9ca5 100644 --- a/src/containers/dashboard/dashboard.scss +++ b/src/containers/dashboard/dashboard.scss @@ -3,7 +3,7 @@ .dashboard { @include flexcol(); flex: 1; - margin: 20px 20px 0; + margin: 15px 15px 0; .form-group { margin-bottom: 15px; @@ -36,6 +36,12 @@ } } + .ant-table-body { + .ant-table-thead > tr > th { + padding: 10px 8px; + } + } + .ant-table-tbody > tr > td { padding: 8px 8px; } @@ -72,7 +78,6 @@ .commands-view { @include flexcol(); flex: 2; - margin-bottom: 15px; .ant-tabs-bar { margin-bottom: 0; @@ -146,10 +151,42 @@ } } + .table-footer { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + line-height: 32px; + text-align: center; + font-weight: bold; + background: #f7f7f7; + cursor: pointer; + + &:hover { + background: #e0e0e0; + } + } + .logs-screenshots { @include flexcol(); - flex: 1; position: relative; + margin-top: 15px; + + .resize-handler { + position: absolute; + top: -10px; + left: 0; + width: 100%; + height: 6px; + background: transparent; + cursor: row-resize; + + &:hover, &.focused { + height: 6px; + background: #ccc; + } + } .ant-tabs { @include flexcol(); @@ -163,7 +200,7 @@ .ant-tabs-content { flex: 1; overflow-y: auto; - height: 160px; + min-height: 70px; border: 1px solid #d9d9d9; border-width: 0 1px 1px; } diff --git a/src/containers/dashboard/editor.js b/src/containers/dashboard/editor.js index 4affc65..348bf20 100644 --- a/src/containers/dashboard/editor.js +++ b/src/containers/dashboard/editor.js @@ -59,7 +59,14 @@ const availableCommands = [ 'while', 'endWhile', 'csvRead', - 'csvSave' + 'csvSave', + 'if', + 'else', + 'endif', + 'storeValue', + 'assertValue', + 'verifyValue', + 'captureEntirePageScreenshot' ] availableCommands.sort() @@ -78,6 +85,7 @@ class DashboardEditor extends React.Component { sourceText: '', sourceTextModified: null, sourceErrMsg: null, + cursor: null, contextMenu: { x: null, @@ -99,7 +107,8 @@ class DashboardEditor extends React.Component { return { sourceText: text, sourceTextModified: text, - sourceErrMsg: null + sourceErrMsg: null, + cursor: { line: 0, ch: 0 } } } @@ -117,6 +126,13 @@ class DashboardEditor extends React.Component { activeTabForCommands: forceType }) + if (type === 'source_view' && this.codeMirror && this.state.cursor) { + // Note: must delay a while so that focus will take effect + setTimeout(() => { + this.codeMirror.setCursor(this.state.cursor, true, true) + }, 200) + } + break } } @@ -124,7 +140,11 @@ class DashboardEditor extends React.Component { onSourceBlur = () => { try { - const { sourceTextModified } = this.state + const { sourceTextModified, sourceText } = this.state + + // Note: save some effort if text is not changed + if (sourceText === sourceTextModified) return + const obj = fromJSONString(sourceTextModified, 'untitled') this.setState({ @@ -180,7 +200,7 @@ class DashboardEditor extends React.Component { componentWillReceiveProps (nextProps) { // Note: update sourceText whenever editing changed - if (nextProps.editing !== this.props.editing) { + if (nextProps.editing.meta.src !== this.props.editing.meta.src) { this.setState( this.editingToSourceText(nextProps.editing) ) @@ -250,6 +270,11 @@ class DashboardEditor extends React.Component { document.addEventListener('click', onHideMenu) } + getTestCaseName = () => { + const { src } = this.props.editing.meta + return src && src.name && src.name.length ? src.name : 'Untitled' + } + renderContextMenu () { const { clipboard } = this.props const { contextMenu } = this.state @@ -384,6 +409,13 @@ class DashboardEditor extends React.Component { dataSource, columns, pagination: false, + footer: () => ( +
    { + this.props.insertCommand(newCommand, commands.length) + }}> + Add +
    + ), onRowClick: (record, index, e) => { this.props.selectCommand(index) }, @@ -523,10 +555,18 @@ class DashboardEditor extends React.Component { sourceText and sourceTextModified */} { this.codeMirror = el }} className={this.state.sourceErrMsg ? 'has-error' : 'no-error'} value={this.state.sourceText} onChange={this.onChangeEditSource} onBlur={this.onSourceBlur} + onCursor={(editor, data) => { + // Note: when value updated, code mirror will automatically emit onCursor with cursor at bottom + // It can be tell with `sticky` as null + if (data.sticky) { + this.setState({ cursor: { line: data.line, ch: data.ch } }) + } + }} options={{ mode: { name: 'javascript', json: true }, lineNumbers: true, diff --git a/src/containers/dashboard/index.js b/src/containers/dashboard/index.js index 3abe546..e31c084 100644 --- a/src/containers/dashboard/index.js +++ b/src/containers/dashboard/index.js @@ -5,7 +5,6 @@ import { bindActionCreators } from 'redux' import './dashboard.scss' import * as actions from '../../actions' -import DashboardActions from './actions' import DashboardEditor from './editor' import DashboardBottom from './bottom' @@ -13,7 +12,6 @@ class Dashboard extends React.Component { render () { return (
    - diff --git a/src/containers/sidebar/index.js b/src/containers/sidebar/index.js new file mode 100644 index 0000000..f23247f --- /dev/null +++ b/src/containers/sidebar/index.js @@ -0,0 +1,97 @@ +import React from 'react' +import { connect } from 'react-redux' +import { bindActionCreators, compose } from 'redux' +import { Modal, Tabs, Icon, Select, Input, Button, Menu, Dropdown, Alert, message } from 'antd' +import ClickOutside from 'react-click-outside' + +import './sidebar.scss' +import * as actions from '../../actions' +import { setIn, updateIn, cn } from '../../common/utils' +import SidebarTestSuites from './test_suites' +import SidebarTestCases from './test_cases' + +class Sidebar extends React.Component { + state = { + drag: { + isDragging: false, + startX: 0, + lastWidth: 260, + currentMinWidth: 260 + } + } + + getSideBarMinWidth = () => { + const { isDragging, lastWidth, currentMinWidth } = this.state.drag + return (isDragging ? currentMinWidth : lastWidth) + 'px' + } + + onResizeDragStart = (e) => { + // e.dataTransfer.setDragImage(new Image(), 10, 10) + const style = window.getComputedStyle(this.$dom) + const width = parseInt(style.width) + + this.setState( + setIn(['drag'], { + isDragging: true, + startX: e.clientX, + lastWidth: width, + currentWidth: width + }, this.state) + ) + } + + onResizeDragEnd = (e) => { + const diff = e.clientX - this.state.drag.startX + const width = diff + this.state.drag.lastWidth + + this.setState( + setIn(['drag'], { + isDragging: false, + startX: 0, + lastWidth: width, + currentMinWidth: width + }) + ) + } + + render () { + return ( +
    { this.$dom = el }} + style={{ minWidth: this.getSideBarMinWidth() }} + > +
    + + + + + + + + +
    + +
    this.setState(setIn(['drag', 'isDragging'], true, this.state))} + /> +
    + ) + } +} + +export default connect( + state => ({ + status: state.status, + testCases: state.editor.testCases, + testSuites: state.editor.testSuites, + editing: state.editor.editing, + player: state.player, + config: state.config + }), + dispatch => bindActionCreators({...actions}, dispatch) +)(Sidebar) diff --git a/src/containers/sidebar/sidebar.scss b/src/containers/sidebar/sidebar.scss new file mode 100644 index 0000000..06b240c --- /dev/null +++ b/src/containers/sidebar/sidebar.scss @@ -0,0 +1,239 @@ + +$bgSuccess: #cfefdf; +$bgError: #fcdbd9; +$bgSelected: #fdffd1; +$bgRunning: #d5d6f9; + +.sidebar { + position: relative; + // display: none; + flex: 1; + min-width: 260px; + height: 100%; + border-right: 2px solid #ccc; + + .sidebar-inner { + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + overflow-y: auto; + } + + .sidebar-title { + padding: 0 10px; + height: 44px; + line-height: 44px; + border-bottom: 2px solid #ccc; + background-color: #f9f9f9; + font-size: 16px; + } + + .sidebar-test-cases { + font-size: 14px; + line-height: 18px; + + li { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + box-sizing: border-box; + padding: 5px 10px; + cursor: pointer; + user-select: none; + + &.success { + background: $bgSuccess; + } + + &.error { + background: $bgError; + } + + &.selected { + background: $bgSelected; + + &.error, + &.success { + padding: 1px 10px 1px 6px; + } + + &.error { + border: 4px solid $bgError; + } + + &.success { + border: 4px solid $bgSuccess; + } + } + + &.disabled { + filter: grayscale(60%); + cursor: not-allowed; + } + + .test-case-name { + flex: 1; + } + + .more-button { + display: none; + } + + &:hover .more-button { + display: block; + } + } + } + + .test-case-actions, + .test-suite-actions { + padding: 0 10px 10px; + + button { + margin-right: 10px; + } + } + + .sidebar-test-suites { + .test-suite-item { + padding: 0 0 10px 0; + margin-bottom: 5px; + + &.playing { + background: $bgSelected; + } + + &.fold { + margin-bottom: 0; + padding-bottom: 0; + + .test-suite-more-actions, + .test-suite-cases { + display: none; + } + } + + .test-suite-row { + padding: 5px 10px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + + .test-suite-title { + flex: 1; + margin-left: 10px; + } + + .more-button { + display: none; + } + + &:hover .more-button { + display: block; + } + } + + .test-suite-cases { + padding: 3px 5px; + + li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 3px 5px 3px 20px; + margin-bottom: 5px; + + &.done-tc { + background: $bgSuccess; + } + + &.error-tc { + background: $bgError; + } + + &.current-tc { + background: $bgRunning; + } + } + } + + .test-suite-more-actions { + padding-left: 27px; + } + } + } + + .ant-tabs { + min-height: 100%; + } + + .ant-tabs-bar { + border-bottom: 2px solid #ccc; + } + + .ant-tabs-nav-container-scrolling { + padding-left: 0; + padding-right: 0; + } + + .ant-tabs-tab-prev.ant-tabs-tab-arrow-show, + .ant-tabs-tab-next.ant-tabs-tab-arrow-show { + display: none; + } + + .ant-tabs-nav { + height: 44px; + } + + .ant-tabs-nav .ant-tabs-tab { + margin-right: 0; + line-height: 27px; + } + + .ant-tabs-nav-scroll { + text-align: center; + } + + .context-menu { + z-index: 10; + + .ant-menu { + border: '1px solid #ccc'; + border-radius: 4px; + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2); + + .ant-menu-item { + height: 36px; + line-height: 36px; + + &:hover { + background: #ecf6fd; + } + } + } + } + + .resize-handler { + position: absolute; + right: -2px; + top: 0; + bottom: 0; + width: 2px; + background: #ccc; + cursor: col-resize; + + &:hover, &.focused { + right: -4px; + width: 6px; + background: #aaa; + } + } +} + +.with-sidebar .sidebar { + display: block; +} diff --git a/src/containers/sidebar/test_cases.js b/src/containers/sidebar/test_cases.js new file mode 100644 index 0000000..54b6720 --- /dev/null +++ b/src/containers/sidebar/test_cases.js @@ -0,0 +1,585 @@ +import React from 'react' +import { connect } from 'react-redux' +import { bindActionCreators, compose } from 'redux' +import { Modal, Tabs, Icon, Select, Input, Button, Menu, Dropdown, Alert, message } from 'antd' +import ClickOutside from 'react-click-outside' +import FileSaver from 'file-saver' +import JSZip from 'jszip' + +import { getPlayer } from '../../common/player' +import { setIn, updateIn, cn, formatDate, nameFactory, pick } from '../../common/utils' +import * as actions from '../../actions' +import * as C from '../../common/constant' +import { + toJSONString, + toJSONDataUri, + toHtmlDataUri, + toHtml, + fromHtml, + fromJSONString +} from '../../common/convert_utils' + +const downloadTestCaseAsJSON = (tc) => { + const str = toJSONString({ name: tc.name, commands: tc.data.commands }) + const blob = new Blob([str], { type: 'text/plain;charset=utf-8' }) + + FileSaver.saveAs(blob, `${tc.name}.json`) +} + +const downloadTestCaseAsHTML = (tc) => { + const str = toHtml({ name: tc.name, commands: tc.data.commands }) + const blob = new Blob([str], { type: 'text/plain;charset=utf-8' }) + + FileSaver.saveAs(blob, `${tc.name}.html`) +} + +class SidebarTestCases extends React.Component { + state = { + showDuplicate: false, + duplicateName: '', + + showRename: false, + rename: '', + + tcContextMenu: { + x: null, + y: null, + isShown: false + } + } + + // Rename relative + onClickRename = () => { + this.props.renameTestCase(this.state.rename, this.state.renameTcId) + .then(() => { + message.success('successfully renamed!', 1.5) + this.toggleRenameModal(false) + }) + .catch((e) => { + message.error(e.message, 1.5) + }) + } + + onCancelRename = () => { + this.toggleRenameModal(false) + this.setState({ + rename: null + }) + } + + onChangeRename = (e) => { + this.setState({ + rename: e.target.value + }) + } + + // Duplicate relative + onClickDuplicate = () => { + this.props.duplicateTestCase(this.state.duplicateName, this.state.duplicateTcId) + .then(() => { + message.success('successfully duplicated!', 1.5) + }) + this.toggleDuplicateModal(false) + } + + onCancelDuplicate = () => { + this.toggleDuplicateModal(false) + } + + onChangeDuplicate = (e) => { + this.setState({ + duplicateName: e.target.value + }) + } + + toggleDuplicateModal = (toShow, tc) => { + let duplicateName = tc ? (tc.name + '_new') : '' + + this.setState({ + showDuplicate: toShow, + duplicateTcId: tc && tc.id, + duplicateName + }) + + if (toShow) { + setTimeout(() => { + const input = this.inputDuplicateTestCase.refs.input + input.focus() + input.selectionStart = input.selectionEnd = input.value.length; + }, 100) + } + } + + toggleRenameModal = (toShow, tc) => { + this.setState({ + showRename: toShow, + renameTcId: tc && tc.id + }) + + if (toShow) { + setTimeout(() => { + const input = this.inputRenameTestCase.refs.input + input.focus() + input.selectionStart = input.selectionEnd = input.value.length; + }, 100) + } + } + + getItemKlass = (tc) => { + const src = this.props.editing.meta.src + const klasses = [] + + if (src && (src.id === tc.id)) klasses.push('selected') + + if (tc.status === C.TEST_CASE_STATUS.SUCCESS) klasses.push('success') + else if (tc.status === C.TEST_CASE_STATUS.ERROR) klasses.push('error') + else klasses.push('normal') + + if (this.props.status !== C.APP_STATUS.NORMAL) { + klasses.push('disabled') + } + + return klasses.join(' ') + } + + changeTestCase = (id) => { + return new Promise((resolve) => { + if (this.props.status !== C.APP_STATUS.NORMAL) return resolve(false) + if (this.props.editing.meta.src && this.props.editing.meta.src.id === id) return resolve(true) + + const { hasUnsaved } = this.props.editing.meta + const go = () => { + this.props.editTestCase(id) + resolve(true) + return Promise.resolve() + } + + if (hasUnsaved) { + return Modal.confirm({ + title: 'Unsaved changes', + content: 'Do you want to discard the unsaved changes?', + okText: 'Discard', + cancelText: 'Cancel', + onOk: go, + onCancel: () => { resolve(false) } + }) + } + + go() + }) + } + + playTestCase = (id) => { + if (this.props.status !== C.APP_STATUS.NORMAL) return + + this.changeTestCase(id) + .then(shouldPlay => { + if (!shouldPlay) return + + setTimeout(() => { + const { commands } = this.props.editing + const openTc = commands.find(tc => tc.cmd.toLowerCase() === 'open') + const { src } = this.props.editing.meta + const getTestCaseName = () => { + return src && src.name && src.name.length ? src.name : 'Untitled' + } + + this.props.playerPlay({ + title: getTestCaseName(), + extra: { + id: src && src.id + }, + mode: getPlayer().C.MODE.STRAIGHT, + startIndex: 0, + startUrl: openTc ? openTc.target : null, + resources: commands, + postDelay: this.props.player.playInterval * 1000 + }) + }, 500) + }) + } + + onReadFile = (process) => (e) => { + const files = [].slice.call(e.target.files) + if (!files || !files.length) return + + const read = (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.onload = (readerEvent) => { + try { + const text = readerEvent.target.result + const obj = process(text, file.name) + resolve({ data: obj }) + } catch (e) { + resolve({ err: e, fileName: file.name }) + } + } + + reader.readAsText(file) + }) + } + + Promise.all(files.map(read)) + .then(list => { + const doneList = list.filter(x => x.data) + const failList = list.filter(x => x.err) + + this.props.addTestCases(doneList.map(x => x.data)) + .then(({ passCount, failCount, failTcs }) => { + message.info( + [ + `${passCount} test case${passCount > 1 ? 's' : ''} imported!`, + `${failList.length + failCount} test case${(failList.length + failCount) > 1 ? 's' : ''} failed!` + ].join(', '), + 3 + ) + + failList.forEach(fail => { + this.props.addLog('error', `in parsing ${fail.fileName}: ${fail.err.message}`) + }) + + failTcs.forEach(fail => { + this.props.addLog('error', `duplicated test case name: ${fail.name}`) + }) + }) + }) + } + + onHTMLFileChange = (e) => { + // Note: clear file input, so that we can fire onFileChange when users selects the same file next time + setTimeout(() => { + this.htmlFileInput.value = null + }, 500) + return this.onReadFile(fromHtml)(e) + } + + onJSONFileChange = (e) => { + setTimeout(() => { + this.jsonFileInput.value = null + }, 500) + return this.onReadFile(fromJSONString)(e) + } + + addTestCase = () => { + const { src, hasUnsaved } = this.props.editing.meta + const go = () => { + this.props.editNewTestCase() + return Promise.resolve() + } + + if (hasUnsaved) { + return Modal.confirm({ + title: 'Unsaved changes', + content: 'Do you want to discard the unsaved changes?', + okText: 'Discard', + cancelText: 'Cancel', + onOk: go, + onCancel: () => {} + }) + } + + go() + } + + onClickTestCaseMore = (e, tc, tcIndex) => { + e.stopPropagation() + e.preventDefault() + + this.setState({ + tcContextMenu: { + x: e.clientX, + y: e.clientY, + isShown: true, + tc, + tcIndex + } + }) + } + + hideTcContextMenu = () => { + this.setState({ + tcContextMenu: { + ...this.state.tcContextMenu, + isShown: false + } + }) + } + + onTcMenuClick = ({ key }, tc, tcIndex) => { + this.hideTcContextMenu() + + switch (key) { + case 'play': { + return this.playTestCase(tc.id) + } + + case 'rename': { + this.setState({ + rename: tc.name + }) + this.toggleRenameModal(true, tc) + break + } + + case 'delete': { + const go = () => { + return this.props.removeTestCase(tc.id) + .then(() => { + message.success('successfully deleted!', 1.5) + }) + .catch(e => { + Modal.warning({ + title: 'Failed to delete', + content: e.message + }) + }) + } + + return Modal.confirm({ + title: 'Sure to delete?', + content: `Do you really want to delete "${tc.name}"?`, + okText: 'Delete', + cancelText: 'Cancel', + onOk: go, + onCancel: () => {} + }) + } + + case 'duplicate': { + return this.toggleDuplicateModal(true, tc) + } + + case 'export_html': { + return downloadTestCaseAsHTML(tc) + } + + case 'export_json': { + return downloadTestCaseAsJSON(tc) + } + } + } + + renderTestCases () { + const isEditingUntitled = !this.props.editing.meta.src + const { testCases } = this.props + + testCases.sort((a, b) => { + const nameA = a.name.toLowerCase() + const nameB = b.name.toLowerCase() + + if (nameA < nameB) return -1 + if (nameA === nameB) return 0 + return 1 + }) + + return ( +
      + {isEditingUntitled ? ( +
    • Untitled
    • + ) : null} + {testCases.map((tc, tcIndex) => ( +
    • this.changeTestCase(tc.id)} + onDoubleClick={() => this.playTestCase(tc.id)} + onContextMenu={(e) => this.onClickTestCaseMore(e, tc, tcIndex)} + > + {tc.name} + this.onClickTestCaseMore(e, tc, tcIndex)} + /> +
    • + ))} +
    + ) + } + + renderTestCaseContextMenu () { + const contextMenu = this.state.tcContextMenu + const mw = 160 + let x = contextMenu.x + window.scrollX + let y = contextMenu.y + window.scrollY + const $box = document.querySelector('.sidebar-inner') + + if ($box && y + 220 > $box.clientHeight) y -= 220 + + if (x - mw > 0) x -= mw + + const style = { + position: 'absolute', + top: y, + left: x, + display: contextMenu.isShown ? 'block' : 'none' + } + + const menuStyle = { + width: mw + 'px' + } + + return ( +
    + + this.onTcMenuClick(e, contextMenu.tc, contextMenu.tcIndex)} + style={menuStyle} + mode="vertical" + selectable={false} + > + Play + Rename.. + Duplicate.. + Export as JSON + Export as HTML (old) + + Delete + + +
    + ) + } + + renderTestCaseMenu () { + const onClickMenuItem = ({ key }) => { + switch (key) { + case 'export_all_json': { + const zip = new JSZip() + + if (this.props.testCases.length === 0) { + return message.error('No saved test cases to export', 1.5) + } + + this.props.testCases.forEach(tc => { + zip.file(`${tc.name}.json`, toJSONString({ + name: tc.name, + commands: tc.data.commands + })) + }) + + zip.generateAsync({ type: 'blob' }) + .then(function (blob) { + FileSaver.saveAs(blob, 'all_test_cases.zip'); + }) + + break + } + + case 'import': { + break + } + } + } + + return ( + + Export All (JSON) + + + { this.jsonFileInput = ref }} + style={{display: 'none'}} + /> + + + + { this.htmlFileInput = ref }} + style={{display: 'none'}} + /> + + + ) + } + + renderDuplicateModal () { + return ( + + { if (e.keyCode === 13) this.onClickDuplicate() }} + onChange={this.onChangeDuplicate} + placeholder="test case name" + ref={el => { this.inputDuplicateTestCase = el }} + /> + + ) + } + + renderRenameModal () { + return ( + + { if (e.keyCode === 13) this.onClickRename() }} + onChange={this.onChangeRename} + placeholder="test case name" + ref={el => { this.inputRenameTestCase = el }} + /> + + ) + } + + render () { + return ( +
    +
    + + + + +
    + + {this.renderTestCases()} + {this.renderTestCaseContextMenu()} + {this.renderDuplicateModal()} + {this.renderRenameModal()} +
    + ) + } +} + +export default connect( + state => ({ + status: state.status, + testCases: state.editor.testCases, + testSuites: state.editor.testSuites, + editing: state.editor.editing, + player: state.player, + config: state.config + }), + dispatch => bindActionCreators({...actions}, dispatch) +)(SidebarTestCases) diff --git a/src/containers/sidebar/test_suites.js b/src/containers/sidebar/test_suites.js new file mode 100644 index 0000000..066213d --- /dev/null +++ b/src/containers/sidebar/test_suites.js @@ -0,0 +1,598 @@ +import React from 'react' +import { connect } from 'react-redux' +import { bindActionCreators, compose } from 'redux' +import { Modal, Tabs, Icon, Select, Input, Button, Menu, Dropdown, Alert, message } from 'antd' +import ClickOutside from 'react-click-outside' +import FileSaver from 'file-saver' +import JSZip from 'jszip' + +import * as actions from '../../actions' +import * as C from '../../common/constant' +import { getPlayer } from '../../common/player' +import { setIn, updateIn, cn, formatDate, nameFactory } from '../../common/utils' +import { stringifyTestSuite, parseTestSuite, validateTestSuiteText } from '../../common/convert_suite_utils' +import EditTestSuite from '../../components/edit_test_suite' +import EditableText from '../../components/editable_text' + +const downloadTestSuite = (ts, testCases) => { + const str = stringifyTestSuite({ + name: ts.name, + cases: ts.cases + }, testCases) + const blob = new Blob([str], { type: 'text/plain;charset=utf-8' }) + + FileSaver.saveAs(blob, `suite_${ts.name}.json`) +} + +class SidebarTestSuites extends React.Component { + state = { + tsContextMenu: { + x: null, + y: null, + isShown: false + }, + + tscContextMenu: { + x: null, + y: null, + isShown: false + }, + + tsEditingNameIndex: -1, + + editTestSuiteSource: { + ts: null, + visible: false + } + } + + addTestSuite = () => { + this.props.addTestSuite({ + name: '__Untitled__', + cases: [] + }) + } + + addTestCaseToTestSuite = (ts) => { + this.props.updateTestSuite(ts.id, { + cases: ts.cases.concat({ + testCaseId: this.props.testCases[0] && this.props.testCases[0].id, + loops: 1 + }) + }) + } + + removeTestCaseFromTestSuite = (ts, index) => { + ts.cases.splice(index, 1) + + this.props.updateTestSuite(ts.id, { + cases: ts.cases, + playStatus: (function () { + const { playStatus = {} } = ts + const { doneIndices = [], errorIndices = [] } = playStatus + const updateIndex = (n) => { + if (n === undefined) return -1 + if (n === index) return -1 + if (n > index) return n - 1 + return n + } + + return { + errorIndices: errorIndices.map(updateIndex).filter(i => i !== -1), + doneIndices: doneIndices.map(updateIndex).filter(i => i !== -1) + } + })() + }) + } + + toggleTestSuiteFold = (ts) => { + this.props.updateTestSuite(ts.id, { + fold: !ts.fold + }) + } + + foldAllTestSuites = () => { + this.props.testSuites.forEach(ts => { + this.props.updateTestSuite(ts.id, { + fold: true + }) + }) + } + + onClickTestSuiteMore = (e, ts, tsIndex) => { + e.stopPropagation() + e.preventDefault() + + this.setState({ + tsContextMenu: { + x: e.clientX, + y: e.clientY, + isShown: true, + ts, + tsIndex + } + }) + } + + onClickTsTestCaseMore = (e, tc, tcIndex, ts, tsIndex) => { + e.stopPropagation() + e.preventDefault() + + this.setState({ + tscContextMenu: { + x: e.clientX, + y: e.clientY, + isShown: true, + tc, + ts, + tcIndex, + tsIndex + } + }) + } + + hideTsContextMenu = () => { + this.setState({ + tsContextMenu: { + ...this.state.tsContextMenu, + isShown: false + } + }) + } + + hideTscContextMenu = () => { + this.setState({ + tscContextMenu: { + ...this.state.tscContextMenu, + isShown: false + } + }) + } + + onTsMenuClick = ({ key }, ts, tsIndex) => { + this.hideTsContextMenu() + + switch (key) { + case 'play': + getPlayer({ name: 'testSuite' }).play({ + title: ts.name, + extra: { + id: ts.id, + name: ts.name + }, + mode: getPlayer().C.MODE.STRAIGHT, + startIndex: 0, + resources: ts.cases.map(item => ({ + id: item.testCaseId, + loops: item.loops + })) + }) + break + + case 'edit_source': + this.setState({ + editTestSuiteSource: { + ts, + visible: true + } + }) + break + + case 'rename': + this.setState({ + tsEditingNameIndex: tsIndex + }) + break + + case 'export': + downloadTestSuite(ts, this.props.testCases) + break + + case 'delete': + Modal.confirm({ + title: 'Are your sure to delete this test suite?', + okText: 'Confirm', + onOk: () => this.props.removeTestSuite(ts.id) + }) + break + } + } + + onTscMenuClick = ({ key }, tc, tcIndex, ts, tsIndex) => { + this.hideTscContextMenu() + + switch (key) { + case 'play_from_here': + getPlayer({ name: 'testSuite' }).play({ + title: ts.name, + extra: { + id: ts.id, + name: ts.name + }, + mode: getPlayer().C.MODE.STRAIGHT, + startIndex: tcIndex, + resources: ts.cases.map(item => ({ + id: item.testCaseId, + loops: item.loops + })) + }) + break + } + } + + onChangeTsName = (val, ts, tsIndex) => { + this.setState({ + tsEditingNameIndex: -1 + }) + + this.props.updateTestSuite(ts.id, { + name: val + }) + } + + onChangeTsCase = (key, val, tcIndex, ts, tsIndex) => { + this.props.updateTestSuite(ts.id, { + cases: setIn([tcIndex, key], val, ts.cases) + }) + } + + getTsTestCaseClass = (tcIndex, tsPlayStatus) => { + if (!tsPlayStatus) return '' + const { doneIndices = [], errorIndices = [], currentIndex } = tsPlayStatus + + if (tcIndex === currentIndex) { + return 'current-tc' + } else if (errorIndices.indexOf(tcIndex) !== -1) { + return 'error-tc' + } else if (doneIndices.indexOf(tcIndex) !== -1) { + return 'done-tc' + } else { + return '' + } + } + + onJSONFileChange = (e) => { + setTimeout(() => { + this.jsonFileInput.value = null + }, 500) + return this.onReadFile(str => parseTestSuite(str, this.props.testCases))(e) + } + + onReadFile = (process) => (e) => { + const files = [].slice.call(e.target.files) + if (!files || !files.length) return + + const read = (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.onload = (readerEvent) => { + try { + const text = readerEvent.target.result + const obj = process(text, file.name) + resolve({ data: obj }) + } catch (e) { + resolve({ err: e, fileName: file.name }) + } + } + + reader.readAsText(file) + }) + } + + Promise.all(files.map(read)) + .then(list => { + const doneList = list.filter(x => x.data) + const failList = list.filter(x => x.err) + + this.props.addTestSuites(doneList.map(x => x.data)) + .then(({ passCount, failCount, failTcs }) => { + message.info( + [ + `${passCount} test suite${passCount > 1 ? 's' : ''} imported!`, + `${failList.length + failCount} test suite${(failList.length + failCount) > 1 ? 's' : ''} failed!` + ].join(', '), + 3 + ) + + failList.forEach(fail => { + this.props.addLog('error', `in parsing ${fail.fileName}: ${fail.err.message}`) + }) + + failTcs.forEach(fail => { + this.props.addLog('error', `duplicated test suite name: ${fail.name}`) + }) + }) + }) + } + + onClosePlayTestSuiteTip = () => { + this.props.updateConfig({ + hidePlayTestSuiteTip: true + }) + } + + renderTestSuiteContextMenu () { + const contextMenu = this.state.tsContextMenu + const mw = 150 + let x = contextMenu.x + window.scrollX + let y = contextMenu.y + window.scrollY + + if (x - mw > 0) x -= mw + + const style = { + position: 'absolute', + top: y, + left: x, + display: contextMenu.isShown ? 'block' : 'none' + } + + const menuStyle = { + width: mw + 'px' + } + + return ( +
    + + this.onTsMenuClick(e, contextMenu.ts, contextMenu.tsIndex)} + style={menuStyle} + mode="vertical" + selectable={false} + > + Play + Edit source.. + Rename.. + Export + + Delete + + +
    + ) + } + + renderTestSuiteCaseContextMenu () { + const contextMenu = this.state.tscContextMenu + const mw = 150 + let x = contextMenu.x + window.scrollX + let y = contextMenu.y + window.scrollY + + if (x - mw > 0) x -= mw + + const style = { + position: 'absolute', + top: y, + left: x, + display: contextMenu.isShown ? 'block' : 'none' + } + + const menuStyle = { + width: mw + 'px' + } + + return ( +
    + + this.onTscMenuClick(e, contextMenu.tc, contextMenu.tcIndex, contextMenu.ts, contextMenu.tsIndex)} + style={menuStyle} + mode="vertical" + selectable={false} + > + Replay from here + + +
    + ) + } + + renderTestSuiteMenu () { + const onClickMenuItem = ({ key }) => { + switch (key) { + case 'export_all': { + const zip = new JSZip() + + if (this.props.testSuites.length === 0) { + return message.error('No saved test suites to export', 1.5) + } + + const genName = nameFactory() + + this.props.testSuites.forEach(ts => { + const name = genName(ts.name) + zip.file(`${name}.json`, stringifyTestSuite(ts, this.props.testCases)) + }) + + zip.generateAsync({ type: 'blob' }) + .then(function (blob) { + FileSaver.saveAs(blob, 'all_suites.zip'); + }); + + break + } + + case 'import': { + break + } + } + } + + return ( + + Export all (JSON) + + + { this.jsonFileInput = el }} + /> + + + ) + } + + renderEditTestSuiteSource () { + if (!this.state.editTestSuiteSource.visible) return null + const ts = this.state.editTestSuiteSource.ts + const source = stringifyTestSuite(ts, this.props.testCases) + const testCases = this.props.testCases + + return ( + validateTestSuiteText(text, testCases)} + onClose={() => this.setState({ editTestSuiteSource: { visible: false } })} + onChange={text => { + const newTestSuite = parseTestSuite(text, testCases) + + this.props.updateTestSuite(ts.id, newTestSuite) + this.setState({ editTestSuiteSource: { visible: false } }) + }} + /> + ) + } + + renderTestSuites () { + return ( +
    +
    + + + + + +
    + {!this.props.config.hidePlayTestSuiteTip && this.props.testSuites.length > 0 ? ( + + ) : null} +
      + {this.props.testSuites.map((ts, tsIndex) => ( +
    • +
      this.toggleTestSuiteFold(ts)} + onContextMenu={(e) => this.onClickTestSuiteMore(e, ts, tsIndex)} + > + + this.onChangeTsName(val, ts, tsIndex)} + isEditing={tsIndex === this.state.tsEditingNameIndex} + inputProps={{ + onClick: (e) => e.stopPropagation(), + onContextMenu: (e) => e.stopPropagation() + }} + /> + {tsIndex === this.state.tsEditingNameIndex ? null : ( + this.onClickTestSuiteMore(e, ts, tsIndex)} + /> + )} +
      + + {ts.cases.length > 0 ? ( +
        + {ts.cases.map((item, tcIndex) => ( +
      • this.onClickTsTestCaseMore(e, item, tcIndex, ts, tsIndex)} + > + this.props.editTestCase(item.testCaseId)} + /> + + this.onChangeTsCase('loops', e.target.value.trim().length === 0 ? '1' : e.target.value, tcIndex, ts, tsIndex)} + style={{ width: '45px', marginRight: '10px' }} + /> + this.removeTestCaseFromTestSuite(ts, tcIndex)} + /> +
      • + ))} +
      + ) : null} + +
      + +
      +
    • + ))} +
    +
    + ) + } + + render () { + return ( +
    + {this.renderTestSuites()} + {this.renderTestSuiteContextMenu()} + {this.renderTestSuiteCaseContextMenu()} + {this.renderEditTestSuiteSource()} +
    + ) + } +} + +export default connect( + state => ({ + status: state.status, + testCases: state.editor.testCases, + testSuites: state.editor.testSuites, + editing: state.editor.editing, + player: state.player, + config: state.config + }), + dispatch => bindActionCreators({...actions}, dispatch) +)(SidebarTestSuites) diff --git a/src/csv_editor.js b/src/csv_editor.js new file mode 100644 index 0000000..23e664f --- /dev/null +++ b/src/csv_editor.js @@ -0,0 +1,103 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { message, Button } from 'antd' +import { UnControlled as CodeMirror } from 'react-codemirror2' +import 'codemirror/lib/codemirror' +import 'codemirror/mode/javascript/javascript' +import 'codemirror/addon/edit/matchbrackets' +import 'codemirror/addon/edit/closebrackets' +import 'codemirror/lib/codemirror.css' +import 'antd/dist/antd.css' +import './csv_editor.scss' + +import { getCSVMan } from './common/csv_man' +import { parseQuery } from './common/utils' + +const csvMan = getCSVMan() +const rootEl = document.getElementById('root'); +const render = () => ReactDOM.render(, rootEl) + +class App extends React.Component { + state = { + csvFile: null, + ready: false, + sourceText: '', + sourceTextModified: '' + } + + onChangeEditSource = (editor, data, text) => { + this.setState({ + sourceTextModified: text + }) + } + + saveCSV = () => { + return csvMan.overwrite(this.state.csvFile, this.state.sourceTextModified) + .then( + () => message.success('Successfully saved'), + e => { + message.error('Error: ' + e.message) + throw e + } + ) + } + + onClickSave = () => { + return this.saveCSV() + } + + onClickSaveClose = () => { + return this.saveCSV() + .then(() => setTimeout(() => window.close(), 300)) + } + + onClickCancel = () => { + window.close() + } + + componentDidMount () { + const queryObj = parseQuery(window.location.search) + const csvFile = queryObj.csv + + if (!csvFile) return + + document.title = csvFile + ' - Kantu CSV Editor' + + csvMan.read(csvFile) + .then(text => { + this.setState({ + csvFile, + ready: true, + sourceText: text, + sourceTextModified: text + }) + }) + } + + render () { + if (!this.state.ready) return
    + + return ( +
    + { this.codeMirror = el }} + value={this.state.sourceText} + onChange={this.onChangeEditSource} + options={{ + lineNumbers: true, + matchBrackets: true, + autoCloseBrackets: true + }} + /> + +
    + + + +
    +
    + ) + } +} + +render() diff --git a/src/csv_editor.scss b/src/csv_editor.scss new file mode 100644 index 0000000..cd573f0 --- /dev/null +++ b/src/csv_editor.scss @@ -0,0 +1,36 @@ +.csv-editor { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + flex-direction: column; + + .react-codemirror2 { + flex: 1; + position: relative; + border-bottom: 1px solid #ccc; + + .CodeMirror { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + height: auto; + font-size: 13px; + } + } + + .csv-actions { + height: 60px; + line-height: 60px; + text-align: center; + background: #f0f0f0; + + button { + margin-right: 10px; + } + } +} \ No newline at end of file diff --git a/src/ext/bg.js b/src/ext/bg.js index aad3c02..47d4ed1 100644 --- a/src/ext/bg.js +++ b/src/ext/bg.js @@ -3,8 +3,10 @@ import { until, delay, setIn, pick, splitIntoTwo } from '../common/utils' import { bgInit } from '../common/ipc/ipc_bg_cs' import * as C from '../common/constant' import log from '../common/log' -import saveScreen from '../common/capture_screenshot' +import clipboard from '../common/clipboard' +import { saveScreen, saveFullScreen } from '../common/capture_screenshot' import storage from '../common/storage' +import { setFileInputFiles } from '../common/debugger' import config from '../config' const state = { @@ -27,17 +29,21 @@ const createTab = (url) => { return Ext.tabs.create({ url, active: true }) } -const activateTab = (tabId) => { +const activateTab = (tabId, focusWindow) => { return Ext.tabs.get(tabId) .then(tab => { - return Ext.windows.update(tab.windowId, { focused: true }) - .then(() => { - return Ext.tabs.update(tab.id, { active: true }) - }) + const p = focusWindow ? Ext.windows.update(tab.windowId, { focused: true }) + : Promise.resolve() + + return p.then(() => Ext.tabs.update(tab.id, { active: true })) .then(() => tab) }) } +const getTab = (tabId) => { + return Ext.tabs.get(tabId) +} + // Generate function to get ipc based on tabIdName and some error message const genGetTabIpc = (tabIdName, purpose) => () => { const tabId = state.tabIds[tabIdName] @@ -89,7 +95,7 @@ const getPlayTab = (url) => { return createOne(url) } - return activateTab(state.tabIds.toPlay) + return getTab(state.tabIds.toPlay) .then( (tab) => { if (!url) { @@ -183,12 +189,32 @@ const notifyRecordCommand = (command) => { }) } +const isTimeToBackup = () => { + return storage.get('config') + .then(config => { + const { enableAutoBackup, lastBackupActionTime, autoBackupInterval } = config + + if (!enableAutoBackup) { + return { + timeout: false, + remain: -1 + } + } + + const diff = new Date() * 1 - (lastBackupActionTime || 0) + return { + timeout: diff > autoBackupInterval * 24 * 3600000, + remain: diff + } + }) +} + const bindEvents = () => { Ext.browserAction.onClicked.addListener(() => { isUpgradeViewed() .then(isViewed => { if (isViewed) { - return activateTab(state.tabIds.panel) + return activateTab(state.tabIds.panel, true) .catch(() => { storage.get('config') .then(config => { @@ -197,7 +223,7 @@ const bindEvents = () => { }) .then(size => { size = size || { - width: 520, + width: 850, height: 775 } @@ -354,6 +380,9 @@ const onRequest = (cmd, args) => { return true + case 'PANEL_TIME_FOR_BACKUP': + return isTimeToBackup().then(obj => obj.timeout) + case 'PANEL_START_RECORDING': log('Start to record...') state.status = C.APP_STATUS.RECORDER @@ -391,23 +420,34 @@ const onRequest = (cmd, args) => { if (state.timer) clearInterval(state.timer) - return getPlayTab(args.url) - .then(tab => { - // Note: wait for tab to confirm it has loaded - return until('ipc of tab to play', () => { - return { - pass: !!state.ipcCache[tab.id], - result: state.ipcCache[tab.id] - } - }, 1000, 6000 * 10) - .then(ipc => { - return ipc.ask('SET_STATUS', { status: C.CONTENT_SCRIPT_STATUS.PLAYING }) - }) - }) - .catch(e => { - togglePlayingBadge(false) - throw e + return getPlayTab() + // Note: catch any error, and make it run 'getPlayTab(args.url)' instead + .catch(e => ({ id: -1 })) + .then(tab => { + if (!state.ipcCache[tab.id]) { + return getPlayTab(args.url) + .then(tab => ({ tab, hasOpenedUrl: true })) + } else { + return { tab, hasOpenedUrl: false } + } + }) + .then(({ tab, hasOpenedUrl }) => { + // Note: wait for tab to confirm it has loaded + return until('ipc of tab to play', () => { + return { + pass: !!state.ipcCache[tab.id], + result: state.ipcCache[tab.id] + } + }, 1000, 6000 * 10) + .then(ipc => { + const p = !hasOpenedUrl ? Promise.resolve() : ipc.ask('MARK_NO_COMMANDS_YET', {}) + return p.then(() => ipc.ask('SET_STATUS', { status: C.CONTENT_SCRIPT_STATUS.PLAYING })) }) + }) + .catch(e => { + togglePlayingBadge(false) + throw e + }) } case 'PANEL_RUN_COMMAND': { @@ -495,6 +535,12 @@ const onRequest = (cmd, args) => { .then(() => res.data) } + // Note: clear timer whenever we execute a new command, and it's not a retry + if (state.timer && retryInfo.retryCount === 0) clearInterval(state.timer) + + // Note: -1 will disable ipc timeout for 'pause' command + const ipcTimeout = args.command.cmd === 'pause' ? -1 : null + return ipc.ask('DOM_READY', {}) .then(() => ipc.ask('RUN_COMMAND', { command: { @@ -504,7 +550,7 @@ const onRequest = (cmd, args) => { retryInfo } } - })) + }, ipcTimeout)) .then(wait) }) } @@ -665,7 +711,7 @@ const onRequest = (cmd, args) => { const tabId = state.tabIds[item.type === 'record' ? 'lastRecord' : 'toPlay'] - return activateTab(tabId) + return activateTab(tabId, true) .then(() => item.ipc.ask('HIGHLIGHT_DOM', { locator: args.locator })) }) } @@ -706,7 +752,7 @@ const onRequest = (cmd, args) => { toggleInspectingBadge(false) setInspectorTabId(null, true, true) - activateTab(state.tabIds.panel) + activateTab(state.tabIds.panel, true) return getPanelTabIpc() .then(panelIpc => { @@ -894,9 +940,26 @@ const onRequest = (cmd, args) => { } case 'CS_CAPTURE_SCREENSHOT': - return activateTab(state.tabIds.toPlay) + return activateTab(state.tabIds.toPlay, true) .then(saveScreen) + case 'CS_CAPTURE_FULL_SCREENSHOT': + return activateTab(state.tabIds.toPlay, true) + .then(getPlayTabIpc) + .then(ipc => { + return saveFullScreen({ + startCapture: () => { + return ipc.ask('START_CAPTURE_FULL_SCREENSHOT', {}) + }, + endCapture: (pageInfo) => { + return ipc.ask('END_CAPTURE_FULL_SCREENSHOT', { pageInfo }) + }, + scrollPage: (offset) => { + return ipc.ask('SCROLL_PAGE', { offset }) + } + }) + }) + case 'CS_TIMEOUT_STATUS': return getPanelTabIpc() .then(ipc => ipc.ask('TIMEOUT_STATUS', args)) @@ -915,6 +978,23 @@ const onRequest = (cmd, args) => { }) } + case 'CS_SET_FILE_INPUT_FILES': { + return setFileInputFiles({ + tabId: args.sender.tab.id, + selector: args.selector, + files: args.files + }) + } + + case 'SET_CLIPBOARD': { + clipboard.set(args.value) + return true + } + + case 'GET_CLIPBOARD': { + return clipboard.get() + } + default: return 'unknown' } @@ -928,23 +1008,25 @@ const initIPC = () => { } const initOnInstalled = () => { - Ext.runtime.setUninstallURL(config.urlAfterUninstall) - - Ext.runtime.onInstalled.addListener(({ reason }) => { - switch (reason) { - case 'install': - return Ext.tabs.create({ - url: config.urlAfterInstall - }) + if (typeof process !== 'undefined' && process.env.NODE_ENV === 'production') { + Ext.runtime.setUninstallURL(config.urlAfterUninstall) + + Ext.runtime.onInstalled.addListener(({ reason }) => { + switch (reason) { + case 'install': + return Ext.tabs.create({ + url: config.urlAfterInstall + }) - case 'update': - Ext.browserAction.setBadgeText({ text: 'NEW' }) - Ext.browserAction.setBadgeBackgroundColor({ color: '#4444FF' }) - return Ext.storage.local.set({ - upgrade_not_viewed: 'not_viewed' - }) - } - }) + case 'update': + Ext.browserAction.setBadgeText({ text: 'NEW' }) + Ext.browserAction.setBadgeBackgroundColor({ color: '#4444FF' }) + return Ext.storage.local.set({ + upgrade_not_viewed: 'not_viewed' + }) + } + }) + } } const initPlayTab = () => { @@ -952,7 +1034,6 @@ const initPlayTab = () => { .then(window => { return Ext.tabs.query({ active: true, windowId: window.id }) .then(tabs => { - console.log('tabs', tabs) if (!tabs || !tabs.length) return false state.tabIds.toPlay = tabs[0].id return true @@ -964,3 +1045,5 @@ bindEvents() initIPC() initOnInstalled() initPlayTab() + +window.clip = clipboard diff --git a/src/ext/content_script.js b/src/ext/content_script.js index 7ef2efb..e2433ed 100644 --- a/src/ext/content_script.js +++ b/src/ext/content_script.js @@ -4,6 +4,7 @@ import inspector from '../common/inspector' import * as C from '../common/constant' import { setIn, updateIn, until } from '../common/utils' import { run, getElementByLocator } from '../common/command_runner' +import { captureClientAPI } from '../common/capture_screenshot' import log from '../common/log' const MASK_CLICK_FADE_TIMEOUT = 2000 @@ -377,10 +378,16 @@ const bindIPCListener = () => { case 'RUN_COMMAND': return runCommand(args.command) .catch(e => { + // Mark that there is already at least one command run + window.noCommandsYet = false + log.error(e.stack) throw e }) .then(data => { + // Mark that there is already at least one command run + window.noCommandsYet = false + if (state.playingFrame !== window) { return { data, isIFrame: true } } @@ -408,6 +415,23 @@ const bindIPCListener = () => { return true } + case 'MARK_NO_COMMANDS_YET': { + window.noCommandsYet = true + return true + } + + case 'START_CAPTURE_FULL_SCREENSHOT': { + return captureClientAPI.startCapture() + } + + case 'END_CAPTURE_FULL_SCREENSHOT': { + return captureClientAPI.endCapture(args.pageInfo) + } + + case 'SCROLL_PAGE': { + return captureClientAPI.scrollPage(args.offset) + } + default: throw new Error('cmd not supported: ' + cmd) } diff --git a/src/index.js b/src/index.js index 37e9a9f..37f0d58 100644 --- a/src/index.js +++ b/src/index.js @@ -6,22 +6,23 @@ import enUS from 'antd/lib/locale-provider/en_US' import App from './app' import { Provider, createStore, reducer } from './redux' +import { initPlayer } from './init_player' import csIpc from './common/ipc/ipc_cs' import testCaseModel, { eliminateBaseUrl, commandWithoutBaseUrl } from './models/test_case_model' +import testSuiteModel from './models/test_suite_model' import storage from './common/storage' -import { getPlayer } from './common/player' -import { delay, updateIn, pick } from './common/utils' import { fromJSONString } from './common/convert_utils' +import { parseTestSuite } from './common/convert_suite_utils' import * as C from './common/constant' import log from './common/log' -import { parseFromCSV, stringifyToCSV } from './common/csv' -import varsFactory from './common/variables' -import Interpreter from './common/interpreter' import { getCSVMan } from './common/csv_man' +import { getScreenshotMan } from './common/screenshot_man' import preTcs from './config/preinstall_macros' +import preTss from './config/preinstall_suites' import { addTestCases, setTestCases, + setTestSuites, setEditing, setPlayerState, updateConfig, @@ -34,7 +35,9 @@ import { doneInspecting, updateSelectedCommand, appendCommand, - listCSV + listCSV, + listScreenshots, + addTestSuites } from './actions' const store = createStore( @@ -67,14 +70,42 @@ const bindDB = () => { }) } - ['updating', 'creating', 'deleting'].forEach(eventName => { + const restoreTestSuites = () => { + return testSuiteModel.list() + .then(tss => { + tss.sort((a, b) => { + const aname = a.name.toLowerCase() + const bname = b.name.toLowerCase() + + if (aname < bname) return -1 + if (aname > bname) return 1 + if (aname === bname) { + return b.updateTime - a.updateTime + } + }) + + store.dispatch( + setTestSuites(tss) + ) + }) + } + + ;['updating', 'creating', 'deleting'].forEach(eventName => { testCaseModel.table.hook(eventName, () => { log('eventName', eventName) setTimeout(restoreTestCases, 50) }) }) + ;['updating', 'creating', 'deleting'].forEach(eventName => { + testSuiteModel.table.hook(eventName, () => { + log('eventName', eventName) + setTimeout(restoreTestSuites, 50) + }) + }) + restoreTestCases() + restoreTestSuites() } // Note: editing is stored in localstorage @@ -104,11 +135,19 @@ const restoreConfig = () => { showSidebar: true, playScrollElementsIntoView: true, playHighlightElements: true, - playCommandInterval: 0, + playCommandInterval: 0.3, recordNotification: true, // timeout in seconds timeoutPageLoad: 60, timeoutElement: 10, + timeoutMacro: 300, + // backup relative + enableAutoBackup: true, + autoBackupInterval: 7, + autoBackupTestCases: true, + autoBackupTestSuites: true, + autoBackupScreenshots: true, + autoBackupCSVFiles: true, ...config } store.dispatch(updateConfig(cfg)) @@ -120,356 +159,9 @@ const restoreCSV = () => { store.dispatch(listCSV()) } -class TimeTracker { - constructor () { - this.reset() - } - - reset () { - this.startTime = new Date() - } - - elapsed () { - return (new Date() - this.startTime) - } - - elapsedInSeconds () { - const diff = this.elapsed() - return (diff / 1000).toFixed(2) + 's' - } -} - -// Note: initialize the player, and listen to all events it emits -const bindPlayer = () => { - const replaceEscapedChar = (str) => { - return [ - [/\\n/g, '\n'], - [/\\t/g, '\t'] - ].reduce((prev, [reg, c]) => { - return prev.replace(reg, c) - }, str) - } - - const interpretCSVCommands = (command, index) => { - const csvMan = getCSVMan() - const { cmd, target, value } = command - - switch (cmd) { - case 'csvRead': { - return csvMan.exists(target) - .then(isExisted => { - if (!isExisted) { - throw new Error(`csv file '${target}' not exist`) - } - - return csvMan.read(target) - .then(parseFromCSV) - .then(rows => { - // Note: !LOOP starts from 1 - const index = vars.get('!LOOP') - 1 - const row = rows[index] - - if (index >= rows.length) { - throw new Error('end of csv file reached') - } - - vars.clear(/^!COL\d+$/i) - - row.forEach((data, i) => { - vars.set({ [`!COL${i + 1}`]: data }) - }) - }) - }) - .then(() => ({ - isFlowLogic: true - })) - } - - case 'csvSave': { - const csvLine = vars.get('!CSVLINE') - - if (!csvLine || !csvLine.length) { - throw new Error('No data to save to csv') - } - - return stringifyToCSV([csvLine]) - .then(newLineText => { - return csvMan.exists(target) - .then(isExisted => { - if (!isExisted) { - return csvMan.write(target, newLineText) - } - - return csvMan.read(target) - .then(originalText => { - const text = (originalText + '\n' + newLineText).replace(/\n+/g, '\n') - return csvMan.write(target, text) - }) - }) - }) - .then(() => { - vars.clear(/^!CSVLINE$/) - store.dispatch(listCSV()) - }) - .then(() => ({ - isFlowLogic: true - })) - } - - default: - return false - } - } - - const interpreter = new Interpreter({ run: interpretCSVCommands }) - const tracker = new TimeTracker() - const vars = varsFactory() - const player = getPlayer({ - prepare: (state) => { - // Each 'replay' has an independent variable scope, - // with global variables as initial scope - vars.reset() - vars.set(state.public.scope || {}, true) - - tracker.reset() - - interpreter.reset() - interpreter.preprocess(state.resources) - - return csIpc.ask('PANEL_START_PLAYING', { url: state.startUrl }) - }, - run: (command, state) => { - // Set loop in every run - vars.set({ - '!LOOP': state.loopsCursor, - '!RUNTIME': tracker.elapsedInSeconds() - }, true) - - if (command.cmd === 'open') { - command = {...command, href: state.startUrl} - } - - // Replace variables in 'target' and 'value' of commands - ;['target', 'value'].forEach(field => { - if (command[field] === undefined) return - - const opts = (command.cmd === 'storeEval' && field === 'target') || - (command.cmd === 'gotoIf' && field === 'target') || - (command.cmd === 'while' && field === 'target') - ? { withHashNotation: true } - : {} - - command = { - ...command, - [field]: vars.render( - replaceEscapedChar(command[field]), - opts - ) - } - }) - - // add timeout info to each command's extra - // Please note that we must set the timeout info at runtime for each command, - // so that timeout could be modified by some 'store' commands and affect - // the rest of commands - command = updateIn(['extra'], extra => ({ - ...(extra || {}), - timeoutPageLoad: vars.get('!TIMEOUT_PAGELOAD'), - timeoutElement: vars.get('!TIMEOUT_WAIT'), - errorIgnore: !!vars.get('!ERRORIGNORE') - }), command) - - // Note: all commands need to be run by interpreter before it is sent to bg - // so that interpreter could pick those flow logic commands and do its job - return interpreter.run(command, state.nextIndex) - .then(({ isFlowLogic, nextIndex }) => { - if (isFlowLogic) return Promise.resolve({ nextIndex }) - return csIpc.ask('PANEL_RUN_COMMAND', { command }) - }) - }, - handleResult: (result, command, state) => { - // Every command should return its window.url - if (result && result.pageUrl) { - vars.set({ '!URL': result.pageUrl }, true) - } - - if (result && result.vars) { - try { - vars.set(result.vars) - } catch (e) { - return Promise.reject(e) - } - } - - let hasError = false - - if (result && result.log) { - if (result.log.info) store.dispatch(addLog('info', result.log.info)) - if (result.log.error) { - store.dispatch(addPlayerErrorCommandIndex(state.nextIndex)) - store.dispatch(addLog('error', result.log.error)) - hasError = true - } - } - - vars.set({ '!LastCommandOK': !hasError }, true) - - if (result && result.screenshot) { - store.dispatch(addLog('info', 'a new screenshot captured')) - store.dispatch(addScreenshot(result.screenshot)) - } - - if (/^(fast|medium|slow)$/i.test(vars.get('!REPLAYSPEED'))) { - const val = vars.get('!REPLAYSPEED').toUpperCase() - player.setPostDelay(({ - FAST: 0, - MEDIUM: 300, - SLOW: 2000 - })[val]) - } - - // For those flow logic that set nextIndex directly in Interpreter.run method - if (result && result.nextIndex !== undefined) { - return Promise.resolve(result.nextIndex) - } - - // For those flow logic that has to get result from bg - // and return nextIndex in Interpreter.postRun - return interpreter.postRun(command, state.nextIndex, result) - .then((data = {}) => data.nextIndex) - } - }, { - preDelay: 0 - }) - - player.on('LOOP_RESTART', ({ currentLoop }) => { - csIpc.ask('PANEL_STOP_PLAYING', {}) - csIpc.ask('PANEL_START_PLAYING', {}) - store.dispatch(addLog('info', `Current loop: ${currentLoop}`)) - }) - - player.on('START', ({ title }) => { - log('START') - - store.dispatch(startPlaying()) - - store.dispatch(setPlayerState({ - status: C.PLAYER_STATUS.PLAYING, - nextCommandIndex: null, - errorCommandIndices: [], - doneCommandIndices: [] - })) - - store.dispatch(addLog('info', `Playing test case ${title}`)) - }) - - player.on('PAUSED', () => { - log('PAUSED') - store.dispatch(setPlayerState({ - status: C.PLAYER_STATUS.PAUSED - })) - - store.dispatch(addLog('info', `Test case paused`)) - }) - - player.on('RESUMED', () => { - log('RESUMED') - store.dispatch(setPlayerState({ - status: C.PLAYER_STATUS.PLAYING - })) - - store.dispatch(addLog('info', `Test case resumed`)) - }) - - player.on('END', (obj) => { - log('END', obj) - csIpc.ask('PANEL_STOP_PLAYING', {}) - - store.dispatch(stopPlaying()) - - store.dispatch(setPlayerState({ - status: C.PLAYER_STATUS.STOPPED, - stopReason: obj.reason, - nextCommandIndex: null, - timeoutStatus: null - })) - - const tcId = obj.extra && obj.extra.id - - switch (obj.reason) { - case player.C.END_REASON.COMPLETE: - if (tcId) store.dispatch(updateTestCasePlayStatus(tcId, C.TEST_CASE_STATUS.SUCCESS)) - message.success('Test case completed running', 1.5) - break - - case player.C.END_REASON.ERROR: - if (tcId) store.dispatch(updateTestCasePlayStatus(tcId, C.TEST_CASE_STATUS.ERROR)) - message.error('Test case encountered some error', 1.5) - break - } - - const logMsg = { - [player.C.END_REASON.COMPLETE]: 'Test case completed', - [player.C.END_REASON.ERROR]: 'Test case failed', - [player.C.END_REASON.MANUAL]: 'Test case was stopped manually' - } - - store.dispatch(addLog('info', logMsg[obj.reason] + ` (Runtime ${tracker.elapsedInSeconds()})`)) - - // Note: show in badage the play result - if (obj.reason === player.C.END_REASON.COMPLETE || - obj.reason === player.C.END_REASON.ERROR) { - csIpc.ask('PANEL_UPDATE_BADGE', { - type: 'play', - blink: 5000, - text: obj.reason === player.C.END_REASON.COMPLETE ? 'done' : 'err', - ...(obj.reason === player.C.END_REASON.COMPLETE ? {} : { color: 'orange' }) - }) - } - }) - - player.on('TO_PLAY', ({ index, currentLoop, loops, resource }) => { - log('TO_PLAYER', index) - store.dispatch(setPlayerState({ - timeoutStatus: null, - nextCommandIndex: index, - currentLoop, - loops - })) - - const triple = [resource.cmd, resource.target, resource.value] - const str = ['', ...triple, ''].join(' | ') - store.dispatch(addLog('info', `Executing: ${str}`)) - - // Note: show in badage the current command index (start from 1) - csIpc.ask('PANEL_UPDATE_BADGE', { - type: 'play', - text: '' + (index + 1) - }) - }) - - player.on('PLAYED_LIST', ({ indices }) => { - log('PLAYED_LIST', indices) - store.dispatch(setPlayerState({ - doneCommandIndices: indices - })) - }) - - player.on('ERROR', ({ errorIndex, msg }) => { - log.error(`command index: ${errorIndex}, Error: ${msg}`) - store.dispatch(addPlayerErrorCommandIndex(errorIndex)) - store.dispatch(addLog('error', msg)) - }) - - player.on('DELAY', ({ total, past }) => { - store.dispatch(setPlayerState({ - timeoutStatus: { - type: 'delay', - total, - past - } - })) - }) +const restoreScreenshots = () => { + getScreenshotMan({ baseDir: 'screenshots' }) + store.dispatch(listScreenshots()) } const bindIpcEvent = () => { @@ -525,12 +217,13 @@ const bindWindowEvents = () => { } bindDB() -bindPlayer() bindIpcEvent() bindWindowEvents() +initPlayer(store) restoreEditing() restoreConfig() restoreCSV() +restoreScreenshots() csIpc.ask('I_AM_PANEL', {}) @@ -543,11 +236,20 @@ storage.get('preinstall') const str = JSON.stringify(preTcs[key]) return fromJSONString(str, key) }) - store.dispatch(addTestCases(tcs)) - return storage.set('preinstall', 'done') + + // Note: test cases need to be save to indexed db before it reflects in store + // so it may take some time before we can preinstall test suites + setTimeout(() => { + const state = store.getState() + + const tss = preTss.map(ts => { + return parseTestSuite(JSON.stringify(ts), state.editor.testCases) + }) + store.dispatch(addTestSuites(tss)) + + return storage.set('preinstall', 'done') + }, 1000) }) render(App) - -if (module.hot) module.hot.accept('./app', () => render(App)); diff --git a/src/init_player.js b/src/init_player.js new file mode 100644 index 0000000..d41bbb0 --- /dev/null +++ b/src/init_player.js @@ -0,0 +1,727 @@ +import { message } from 'antd' +import varsFactory from './common/variables' +import Interpreter from './common/interpreter' +import { getCSVMan } from './common/csv_man' +import { parseFromCSV, stringifyToCSV } from './common/csv' +import { Player, getPlayer } from './common/player' +import csIpc from './common/ipc/ipc_cs' +import log from './common/log' +import { updateIn, setIn } from './common/utils' +import * as C from './common/constant' +import * as act from './actions' + +class TimeTracker { + constructor () { + this.reset() + } + + reset () { + this.startTime = new Date() + } + + elapsed () { + return (new Date() - this.startTime) + } + + elapsedInSeconds () { + const diff = this.elapsed() + return (diff / 1000).toFixed(2) + 's' + } +} + +class Timeout { + constructor (callback) { + this.callback = callback + } + + reset (callback) { + this.cancel() + + if (callback) { + this.callback = callback + } + + this.timer = null + this.timeout = null + this.startTime = null + } + + restart (newTimeout) { + if (!this.timeout) { + this.timeout = newTimeout + this.startTime = new Date() + this.timer = setTimeout(this.callback, this.timeout) + } else { + const past = new Date() * 1 - this.startTime * 1 + const rest = newTimeout - past + + clearTimeout(this.timer) + + if (rest < 0) return this.callback() + + this.timeout = newTimeout + this.timer = setTimeout(this.callback, rest) + } + } + + cancel () { + clearTimeout(this.timer) + } +} + +const replaceEscapedChar = (str) => { + return [ + [/\\n/g, '\n'], + [/\\t/g, '\t'] + ].reduce((prev, [reg, c]) => { + return prev.replace(reg, c) + }, str) +} + +const interpretCSVCommands = ({ store, vars }) => (command, index) => { + const csvMan = getCSVMan() + const { cmd, target, value } = command + + switch (cmd) { + case 'csvRead': { + return csvMan.exists(target) + .then(isExisted => { + if (!isExisted) { + vars.set({ '!CsvReadStatus': 'FILE_NOT_FOUND' }, true) + throw new Error(`csv file '${target}' not exist`) + } + + return csvMan.read(target) + .then(parseFromCSV) + .then(rows => { + // Note: !CsvReadLineNumber starts from 1 + const index = vars.get('!CsvReadLineNumber') - 1 + const row = rows[index] + + if (index >= rows.length) { + vars.set({ '!CsvReadStatus': 'END_OF_FILE' }, true) + throw new Error('end of csv file reached') + } else { + vars.set({ '!CsvReadStatus': 'OK' }, true) + } + + vars.clear(/^!COL\d+$/i) + + row.forEach((data, i) => { + vars.set({ [`!COL${i + 1}`]: data }) + }) + }) + }) + .then(() => ({ + isFlowLogic: true + })) + } + + case 'csvSave': { + const csvLine = vars.get('!CSVLINE') + + if (!csvLine || !csvLine.length) { + throw new Error('No data to save to csv') + } + + return stringifyToCSV([csvLine]) + .then(newLineText => { + return csvMan.exists(target) + .then(isExisted => { + if (!isExisted) { + return csvMan.write(target, newLineText) + } + + return csvMan.read(target) + .then(originalText => { + const text = (originalText + '\n' + newLineText).replace(/\n+/g, '\n') + return csvMan.write(target, text) + }) + }) + }) + .then(() => { + vars.clear(/^!CSVLINE$/) + store.dispatch(act.listCSV()) + }) + .then(() => ({ + isFlowLogic: true + })) + } + + default: + return false + } +} + +// Note: initialize the player, and listen to all events it emits +export const initPlayer = (store) => { + const vars = varsFactory() + const interpreter = new Interpreter({ run: interpretCSVCommands({vars, store}) }) + const tcPlayer = initTestCasePlayer({store, vars, interpreter}) + const tsPlayer = initTestSuitPlayer({store, tcPlayer}) +} + +const initTestCasePlayer = ({ store, vars, interpreter }) => { + const tracker = new TimeTracker() + const macroTimer = new Timeout(() => player.stopWithError(new Error(`macro timeout ${vars.get('!TIMEOUT_MACRO')}s`))) + const player = getPlayer({ + prepare: (state) => { + // Each 'replay' has an independent variable scope, + // with global variables as initial scope + vars.reset() + vars.set(state.public.scope || {}, true) + + tracker.reset() + + interpreter.reset() + interpreter.preprocess(state.resources) + + return csIpc.ask('PANEL_START_PLAYING', { url: state.startUrl }) + }, + run: (command, state) => { + const useClipboard = /!clipboard/i.test(command.target + ';' + command.value) + const prepare = !useClipboard + ? Promise.resolve({ useClipboard: false }) + : csIpc.ask('GET_CLIPBOARD').then(clipboard => ({ useClipboard: true, clipboard })) + + return prepare.then(({ useClipboard, clipboard = '' }) => { + // Set clipboard variable if it is used + if (useClipboard) { + vars.set({ '!CLIPBOARD': clipboard }) + } + + // Set loop in every run + vars.set({ + '!LOOP': state.loopsCursor, + '!RUNTIME': tracker.elapsedInSeconds() + }, true) + + if (command.cmd === 'open') { + command = {...command, href: state.startUrl} + } + + // Replace variables in 'target' and 'value' of commands + ;['target', 'value'].forEach(field => { + if (command[field] === undefined) return + + const opts = (command.cmd === 'storeEval' && field === 'target') || + (command.cmd === 'gotoIf' && field === 'target') || + (command.cmd === 'if' && field === 'target') || + (command.cmd === 'while' && field === 'target') + ? { withHashNotation: true } + : {} + + command = { + ...command, + [field]: vars.render( + replaceEscapedChar( + command.cmd === 'type' ? command[field] : command[field].trim() + ), + opts + ) + } + }) + + // add timeout info to each command's extra + // Please note that we must set the timeout info at runtime for each command, + // so that timeout could be modified by some 'store' commands and affect + // the rest of commands + command = updateIn(['extra'], extra => ({ + ...(extra || {}), + timeoutPageLoad: vars.get('!TIMEOUT_PAGELOAD'), + timeoutElement: vars.get('!TIMEOUT_WAIT'), + errorIgnore: !!vars.get('!ERRORIGNORE') + }), command) + + // Note: all commands need to be run by interpreter before it is sent to bg + // so that interpreter could pick those flow logic commands and do its job + return interpreter.run(command, state.nextIndex) + .then( + ({ isFlowLogic, nextIndex }) => { + if (isFlowLogic) return Promise.resolve({ nextIndex }) + + // Note: -1 will disable ipc timeout for 'pause' command + const timeout = command.cmd === 'pause' ? -1 : null + return csIpc.ask('PANEL_RUN_COMMAND', { command }, timeout) + }, + e => { + // Note: if variable !ERRORIGNORE is set to true, + // it will just log errors instead of a stop of whole macro + if (vars.get('!ERRORIGNORE')) { + return { + log: { + error: e.message + } + } + } + + throw e + } + ) + }) + }, + handleResult: (result, command, state) => { + const prepares = [] + + // Every command should return its window.url + if (result && result.pageUrl) { + vars.set({ '!URL': result.pageUrl }, true) + } + + if (result && result.vars) { + try { + vars.set(result.vars) + + // Note: if set value to !Clipboard, there is an async job we must get done before handleResult could return + const clipBoardKey = Object.keys(result.vars).find(key => /!clipboard/i.test(key)) + if (clipBoardKey) { + prepares.push( + csIpc.ask('SET_CLIPBOARD', { value: result.vars[clipBoardKey] }) + ) + } + + // Note: if user sets !timeout_macro to some other value, re-calculate the time left + const timeoutMacroKey = Object.keys(result.vars).find(key => /!timeout_macro/i.test(key)) + if (timeoutMacroKey) { + macroTimer.restart(result.vars[timeoutMacroKey] * 1000) + } + } catch (e) { + return Promise.reject(e) + } + } + + let hasError = false + + if (result && result.log) { + if (result.log.info) store.dispatch(act.addLog('info', result.log.info)) + if (result.log.error) { + store.dispatch(act.addPlayerErrorCommandIndex(state.nextIndex)) + store.dispatch(act.addLog('error', result.log.error)) + hasError = true + } + } + + if (command.cmd !== 'echo') { + vars.set({ '!LastCommandOK': !hasError }, true) + } + + if (result && result.screenshot) { + store.dispatch(act.addLog('info', 'a new screenshot captured')) + store.dispatch(act.addScreenshot(result.screenshot)) + } + + if (/^(fast|medium|slow)$/i.test(vars.get('!REPLAYSPEED'))) { + const val = vars.get('!REPLAYSPEED').toUpperCase() + player.setPostDelay(({ + FAST: 0, + MEDIUM: 300, + SLOW: 2000 + })[val]) + } + + // For those flow logic that set nextIndex directly in Interpreter.run method + if (result && result.nextIndex !== undefined) { + return Promise.all(prepares).then(() => result.nextIndex) + } + + // For those flow logic that has to get result from bg + // and return nextIndex in Interpreter.postRun + return Promise.all(prepares) + .then(() => interpreter.postRun(command, state.nextIndex, result)) + .then((data = {}) => data.nextIndex) + } + }, { + preDelay: 0 + }) + + player.on('LOOP_START', ({ loopsCursor }) => { + // Note: set 'csv read line number' to loops whenever a new loop starts + vars.set({ '!CsvReadLineNumber': loopsCursor }, true) + + // Note: reset macro timeout on each loop + macroTimer.reset() + macroTimer.restart(vars.get('!TIMEOUT_MACRO') * 1000) + }) + + player.on('LOOP_RESTART', ({ currentLoop, loopsCursor }) => { + csIpc.ask('PANEL_STOP_PLAYING', {}) + csIpc.ask('PANEL_START_PLAYING', {}) + store.dispatch(act.addLog('info', `Current loop: ${currentLoop}`)) + }) + + player.on('START', ({ title, loopsCursor }) => { + log('START') + + store.dispatch(act.startPlaying()) + + store.dispatch(act.setPlayerState({ + status: C.PLAYER_STATUS.PLAYING, + nextCommandIndex: null, + errorCommandIndices: [], + doneCommandIndices: [] + })) + + store.dispatch(act.addLog('info', `Playing test case ${title}`)) + }) + + player.on('PAUSED', () => { + log('PAUSED') + store.dispatch(act.setPlayerState({ + status: C.PLAYER_STATUS.PAUSED + })) + + store.dispatch(act.addLog('info', `Test case paused`)) + }) + + player.on('RESUMED', () => { + log('RESUMED') + store.dispatch(act.setPlayerState({ + status: C.PLAYER_STATUS.PLAYING + })) + + store.dispatch(act.addLog('info', `Test case resumed`)) + }) + + player.on('END', (obj) => { + log('END', obj) + + macroTimer.cancel() + + csIpc.ask('PANEL_STOP_PLAYING', {}) + + store.dispatch(act.stopPlaying()) + + store.dispatch(act.setPlayerState({ + status: C.PLAYER_STATUS.STOPPED, + stopReason: obj.reason, + nextCommandIndex: null, + timeoutStatus: null + })) + + const tcId = obj.extra && obj.extra.id + + switch (obj.reason) { + case player.C.END_REASON.COMPLETE: + if (tcId) store.dispatch(act.updateTestCasePlayStatus(tcId, C.TEST_CASE_STATUS.SUCCESS)) + message.success('Test case completed running', 1.5) + break + + case player.C.END_REASON.ERROR: + if (tcId) store.dispatch(act.updateTestCasePlayStatus(tcId, C.TEST_CASE_STATUS.ERROR)) + message.error('Test case encountered some error', 1.5) + break + } + + const logMsg = { + [player.C.END_REASON.COMPLETE]: 'Test case completed', + [player.C.END_REASON.ERROR]: 'Test case failed', + [player.C.END_REASON.MANUAL]: 'Test case was stopped manually' + } + + store.dispatch(act.addLog('info', logMsg[obj.reason] + ` (Runtime ${tracker.elapsedInSeconds()})`)) + + // Note: show in badage the play result + if (obj.reason === player.C.END_REASON.COMPLETE || + obj.reason === player.C.END_REASON.ERROR) { + csIpc.ask('PANEL_UPDATE_BADGE', { + type: 'play', + blink: 5000, + text: obj.reason === player.C.END_REASON.COMPLETE ? 'done' : 'err', + ...(obj.reason === player.C.END_REASON.COMPLETE ? {} : { color: 'orange' }) + }) + } + }) + + player.on('TO_PLAY', ({ index, currentLoop, loops, resource }) => { + log('TO_PLAY', index, resource) + store.dispatch(act.setPlayerState({ + timeoutStatus: null, + nextCommandIndex: index, + currentLoop, + loops + })) + + const triple = [resource.cmd, resource.target, resource.value] + const str = ['', ...triple, ''].join(' | ') + store.dispatch(act.addLog('info', `Executing: ${str}`)) + + // Note: show in badage the current command index (start from 1) + csIpc.ask('PANEL_UPDATE_BADGE', { + type: 'play', + text: '' + (index + 1) + }) + }) + + player.on('PLAYED_LIST', ({ indices }) => { + log('PLAYED_LIST', indices) + store.dispatch(act.setPlayerState({ + doneCommandIndices: indices + })) + }) + + player.on('ERROR', ({ errorIndex, msg }) => { + log.error(`command index: ${errorIndex}, Error: ${msg}`) + store.dispatch(act.addPlayerErrorCommandIndex(errorIndex)) + store.dispatch(act.addLog('error', msg)) + }) + + player.on('DELAY', ({ total, past }) => { + store.dispatch(act.setPlayerState({ + timeoutStatus: { + type: 'delay', + total, + past + } + })) + }) + + return player +} + +const initTestSuitPlayer = ({store, tcPlayer}) => { + const tsTracker = new TimeTracker() + const tcTracker = new TimeTracker() + let state = { + isPlaying: false, + tsId: null, + lastErrMsg: '', + testCasePromiseHandlers: null, + reports: [], + stopReason: null + + } + const setState = (st) => { + state = { + ...state, + ...st + } + } + const addReport = (report) => { + setState({ + reports: state.reports.concat(report) + }) + } + const tsPlayer = getPlayer({ + name: 'testSuite', + prepare: () => { + setState({ + isPlaying: true, + reports: [] + }) + }, + run: (testCase) => { + const tcId = testCase.id + const tcLoops = testCase.loops > 1 ? parseInt(testCase.loops, 10) : 1 + const state = store.getState() + const tcs = state.editor.testCases + const tc = tcs.find(tc => tc.id === tcId) + const openTc = tc && tc.data.commands.find(c => c.cmd.toLowerCase() === 'open') + + if (!tc) { + throw new Error('test case not exist') + } + + // update editing && start to play tcPlayer + store.dispatch(act.editTestCase(tc.id)) + store.dispatch(act.playerPlay({ + extra: { + id: tc.id, + name: tc.name + }, + mode: tcLoops === 1 ? Player.C.MODE.STRAIGHT : Player.C.MODE.LOOP, + loopsStart: 1, + loopsEnd: tcLoops, + startIndex: 0, + startUrl: openTc ? openTc.target : null, + resources: tc.data.commands, + postDelay: state.config.playCommandInterval * 1000 + })) + + return new Promise((resolve, reject) => { + setState({ + testCasePromiseHandlers: { resolve, reject } + }) + }) + }, + handleResult: (result, testCase, state) => { + // return undefined, so that player will play the next one + return Promise.resolve(undefined) + } + }, { preDelay: 0 }) + + tsPlayer.on('START', ({ title, extra }) => { + log('START SUITE') + tsTracker.reset() + + setState({ + tsId: extra.id, + isPlaying: true, + stopReason: null + }) + + store.dispatch(act.addLog('info', `Playing test suite ${title}`)) + store.dispatch(act.setPlayerMode(C.PLAYER_MODE.TEST_SUITE)) + store.dispatch(act.updateTestSuite(extra.id, (ts) => { + return { + ...ts, + playStatus: { + isPlaying: true, + currentIndex: -1, + errorIndices: [], + doneIndices: [] + } + } + })) + }) + + tsPlayer.on('PAUSED', ({ extra }) => { + log('PAUSED SUITE') + store.dispatch(act.addLog('info', `Test suite paused`)) + tcPlayer.pause() + }) + + tsPlayer.on('RESUMED', ({ extra }) => { + log('RESUMED SUIITE') + store.dispatch(act.addLog('info', `Test suite resumed`)) + tcPlayer.resume() + }) + + tsPlayer.on('TO_PLAY', ({ index, extra }) => { + tcTracker.reset() + + setState({ + lastErrMsg: '', + tcIndex: index + }) + + store.dispatch(act.updateTestSuite(extra.id, (ts) => { + return { + ...ts, + playStatus: { + ...ts.playStatus, + currentIndex: index + } + } + })) + }) + + tsPlayer.on('PLAYED_LIST', ({ indices, extra }) => { + store.dispatch(act.updateTestSuite(extra.id, (ts) => { + return { + ...ts, + playStatus: { + ...ts.playStatus, + doneIndices: indices + } + } + })) + }) + + tsPlayer.on('END', ({ reason, extra, opts }) => { + if (!state.isPlaying) return + + setState({ + isPlaying: false + }) + + // Note: reset player mode to 'test case', it will only be 'test suite' + // during replays of test suites + store.dispatch(act.setPlayerMode(C.PLAYER_MODE.TEST_CASE)) + store.dispatch(act.updateTestSuite(extra.id, (ts) => { + return { + ...ts, + playStatus: { + ...ts.playStatus, + isPlaying: false, + currentIndex: -1 + } + } + })) + + if (reason === Player.C.END_REASON.MANUAL && (!opts || !opts.tcPlayerStopped)) { + tcPlayer.stop() + } + + // Note: give it some time, in case we're stopping tc player above + setTimeout(() => { + const totalCount = state.reports.length + const failureCount = state.reports.filter(r => r.stopReason === Player.C.END_REASON.ERROR).length + const successCount = totalCount - failureCount + + const statusMap = { + [Player.C.END_REASON.MANUAL]: 'Manually stopped', + [Player.C.END_REASON.COMPLETE]: 'OK', + [Player.C.END_REASON.ERROR]: 'Error' + } + const tsStatus = statusMap[state.stopReason || reason] + const lines = [ + `Test Suite name: ${extra.name}`, + `Start Time: ${tsTracker.startTime.toString()}`, + `Overall status: ${tsStatus}, Runtime: ${tsTracker.elapsedInSeconds()}`, + `Macro run: ${totalCount}`, + `Success: ${successCount}`, + `Failure: ${failureCount}`, + `Macro executed:` + ] + + state.reports.forEach(r => { + const tcStatus = statusMap[r.stopReason] + (r.stopReason === Player.C.END_REASON.ERROR ? `: ${r.errMsg}` : '') + lines.push(`${r.name} (${tcStatus}, Runtime: ${r.usedTime})`) + }) + + store.dispatch(act.addLog('info', lines.join('\n'))) + }, 200) + }) + + // Test Case Player: we should handle cases when test case player stops automatically + tcPlayer.on('END', ({ reason, extra }) => { + if (store.getState().player.mode !== C.PLAYER_MODE.TEST_SUITE) return + + addReport({ + id: extra.id, + name: extra.name, + errMsg: state.lastErrMsg, + stopReason: reason, + usedTime: tcTracker.elapsedInSeconds() + }) + + // Avoid a 'stop' loop between tsPlayer and tcPlayer + switch (reason) { + case Player.C.END_REASON.MANUAL: + break + + case Player.C.END_REASON.COMPLETE: + state.testCasePromiseHandlers.resolve(true) + break + + case Player.C.END_REASON.ERROR: + store.dispatch(act.updateTestSuite(state.tsId, (ts) => { + return { + ...ts, + playStatus: { + ...ts.playStatus, + errorIndices: ts.playStatus.errorIndices.concat([tsPlayer.state.nextIndex]) + } + } + })) + + setState({ + stopReason: Player.C.END_REASON.ERROR + }) + + // Updated on 2017-12-15, Even if there is error, test suite should move on to next macro + // Note: tell tsPlayer not to trigger tcPlayer stop again + // tsPlayer.stop({ tcPlayerStopped: true }) + state.testCasePromiseHandlers.resolve(true) + break + } + }) + + tcPlayer.on('ERROR', ({ msg }) => { + setState({ + lastErrMsg: msg + }) + }) + + return tsPlayer +} diff --git a/src/models/db.js b/src/models/db.js index e81df80..d9915a1 100644 --- a/src/models/db.js +++ b/src/models/db.js @@ -6,6 +6,11 @@ db.version(1).stores({ testCases: 'id,name,updateTime' }) +db.version(2).stores({ + testCases: 'id,name,updateTime', + testSuites: 'id,name,updateTime' +}) + db.open(); export default db diff --git a/src/models/test_suite_model.js b/src/models/test_suite_model.js new file mode 100644 index 0000000..5c85121 --- /dev/null +++ b/src/models/test_suite_model.js @@ -0,0 +1,48 @@ +import { uid, pick, compose, on, map } from '../common/utils' +import db from './db' + +const model = { + table: db.testSuites, + list: () => { + return db.testSuites.toArray() + }, + insert: (data) => { + if (!data.name) { + throw new Error('Model TestSuite - insert: missing name') + } + + if (!Array.isArray(data.cases)) { + throw new Error('Model TestSuite - insert: cases should an array') + } + + data.updateTime = new Date() * 1 + data.id = uid() + return db.testSuites.add(data) + }, + bulkInsert: (tcs) => { + const list = tcs.map(data => { + if (!data.name) { + throw new Error('Model TestSuite - insert: missing name') + } + + if (!Array.isArray(data.cases)) { + throw new Error('Model TestSuite - insert: cases should an array') + } + + data.updateTime = new Date() * 1 + data.id = uid() + + return data + }) + + return db.testSuites.bulkAdd(list) + }, + update: (id, data) => { + return db.testSuites.update(id, data) + }, + remove: (id) => { + return db.testSuites.delete(id) + } +} + +export default model diff --git a/src/reducers/index.js b/src/reducers/index.js index dc40280..ec1c080 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -31,6 +31,7 @@ const initialState = { recorderStatus: C.RECORDER_STATUS.STOPPED, inspectorStatus: C.INSPECTOR_STATUS.STOPPED, editor: { + testSuites: [], testCases: [], editing: { ...newTestCaseEditing @@ -40,6 +41,7 @@ const initialState = { } }, player: { + mode: C.PLAYER_MODE.TEST_CASE, status: C.PLAYER_STATUS.STOPPED, stopReason: null, currentLoop: 0, @@ -256,6 +258,17 @@ export default function reducer (state = initialState, action) { case T.SET_TEST_CASES: return setIn(['editor', 'testCases'], action.data, state) + case T.SET_TEST_SUITES: + return setIn(['editor', 'testSuites'], action.data, state) + + case T.UPDATE_TEST_SUITE: { + const { id, updated } = action.data + const index = state.editor.testSuites.findIndex(ts => ts.id === id) + + if (index === -1) return state + return setIn(['editor', 'testSuites', index], updated, state) + } + case T.SET_EDITING: if (!action.data) return state return updateHasUnSaved( @@ -312,7 +325,9 @@ export default function reducer (state = initialState, action) { case T.RENAME_TEST_CASE: return setIn(['editor', 'editing', 'meta', 'src', 'name'], action.data, state) - case T.REMOVE_CURRENT_TEST_CASE: { + case T.REMOVE_TEST_CASE: { + if (!action.data.isCurrent) return state + const { id } = state.editor.editing.meta.src const { selectedIndex } = state.editor.editing.meta const candidates = state.editor.testCases.filter(tc => tc.id !== id) @@ -405,6 +420,12 @@ export default function reducer (state = initialState, action) { csvs: action.data } + case T.SET_SCREENSHOT_LIST: + return { + ...state, + screenshots: action.data + } + default: return state }