在 iRecorder (Web IDE , Web UI 自动化测试平台)中添加上传云端平台的功能
后端代码(Kotlin):
@Controller
class UiTestCaseController {
@Autowired
lateinit var uiTestCaseMapper: UiTestCaseMapper
@Autowired
lateinit var employeeMapper: EmployeeMapper
class IRecorderSideJsonData {
lateinit var name: String
lateinit var sideJson: String
lateinit var token: String
}
@RequestMapping(value = ["/uitestcase/upload.api"], method = [RequestMethod.POST])
@ResponseBody
fun upload(@RequestBody irecorderSideJsonData: IRecorderSideJsonData): Boolean {
val name = irecorderSideJsonData.name
val sideJson = irecorderSideJsonData.sideJson
val token = irecorderSideJsonData.token
println("name=${name}")
println("sideJson=${sideJson}")
println("token=${token}")
val uiTestCase: UiTestCase? = uiTestCaseMapper.findByToken(token)
try {
if (uiTestCase == null) {// 新记录
val newRecord = UiTestCase()
newRecord.gmtCreate = Date()
newRecord.gmtModified = Date()
newRecord.isDeleted = 0
newRecord.name = name
newRecord.sideJson = sideJson
newRecord.token = token
uiTestCaseMapper.insert(newRecord)
return true
} else { // 更新记录
// update 核心字段
uiTestCase.gmtModified = Date()
uiTestCase.name = name
uiTestCase.sideJson = sideJson
uiTestCaseMapper.updateByPrimaryKeyWithBLOBs(uiTestCase)
return true
}
} catch (e: Exception) {
e.printStackTrace()
}
return false
}
@RequestMapping(value = ["/uitestcase/get.api"], method = [RequestMethod.GET])
@ResponseBody
fun get(token: String): UiTestCase {
return uiTestCaseMapper.findByToken(token)
}
// http://127.0.0.1:9000/getTestCase?token=aac0a0bc-9f06-402c-b44e-ecbfa29f1b90
@RequestMapping(value = ["/getTestCase"], method = [RequestMethod.GET])
fun getTestCase(token: String, model: Model, request: HttpServletRequest, response: HttpServletResponse): String {
val loginUser = SimpleUserUtil.getBucSSOUser(request);
val empId = loginUser.empId
println("loginUser:${JSON.toJSONString(loginUser)}")
val uiTestCase = uiTestCaseMapper.findByToken(token)
println("uiTestCase===${JSON.toJSONString(uiTestCase)}")
if (uiTestCase.owner == null) { // 首次带着该 token 打开链接,回写当前打开人的工号到 owner 字段
uiTestCase.owner = empId
uiTestCaseMapper.updateByPrimaryKeyWithBLOBs(uiTestCase)
}
model.addAttribute("uiTestCase", uiTestCase)
val emp: Employee? = employeeMapper.findByWorkno(uiTestCase.owner)
var ownerText = emp?.empName
model.addAttribute("ownerText", ownerText)
val sideJson = uiTestCase.sideJson
println("sideJson=$sideJson")
val sideJsonDto = JSON.parseObject<SideJsonDto>(sideJson, SideJsonDto::class.java)
println("sideJsonDto = " + JSON.toJSONString(sideJsonDto))
model.addAttribute("sideJsonDto", sideJsonDto)
return "/testcase"
}
@RequestMapping(value = ["", "/", "/listTestCase"], method = [RequestMethod.GET])
fun listTestCase(model: Model, request: HttpServletRequest, response: HttpServletResponse): String {
val loginUser = SimpleUserUtil.getBucSSOUser(request);
val empId = loginUser.empId
println("loginUser:${JSON.toJSONString(loginUser)}")
val list = uiTestCaseMapper.listTestCase(empId)
println(JSON.toJSONString(list))
model.addAttribute("list", list)
return "/testcaselist"
}
}
前端代码(React jsx):
import browser from 'webextension-polyfill'
import {js_beautify as beautify} from 'js-beautify'
import UpgradeProject from './migrate'
import {FileTypes, migrateProject, migrateTestCase, migrateUrls, verifyFile,} from './legacy/migrate'
import TestCase from '../models/TestCase'
import UiState from '../stores/view/UiState'
import PlaybackState from '../stores/view/PlaybackState'
import ModalState from '../stores/view/ModalState'
import Selianize, {ParseError} from 'selianize'
import Manager from '../../plugin/manager'
import chromeGetFile from './filesystem/chrome'
import firefoxGetFile from './filesystem/firefox'
import {userAgent as parsedUA} from '../../common/utils'
import uuidv4 from 'uuid/v4'
export const supportedFileFormats = '.side, text/html'
export function getFile(path) {
const browserName = parsedUA.browser.name
return (() => {
if (browserName === 'Chrome') {
return chromeGetFile(path)
} else if (browserName === 'Firefox') {
return firefoxGetFile(path)
} else {
return Promise.reject(
new Error('Operation is not supported in this browser')
)
}
})().then(blob => {
return new Promise(res => {
const reader = new FileReader()
reader.addEventListener('load', () => {
res(reader.result)
})
reader.readAsDataURL(blob)
})
})
}
export function loadAsText(blob) {
return new Promise(res => {
const fileReader = new FileReader()
fileReader.onload = e => {
res(e.target.result)
}
fileReader.readAsText(blob)
})
}
export function saveProject(_project) {
const project = _project.toJS()
downloadProject(project)
UiState.saved()
}
/**
* 原生 js 的 Ajax 函数
* @type {{get: Ajax.get, post: Ajax.post}}
*/
const Ajax = {
get: function (url, fn) {
// XMLHttpRequest对象用于在后台与服务器交换数据
var xhr = new XMLHttpRequest()
xhr.open('GET', url, true)
xhr.onreadystatechange = function () {
// readyState == 4说明请求已完成
if ((xhr.readyState == 4 && xhr.status == 200) || xhr.status == 304) {
// 从服务器获得数据
fn.call(this, xhr.responseText)
}
}
xhr.send()
},
// data应为'a=a1&b=b1'这种字符串格式,在jq里如果data为对象会自动将对象转成这种字符串格式
post: function (url, data, fn) {
var xhr = new XMLHttpRequest()
xhr.open('POST', url, true)
// 添加http头,发送信息至服务器时内容编码类型
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.onreadystatechange = function () {
if (xhr.readyState == 4 && (xhr.status == 200 || xhr.status == 304)) {
fn.call(this, xhr.responseText)
}
}
xhr.send(data)
},
}
export function uploadProject(_project) {
const project = _project.toJS()
const sideJson = JSON.stringify(project)
const token = uuidv4()
const host = 'http://localhost:9000'
let data = {
name: project.name,
sideJson: sideJson,
token: token
}
console.log(`data===>${JSON.stringify(data)}`)
Ajax.post(`${host}/uitestcase/upload.api`, JSON.stringify(data), res => {
console.log(`${host}/uitestcase/upload.api result: ${res}`)
ModalState.showAlert({
title: '上传云端',
description: `上传成功,前往小U平台查看用例: ${host}/getTestCase?token=${token}`,
confirmLabel: '确定',
cancelLabel: '取消',
}).then(choseConfirm => {
console.log(`choseConfirm=${choseConfirm}`)
if (choseConfirm) {
window.open(`${host}/getTestCase?token=${token}`, '_blank')
}
})
})
}
function downloadProject(project) {
return exportProject(project).then(snapshot => {
if (snapshot) {
project.snapshot = snapshot
Object.assign(project, Manager.emitDependencies())
}
return browser.downloads.download({
filename: project.name + '.side',
url: createBlob(
'application/json',
beautify(JSON.stringify(project), {indent_size: 2})
),
saveAs: true,
conflictAction: 'overwrite',
})
})
}
function exportProject(project) {
return Manager.validatePluginExport(project).then(() => {
return Selianize(project, {
silenceErrors: true,
skipStdLibEmitting: true,
}).catch(err => {
const markdown = ParseError((err && err.message) || err)
ModalState.showAlert({
title: 'Error saving project',
description: markdown,
confirmLabel: 'Download log',
cancelLabel: 'Close',
}).then(choseDownload => {
if (choseDownload) {
browser.downloads.download({
filename: project.name + '-logs.md',
url: createBlob('text/markdown', markdown),
saveAs: true,
conflictAction: 'overwrite',
})
}
})
return Promise.reject()
})
})
}
let previousFile = null
// eslint-disable-next-line
function createBlob(mimeType, data) {
const blob = new Blob([data], {
type: 'text/plain',
})
// If we are replacing a previously generated file we need to
// manually revoke the object URL to avoid memory leaks.
if (previousFile !== null) {
window.URL.revokeObjectURL(previousFile)
}
previousFile = window.URL.createObjectURL(blob)
return previousFile
}
export function loadProject(project, file) {
function displayError(error) {
ModalState.showAlert({
title: 'Error migrating project',
description: error.message,
confirmLabel: 'Close',
})
}
loadAsText(file).then(contents => {
if (/\.side$/.test(file.name)) {
loadJSProject(project, UpgradeProject(JSON.parse(contents)))
} else {
try {
const type = verifyFile(contents)
if (type === FileTypes.Suite) {
ModalState.importSuite(contents, files => {
try {
loadJSProject(project, migrateProject(files))
} catch (error) {
displayError(error)
}
})
} else if (type === FileTypes.TestCase) {
let {test, baseUrl} = migrateTestCase(contents)
if (project.urls.length && !project.urls.includes(baseUrl)) {
ModalState.showAlert({
title: 'Migrate test case',
description: `The test case you're trying to migrate has a different base URL (${baseUrl}) than the project's one. \nIn order to migrate the test case URLs will be made absolute.`,
confirmLabel: 'Migrate',
cancelLabel: 'Discard',
}).then(choseMigration => {
if (choseMigration) {
UiState.selectTest(
project.addTestCase(
TestCase.fromJS(migrateUrls(test, baseUrl))
)
)
}
})
} else {
UiState.selectTest(
project.addTestCase(TestCase.fromJS(test, baseUrl))
)
}
}
} catch (error) {
displayError(error)
}
}
})
}
export function loadJSProject(project, data) {
UiState.changeView('Tests')
PlaybackState.clearPlayingCache()
UiState.clearViewCache()
project.fromJS(data)
UiState.projectChanged()
Manager.emitMessage({
action: 'event',
event: 'projectLoaded',
options: {
projectName: project.name,
projectId: project.id,
},
})
}
side runner 代码
#!/usr/bin/env node
// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import util from 'util'
import { fork } from 'child_process'
import program from 'commander'
import winston from 'winston'
import glob from 'glob'
import rimraf from 'rimraf'
import { js_beautify as beautify } from 'js-beautify'
import Selianize, { getUtilsFile } from 'selianize'
import Capabilities from './capabilities'
import ParseProxy from './proxy'
import Config from './config'
import Satisfies from './versioner'
import metadata from '../package.json'
const DEFAULT_TIMEOUT = 15000
process.title = metadata.name
program
.usage('[options] project.side [project.side] [*.side]')
.version(metadata.version)
.option('-c, --capabilities [list]', 'Webdriver capabilities')
.option('-s, --server [url]', 'Webdriver remote server')
.option('-p, --params [list]', 'General parameters')
.option('-f, --filter [string]', 'Run suites matching name')
.option(
'-w, --max-workers [number]',
'Maximum amount of workers that will run your tests, defaults to number of cores'
)
.option('--base-url [url]', 'Override the base URL that was set in the IDE')
.option(
'--timeout [number | undefined]',
`The maximimum amount of time, in milliseconds, to spend attempting to locate an element. (default: ${DEFAULT_TIMEOUT})`
)
.option(
'--proxy-type [type]',
'Type of proxy to use (one of: direct, manual, pac, socks, system)'
)
.option(
'--proxy-options [list]',
'Proxy options to pass, for use with manual, pac and socks proxies'
)
.option(
'--configuration-file [filepath]',
'Use specified YAML file for configuration. (default: .side.yml)'
)
.option(
'--output-directory [directory]',
'Write test results to files, results written in JSON'
)
.option(
'--force',
"Forcibly run the project, regardless of project's version"
)
.option('--debug', 'Print debug logs')
if (process.env.NODE_ENV === 'development') {
program.option(
'-e, --extract',
'Only extract the project file to code (this feature is for debugging purposes)'
)
program.option(
'-r, --run [directory]',
'Run the extracted project files (this feature is for debugging purposes)'
)
}
program.parse(process.argv)
if (!program.args.length && !program.run) {
program.outputHelp()
process.exit(1)
}
winston.cli()
winston.level = program.debug ? 'debug' : 'info'
if (program.extract || program.run) {
winston.warn(
"This feature is used by iRecorder IDE maintainers for debugging purposes, we hope you know what you're doing!"
)
}
const configuration = {
capabilities: {
browserName: 'chrome',
},
params: {},
runId: crypto.randomBytes(16).toString('hex'),
path: path.join(__dirname, '../../'),
}
const confPath = program.configurationFile || '.side.yml'
const configurationFilePath = path.isAbsolute(confPath)
? confPath
: path.join(process.cwd(), confPath)
try {
Object.assign(configuration, Config.load(configurationFilePath))
} catch (e) {
winston.debug('Could not load ' + configurationFilePath)
}
program.filter = program.filter || '*'
configuration.server = program.server ? program.server : configuration.server
configuration.timeout = program.timeout
? +program.timeout
: configuration.timeout
? +configuration.timeout
: DEFAULT_TIMEOUT // eslint-disable-line indent
if (configuration.timeout === 'undefined') configuration.timeout = undefined
if (program.capabilities) {
try {
configuration.capabilities = Capabilities.parseString(program.capabilities)
} catch (e) {
winston.debug('Failed to parse inline capabilities')
}
}
if (program.params) {
try {
configuration.params = Capabilities.parseString(program.params)
} catch (e) {
winston.debug('Failed to parse additional params')
}
}
if (program.proxyType) {
try {
let opts = program.proxyOptions
if (program.proxyType === 'manual' || program.proxyType === 'socks') {
opts = Capabilities.parseString(opts)
}
const proxy = ParseProxy(program.proxyType, opts)
Object.assign(configuration, proxy)
} catch (e) {
winston.error(e.message)
process.exit(1)
}
} else if (configuration.proxyType) {
try {
const proxy = ParseProxy(
configuration.proxyType,
configuration.proxyOptions
)
Object.assign(configuration, proxy)
} catch (e) {
winston.error(e.message)
process.exit(1)
}
}
configuration.baseUrl = program.baseUrl
? program.baseUrl
: configuration.baseUrl
winston.debug(util.inspect(configuration))
let projectPath
function runProject(project) {
winston.info(`Running ${project.path}`)
if (!program.force) {
let warning
try {
warning = Satisfies(project.version, '2.0')
} catch (e) {
return Promise.reject(e)
}
if (warning) {
winston.warn(warning)
}
} else {
winston.warn("--force is set, ignoring project's version")
}
if (!project.suites.length) {
return Promise.reject(
new Error(
`The project ${
project.name
} has no test suites defined, create a suite using the IDE.`
)
)
}
projectPath = `side-suite-${project.id}`
rimraf.sync(projectPath)
fs.mkdirSync(projectPath)
fs.writeFileSync(
path.join(projectPath, 'package.json'),
JSON.stringify(
{
name: project.name,
version: '0.0.0',
jest: {
modulePaths: [path.join(__dirname, '../node_modules')],
setupTestFrameworkScriptFile: require.resolve(
'jest-environment-selenium/dist/setup.js'
),
testEnvironment: 'jest-environment-selenium',
testEnvironmentOptions: configuration,
},
dependencies: project.dependencies || {},
},
null,
2
)
)
return Selianize(project, { silenceErrors: true }, project.snapshot).then(
code => {
const tests = code.tests
.reduce((tests, test) => {
return (tests += test.code)
}, 'const utils = require("./utils.js");const tests = {};')
.concat('module.exports = tests;')
writeJSFile(path.join(projectPath, 'commons'), tests, '.js')
writeJSFile(path.join(projectPath, 'utils'), getUtilsFile(), '.js')
code.suites.forEach(suite => {
if (!suite.tests) {
// not parallel
const cleanup = suite.persistSession
? ''
: 'beforeEach(() => {vars = {};});afterEach(async () => (cleanup()));'
writeJSFile(
path.join(projectPath, suite.name),
`// This file was generated using iRecorder (Jason Chimpanzee).\nconst tests = require("./commons.js");${
code.globalConfig
}${suite.code}${cleanup}`
)
} else if (suite.tests.length) {
fs.mkdirSync(path.join(projectPath, suite.name))
// parallel suite
suite.tests.forEach(test => {
writeJSFile(
path.join(projectPath, suite.name, test.name),
`// This file was generated using iRecorder (Jason Chimpanzee).\nconst tests = require("../commons.js");${
code.globalConfig
}${test.code}`
)
})
}
})
return new Promise((resolve, reject) => {
let npmInstall
if (project.dependencies && Object.keys(project.dependencies).length) {
npmInstall = new Promise((resolve, reject) => {
const child = fork(require.resolve('./npm'), {
cwd: path.join(process.cwd(), projectPath),
stdio: 'inherit',
})
child.on('exit', code => {
if (code) {
reject()
} else {
resolve()
}
})
})
} else {
npmInstall = Promise.resolve()
}
npmInstall
.then(() => {
if (program.extract) {
resolve()
} else {
runJest(project) // main run
.then(resolve)
.catch(reject)
}
})
.catch(reject)
})
}
)
}
/**
* run the auto generated jest test project
* @param project
* @returns {Promise}
*/
function runJest(project) {
return new Promise((resolve, reject) => {
const args = [
'--testMatch',
`{**/*${program.filter}*/*.test.js,**/*${program.filter}*.test.js}`,
]
.concat(program.maxWorkers ? ['-w', program.maxWorkers] : [])
.concat(
program.outputDirectory
? [
'--json',
'--outputFile',
path.isAbsolute(program.outputDirectory)
? path.join(program.outputDirectory, `${project.name}.json`)
: '../' +
path.join(program.outputDirectory, `${project.name}.json`),
]
: []
)
const opts = {
cwd: path.join(process.cwd(), projectPath),
stdio: 'inherit',
}
winston.debug('jest worker args')
winston.debug(args)
winston.debug('jest work opts')
winston.debug(opts)
const child = fork(require.resolve('./child'), args, opts)
child.on('exit', code => {
console.log('') // eslint-disable-line no-console
// when is not running
if (!program.run) {
// uncomment this: keep the jest project files
// rimraf.sync(projectPath) // delete the project files
}
if (code) {
reject()
} else {
resolve()
}
})
})
}
function runAll(projects, index = 0) {
if (index >= projects.length) return Promise.resolve()
return runProject(projects[index])
.then(() => {
return runAll(projects, ++index)
})
.catch(error => {
process.exitCode = 1
error && winston.error(error.message + '\n')
return runAll(projects, ++index)
})
}
function writeJSFile(name, data, postfix = '.test.js') {
fs.writeFileSync(`${name}${postfix}`, beautify(data, { indent_size: 2 }))
}
const projects = [
...program.args.reduce((projects, project) => {
glob.sync(project).forEach(p => {
projects.add(p)
})
return projects
}, new Set()),
].map(p => {
const project = JSON.parse(fs.readFileSync(p))
project.path = p
return project
})
function handleQuit(_signal, code) {
if (!program.run) {
rimraf.sync(projectPath)
}
process.exit(code)
}
process.on('SIGINT', handleQuit)
process.on('SIGTERM', handleQuit)
if (program.run) {
projectPath = program.run
runJest({
name: 'test',
}).catch(winston.error)
} else {
runAll(projects)
}
网友评论