remix-ide
Version:
Extendable Web IDE for Ethereum
561 lines (520 loc) • 21.4 kB
JavaScript
var yo = require('yo-yo')
var async = require('async')
var tooltip = require('../ui/tooltip')
var css = require('./styles/test-tab-styles')
var remixTests = require('remix-tests')
import { ViewPlugin } from '@remixproject/engine'
import { canUseWorker, urlFromVersion } from '../compiler/compiler-utils'
const TestTabLogic = require('./testTab/testTab')
const profile = {
name: 'solidityUnitTesting',
displayName: 'Solidity unit testing',
methods: ['testFromPath', 'testFromSource'],
events: [],
icon: 'assets/img/unitTesting.webp',
description: 'Fast tool to generate unit tests for your contracts',
location: 'sidePanel',
documentation: 'https://remix-ide.readthedocs.io/en/latest/unittesting.html'
}
module.exports = class TestTab extends ViewPlugin {
constructor (fileManager, filePanel, compileTab, appManager, renderer) {
super(profile)
this.compileTab = compileTab
this._view = { el: null }
this.fileManager = fileManager
this.filePanel = filePanel
this.data = {}
this.appManager = appManager
this.renderer = renderer
this.hasBeenStopped = false
this.runningTestsNumber = 0
this.readyTestsNumber = 0
this.areTestsRunning = false
this.defaultPath = 'browser/tests'
appManager.event.on('activate', (name) => {
if (name === 'solidity') this.updateRunAction()
})
appManager.event.on('deactivate', (name) => {
if (name === 'solidity') this.updateRunAction()
})
}
onActivationInternal () {
this.testTabLogic = new TestTabLogic(this.fileManager)
this.listenToEvents()
}
listenToEvents () {
this.filePanel.event.register('newTestFileCreated', file => {
var testList = this.view.querySelector("[class^='testList']")
var test = this.createSingleTest(file)
testList.appendChild(test)
this.data.allTests.push(file)
this.data.selectedTests.push(file)
})
this.fileManager.events.on('noFileSelected', () => {
})
this.fileManager.events.on('currentFileChanged', (file, provider) => this.updateForNewCurrent(file))
}
updateForNewCurrent (file) {
this.updateGenerateFileAction(file)
if (!this.areTestsRunning) this.updateRunAction(file)
this.testTabLogic.getTests((error, tests) => {
if (error) return tooltip(error)
this.data.allTests = tests
this.data.selectedTests = [...this.data.allTests]
this.updateTestFileList(tests)
if (!this.testsOutput) return
})
}
createSingleTest (testFile) {
return yo`
<div class="d-flex align-items-center py-1">
<input class="singleTest" id="singleTest${testFile}" onchange=${(e) => this.toggleCheckbox(e.target.checked, testFile)} type="checkbox" checked="true">
<label class="singleTestLabel text-nowrap pl-2 mb-0" for="singleTest${testFile}">${testFile}</label>
</div>
`
}
listTests () {
if (!this.data.allTests) return []
return this.data.allTests.map(
testFile => this.createSingleTest(testFile)
)
}
toggleCheckbox (eChecked, test) {
if (!this.data.selectedTests) {
this.data.selectedTests = this._view.el.querySelectorAll('.singleTest:checked')
}
let selectedTests = this.data.selectedTests
selectedTests = eChecked ? [...selectedTests, test] : selectedTests.filter(el => el !== test)
this.data.selectedTests = selectedTests
let checkAll = this._view.el.querySelector('[id="checkAllTests"]')
const runBtn = document.getElementById('runTestsTabRunAction')
if (eChecked) {
checkAll.checked = true
if ((this.readyTestsNumber === this.runningTestsNumber || this.hasBeenStopped) && document.getElementById('runTestsTabStopAction').innerText === 'Stop') {
runBtn.removeAttribute('disabled')
runBtn.setAttribute('title', 'Run tests')
}
} else if (!selectedTests.length) {
checkAll.checked = false
runBtn.setAttribute('disabled', 'disabled')
runBtn.setAttribute('title', 'No test file selected')
}
}
checkAll (event) {
const checkBoxes = this._view.el.querySelectorAll('.singleTest')
const checkboxesLabels = this._view.el.querySelectorAll('.singleTestLabel')
// checks/unchecks all
for (let i = 0; i < checkBoxes.length; i++) {
checkBoxes[i].checked = event.target.checked
this.toggleCheckbox(event.target.checked, checkboxesLabels[i].innerText)
}
}
testCallback (result) {
this.testsOutput.hidden = false
if (result.type === 'contract') {
this.testSuite = result.value
if (this.testSuites) {
this.testSuites.push(this.testSuite)
} else {
this.testSuites = [this.testSuite]
}
this.rawFileName = result.filename
this.runningTestFileName = this.cleanFileName(this.rawFileName, this.testSuite)
this.outputHeader = yo`
<div id="${this.runningTestFileName}" data-id="testTabSolidityUnitTestsOutputheader" class="pt-1">
<span class="font-weight-bold">${this.testSuite} (${this.rawFileName})</span>
</div>
`
this.testsOutput.appendChild(this.outputHeader)
} else if (result.type === 'testPass') {
this.testsOutput.appendChild(yo`
<div id="${this.runningTestFileName}" data-id="testTabSolidityUnitTestsOutputheader" class="${css.testPass} ${css.testLog} text-success border-0">
✓ ${result.value}
</div>
`)
} else if (result.type === 'testFailure') {
this.testsOutput.appendChild(yo`
<div class="${css.testFailure} ${css.testLog} d-flex flex-column text-danger border-0" id="UTContext${result.context}">
<span> ✘ ${result.value}</span>
<span>"${result.errMsg}"</span>
</div>
`)
}
}
resultsCallback (_err, result, cb) {
// total stats for the test
// result.passingNum
// result.failureNum
// result.timePassed
cb()
}
cleanFileName (fileName, testSuite) {
return fileName ? fileName.replace(/\//g, '_').replace(/\./g, '_') + testSuite : fileName
}
setHeader (status) {
if (status) {
const label = yo`
<div
class="alert-success d-inline-block mb-1 mr-1 p-1 passed_${this.runningTestFileName}"
title="All contract tests passed"
>
PASS
</div>
`
this.outputHeader && yo.update(this.outputHeader, yo`
<div id="${this.runningTestFileName}" data-id="testTabSolidityUnitTestsOutputheader" class="pt-1">
${label} <span class="font-weight-bold">${this.testSuite} (${this.rawFileName})</span>
</div>
`)
} else {
const label = yo`
<div
class="alert-danger d-inline-block mb-1 mr-1 p-1 failed_${this.runningTestFileName}"
title="At least one contract test failed"
>
FAIL
</div>
`
this.outputHeader && yo.update(this.outputHeader, yo`
<div id="${this.runningTestFileName}" data-id="testTabSolidityUnitTestsOutputheader" class="pt-1">
${label} <span class="font-weight-bold">${this.testSuite} (${this.rawFileName})</span>
</div>
`)
}
}
updateFinalResult (_errors, result, filename) {
++this.readyTestsNumber
this.testsOutput.hidden = false
if (!result && (_errors || _errors.errors || Array.isArray(_errors) && (_errors[0].message || _errors[0].formattedMessage))) {
this.testCallback({ type: 'contract', filename })
this.setHeader(false)
}
if (_errors && _errors.errors) {
_errors.errors.forEach((err) => this.renderer.error(err.formattedMessage || err.message, this.testsOutput, {type: err.severity}))
} else if (_errors && Array.isArray(_errors) && (_errors[0].message || _errors[0].formattedMessage)) {
_errors.forEach((err) => this.renderer.error(err.formattedMessage || err.message, this.testsOutput, {type: err.severity}))
} else if (_errors && !_errors.errors && !Array.isArray(_errors)) {
// To track error like this: https://github.com/ethereum/remix/pull/1438
this.renderer.error(_errors.formattedMessage || _errors.message, this.testsOutput, {type: 'error'})
}
yo.update(this.resultStatistics, this.createResultLabel())
if (result) {
const totalTime = parseFloat(result.totalTime).toFixed(2)
if (result.totalPassing > 0 && result.totalFailing > 0) {
this.testsOutput.appendChild(yo`
<div class="d-flex alert-secondary mb-3 p-3 flex-column">
<span class="font-weight-bold">Result for ${filename}</span>
<span class="text-success">Passing: ${result.totalPassing}</span>
<span class="text-danger">Failing: ${result.totalFailing}</span>
<span>Total time: ${totalTime}s</span>
</div>
`)
} else if (result.totalPassing > 0 && result.totalFailing <= 0) {
this.testsOutput.appendChild(yo`
<div class="d-flex alert-secondary mb-3 p-3 flex-column">
<span class="font-weight-bold">Result for ${filename}</span>
<span class="text-success">Passing: ${result.totalPassing}</span>
<span>Total time: ${totalTime}s</span>
</div>
`)
} else if (result.totalPassing <= 0 && result.totalFailing > 0) {
this.testsOutput.appendChild(yo`
<div class="d-flex alert-secondary mb-3 p-3 flex-column">
<span class="font-weight-bold">Result for ${filename}</span>
<span class="text-danger">Failing: ${result.totalFailing}</span>
<span>Total time: ${totalTime}s</span>
</div>
`)
}
// fix for displaying right label for multiple tests (testsuites) in a single file
this.testSuites.forEach(testSuite => {
this.testSuite = testSuite
this.runningTestFileName = this.cleanFileName(filename, this.testSuite)
this.outputHeader = document.querySelector(`#${this.runningTestFileName}`)
this.setHeader(true)
})
result.errors.forEach((error, index) => {
this.testSuite = error.context
this.runningTestFileName = this.cleanFileName(filename, error.context)
this.outputHeader = document.querySelector(`#${this.runningTestFileName}`)
const isFailingLabel = document.querySelector(`.failed_${this.runningTestFileName}`)
if (!isFailingLabel) this.setHeader(false)
})
this.testsOutput.appendChild(yo`
<div>
<p class="text-info mb-2 border-top m-0"></p>
</div>
`)
}
if (this.hasBeenStopped && (this.readyTestsNumber !== this.runningTestsNumber)) {
// if all tests has been through before stopping no need to print this.
this.testsExecutionStopped.hidden = false
}
if (_errors) this.testsExecutionStoppedError.hidden = false
if (_errors || this.hasBeenStopped || this.readyTestsNumber === this.runningTestsNumber) {
// All tests are ready or the operation has been canceled or there was a compilation error in one of the test files.
const stopBtn = document.getElementById('runTestsTabStopAction')
stopBtn.setAttribute('disabled', 'disabled')
const stopBtnLabel = document.getElementById('runTestsTabStopActionLabel')
stopBtnLabel.innerText = 'Stop'
if (this.data.selectedTests.length !== 0) {
const runBtn = document.getElementById('runTestsTabRunAction')
runBtn.removeAttribute('disabled')
}
this.areTestsRunning = false
}
}
async testFromPath (path) {
const fileContent = await this.fileManager.readFile(path)
return this.testFromSource(fileContent, path)
}
/*
Test is not associated with the UI
*/
testFromSource (content, path = 'browser/unit_test.sol') {
return new Promise((resolve, reject) => {
let runningTest = {}
runningTest[path] = { content }
const {currentVersion, evmVersion, optimize} = this.compileTab.getCurrentCompilerConfig()
const currentCompilerUrl = urlFromVersion(currentVersion)
const compilerConfig = {
currentCompilerUrl,
evmVersion,
optimize,
usingWorker: canUseWorker(currentVersion)
}
remixTests.runTestSources(runningTest, compilerConfig, () => {}, () => {}, (error, result) => {
if (error) return reject(error)
resolve(result)
}, (url, cb) => {
return this.compileTab.compileTabLogic.importFileCb(url, cb)
})
})
}
runTest (testFilePath, callback) {
if (this.hasBeenStopped) {
this.updateFinalResult()
return
}
this.resultStatistics.hidden = false
this.fileManager.readFile(testFilePath).then((content) => {
const runningTest = {}
runningTest[testFilePath] = { content }
const {currentVersion, evmVersion, optimize} = this.compileTab.getCurrentCompilerConfig()
const currentCompilerUrl = urlFromVersion(currentVersion)
const compilerConfig = {
currentCompilerUrl,
evmVersion,
optimize,
usingWorker: canUseWorker(currentVersion)
}
remixTests.runTestSources(
runningTest,
compilerConfig,
(result) => this.testCallback(result),
(_err, result, cb) => this.resultsCallback(_err, result, cb),
(error, result) => {
this.updateFinalResult(error, result, testFilePath)
callback(error)
}, (url, cb) => {
return this.compileTab.compileTabLogic.importFileCb(url, cb)
}
)
}).catch((error) => {
if (error) return
})
}
updateCurrentPath (e) {
const newValue = e.target.value === '' ? this.defaultPath : e.target.value
this.testTabLogic.setCurrentPath(newValue)
this.updateRunAction()
this.updateForNewCurrent()
}
runTests () {
this.areTestsRunning = true
this.hasBeenStopped = false
this.readyTestsNumber = 0
this.runningTestsNumber = this.data.selectedTests.length
yo.update(this.resultStatistics, this.createResultLabel())
const stopBtn = document.getElementById('runTestsTabStopAction')
stopBtn.removeAttribute('disabled')
const runBtn = document.getElementById('runTestsTabRunAction')
runBtn.setAttribute('disabled', 'disabled')
this.call('editor', 'clearAnnotations')
this.testsOutput.innerHTML = ''
this.testsOutput.hidden = true
this.testsExecutionStopped.hidden = true
this.testsExecutionStoppedError.hidden = true
const tests = this.data.selectedTests
if (!tests) return
this.resultStatistics.hidden = tests.length === 0
async.eachOfSeries(tests, (value, key, callback) => {
if (this.hasBeenStopped) return
this.runTest(value, callback)
})
}
stopTests () {
this.hasBeenStopped = true
const stopBtnLabel = document.getElementById('runTestsTabStopActionLabel')
stopBtnLabel.innerText = 'Stopping'
const stopBtn = document.getElementById('runTestsTabStopAction')
stopBtn.setAttribute('disabled', 'disabled')
const runBtn = document.getElementById('runTestsTabRunAction')
runBtn.setAttribute('disabled', 'disabled')
}
updateGenerateFileAction (currentFile) {
let el = yo`
<button
class="btn border w-50"
data-id="testTabGenerateTestFile"
title="Generate sample test file."
onclick="${this.testTabLogic.generateTestFile.bind(this.testTabLogic)}"
>
Generate
</button>
`
if (!this.generateFileActionElement) {
this.generateFileActionElement = el
} else {
yo.update(this.generateFileActionElement, el)
}
return this.generateFileActionElement
}
updateRunAction (currentFile) {
let el = yo`
<button id="runTestsTabRunAction" title="Run tests" data-id="testTabRunTestsTabRunAction" class="w-50 btn btn-primary" onclick="${() => this.runTests()}">
<span class="fas fa-play ml-2"></span>
<label class="${css.labelOnBtn} btn btn-primary p-1 ml-2 m-0">Run</label>
</button>
`
const isSolidityActive = this.appManager.isActive('solidity')
if (!isSolidityActive || !this.listTests().length) {
el.setAttribute('disabled', 'disabled')
if (!currentFile || (currentFile && currentFile.split('.').pop().toLowerCase() !== 'sol')) {
el.setAttribute('title', 'No solidity file selected')
} else {
el.setAttribute('title', 'The "Solidity Plugin" should be activated')
// @todo(#2747) we can activate the plugin here
}
}
if (!this.runActionElement) {
this.runActionElement = el
} else {
yo.update(this.runActionElement, el)
}
return this.runActionElement
}
updateStopAction () {
return yo`
<button id="runTestsTabStopAction" data-id="testTabRunTestsTabStopAction" class="w-50 pl-2 ml-2 btn btn-secondary" disabled="disabled" title="Stop running tests" onclick=${() => this.stopTests()}">
<span class="fas fa-stop ml-2"></span>
<label class="${css.labelOnBtn} btn btn-secondary p-1 ml-2 m-0" id="runTestsTabStopActionLabel">Stop</label>
</button>
`
}
updateTestFileList (tests) {
const testsMessage = (tests && tests.length ? this.listTests() : 'No test file available')
let el = yo`<div class="${css.testList} py-2 mt-0 border-bottom">${testsMessage}</div>`
if (!this.testFilesListElement) {
this.testFilesListElement = el
} else {
yo.update(this.testFilesListElement, el)
}
this.updateRunAction()
return this.testFilesListElement
}
selectAll () {
return yo`
<div class="d-flex align-items-center mx-3 pb-2 mt-2 border-bottom">
<input id="checkAllTests"
type="checkbox"
data-id="testTabCheckAllTests"
onclick="${(event) => { this.checkAll(event) }}"
checked="true"
>
<label class="text-nowrap pl-2 mb-0" for="checkAllTests"> Select all </label>
</div>
`
}
infoButton () {
return yo`
<a class="btn border text-decoration-none pr-0 d-flex w-50 ml-2" title="Check out documentation." target="__blank" href="https://remix-ide.readthedocs.io/en/latest/unittesting.html#generate-test-file">
<label class="btn p-1 ml-2 m-0">How to use...</label>
</a>
`
}
createResultLabel () {
if (!this.data.selectedTests) return yo`<span></span>`
const ready = this.readyTestsNumber ? `${this.readyTestsNumber}` : '0'
return yo`<span class='text-info h6'>Progress: ${ready} finished (of ${this.runningTestsNumber})</span>`
}
updateDirList () {
for (var o of this.uiPathList.querySelectorAll('option')) o.remove()
this.uiPathList.appendChild(yo`<option>browser</option>`)
if (this.testTabLogic.isRemixDActive()) this.uiPathList.appendChild(yo`<option>localhost</option>`)
if (!this._view.el) return
this.testTabLogic.dirList(this.inputPath.value).then((options) => {
options.forEach((path) => this.uiPathList.appendChild(yo`<option>${path}</option>`))
})
}
render () {
this.onActivationInternal()
this.testsOutput = yo`<div class="mx-3 mb-2 pb-4 border-top border-primary" hidden='true' id="solidityUnittestsOutput" data-id="testTabSolidityUnitTestsOutput"></a>`
this.testsExecutionStopped = yo`<label class="text-warning h6" data-id="testTabTestsExecutionStopped">The test execution has been stopped</label>`
this.testsExecutionStoppedError = yo`<label class="text-danger h6" data-id="testTabTestsExecutionStoppedError">The test execution has been stopped because of error(s) in your test file</label>`
this.uiPathList = yo`<datalist id="utPathList"></datalist>`
this.inputPath = yo`<input
placeholder=${this.defaultPath}
list="utPathList"
class="custom-select"
id="utPath"
data-id="uiPathInput"
name="utPath"
style="background-image: var(--primary);"
onkeydown=${(e) => { if (e.keyCode === 191) this.updateDirList() }}
onchange=${(e) => this.updateCurrentPath(e)}/>`
const availablePaths = yo`
<div>
${this.inputPath}
${this.uiPathList}
</div>
`
this.updateDirList()
this.testsExecutionStopped.hidden = true
this.testsExecutionStoppedError.hidden = true
this.resultStatistics = this.createResultLabel()
this.resultStatistics.hidden = true
const el = yo`
<div class="${css.testTabView} px-2" id="testView">
<div class="${css.infoBox}">
<p class="text-lg"> Test your smart contract in Solidity.</p>
<p> Select directory to load and generate test files.</p>
<label>Test directory:</label>
${availablePaths}
</div>
<div class="${css.tests}">
<div class="d-flex p-2">
${this.updateGenerateFileAction()}
${this.infoButton()}
</div>
<div class="d-flex p-2">
${this.updateRunAction()}
${this.updateStopAction()}
</div>
${this.selectAll()}
${this.updateTestFileList()}
<div class="align-items-start flex-column mt-2 mx-3 mb-0">
${this.resultStatistics}
${this.testsExecutionStopped}
${this.testsExecutionStoppedError}
</div>
${this.testsOutput}
</div>
</div>
`
this._view.el = el
this.testTabLogic.setCurrentPath(this.defaultPath)
this.updateForNewCurrent(this.fileManager.currentFile())
return el
}
}