@sprucelabs/spruce-cli
Version:
Command line interface for building Spruce skills.
482 lines (401 loc) • 14.8 kB
text/typescript
import pathUtil from 'path'
import { SpruceSchemas } from '@sprucelabs/mercury-types'
import { SchemaValues } from '@sprucelabs/schema'
import { diskUtil } from '@sprucelabs/spruce-skill-utils'
import open from 'open'
import testOptionsSchema from '#spruce/schemas/spruceCli/v2020_07_22/testOptions.schema'
import SpruceError from '../../../errors/SpruceError'
import AbstractAction from '../../AbstractAction'
import { FeatureActionResponse, ActionOptions } from '../../features.types'
import WatchFeature from '../../watch/WatchFeature'
import {
SpruceTestFile,
SpruceTestFileTest,
SpruceTestResults,
} from '../test.types'
import TestReporter, { WatchMode } from '../TestReporter'
import TestRunner from '../TestRunner'
export const optionsSchema = testOptionsSchema
export type OptionsSchema = typeof optionsSchema
type DidChangePayload =
SchemaValues<SpruceSchemas.SpruceCli.v2020_07_22.WatcherDidDetectChangesEmitPayloadSchema>
export default class TestAction extends AbstractAction<OptionsSchema> {
public optionsSchema = optionsSchema
public invocationMessage = 'Starting tests... 🛡'
private testReporter?: TestReporter | undefined
private testRunner?: TestRunner
private runnerStatus: 'hold' | 'quit' | 'run' | 'restart' = 'hold'
private pattern: string | undefined
private inspect?: number | null
private holdPromiseResolve?: () => void
private lastTestResults: SpruceTestResults = { totalTestFiles: 0 }
private originalInspect!: number
private watcher?: WatchFeature
private watchMode: WatchMode = 'off'
private isRpTraining = false
private fileChangeTimeout?: any
private hasWatchEverBeenEnabled = false
private readonly watchDelaySec = 2
public constructor(options: ActionOptions) {
super(options)
}
public async execute(
options: SchemaValues<OptionsSchema>
): Promise<FeatureActionResponse> {
if (!options.watchMode) {
const settings = this.Service('settings')
options.watchMode = settings.get('test.watchMode') ?? 'off'
}
const normalizedOptions = this.validateAndNormalizeOptions(options)
const {
pattern,
shouldReportWhileRunning,
inspect,
shouldHoldAtStart,
watchMode,
shouldReturnImmediately,
} = normalizedOptions
this.originalInspect = inspect ?? 5200
this.inspect = inspect
this.pattern = pattern
this.hasWatchEverBeenEnabled = watchMode !== 'off'
this.watchMode = watchMode as WatchMode
if (shouldReportWhileRunning) {
this.testReporter = new TestReporter({
cwd: this.cwd,
watchMode: this.watchMode,
status: shouldHoldAtStart ? 'stopped' : 'ready',
isDebugging: !!inspect,
filterPattern: pattern ?? undefined,
isRpTraining: this.isRpTraining,
handleRestart: this.handleRestart.bind(this),
handleStartStop: this.handleStartStop.bind(this),
handleQuit: this.handleQuit.bind(this),
handleRerunTestFile: this.handleRerunTestFile.bind(this),
handleOpenTestFile: this.handleOpenTestFile.bind(this),
handleFilterPatternChange:
this.handleFilterPatternChange.bind(this),
handleToggleDebug: this.handleToggleDebug.bind(this),
handletoggleStandardWatch:
this.handletoggleStandardWatch.bind(this),
handleToggleSmartWatch: this.handleToggleSmartWatch?.bind(this),
handleToggleRpTraining: this.handleToggleRpTraining?.bind(this),
})
await this.testReporter.start()
}
this.watcher = this.getFeature('watch') as WatchFeature
void this.watcher.startWatching({ delay: 0 })
await this.emitter.on(
'watcher.did-detect-change',
this.handleFileChange.bind(this)
)
this.runnerStatus = shouldHoldAtStart ? 'hold' : 'run'
const promise = this.startTestRunner(normalizedOptions)
if (shouldReturnImmediately) {
return {
meta: {
promise,
test: this,
},
}
}
void this.emitter.emit('test.reporter-did-boot', {
reporter: this,
})
const testResults = await promise
await this.watcher?.stopWatching()
await this.testReporter?.destroy()
const actionResponse: FeatureActionResponse = {
meta: { testResults },
summaryLines: [
`Test files: ${testResults.totalTestFiles}`,
`Tests: ${testResults.totalTests ?? '0'}`,
`Passed: ${testResults.totalPassed ?? '0'}`,
`Failed: ${testResults.totalFailed ?? '0'}`,
`Skipped: ${testResults.totalSkipped ?? '0'}`,
`Todo: ${testResults.totalTodo ?? '0'}`,
],
}
if (testResults.totalFailed ?? 0 > 0) {
actionResponse.errors =
this.generateErrorsFromTestResults(testResults)
}
return actionResponse
}
private handleFileChange(payload: DidChangePayload) {
if (
this.watchMode === 'off' ||
!(this.runnerStatus === 'run' || this.runnerStatus == 'hold')
) {
return
}
const { changes } = payload
let shouldRestart = false
const filesWeCareAbout: string[] = []
for (const change of changes) {
const { path, name } = change.values
if (this.doWeCareAboutThisFileChanging(path)) {
this.testReporter?.setStatusLabel(`Built file: ${name}`)
shouldRestart = true
filesWeCareAbout.push(path)
break
}
}
if (shouldRestart) {
if (this.fileChangeTimeout) {
clearTimeout(this.fileChangeTimeout)
}
this.testReporter?.startCountdownTimer(this.watchDelaySec)
this.fileChangeTimeout = setTimeout(() => {
if (this.watchMode === 'smart') {
const smartFilter =
this.generateFilterFromChangedFiles(filesWeCareAbout)
if (smartFilter.length > 0) {
this.handleFilterPatternChange(smartFilter)
} else {
this.restart()
}
} else {
this.restart()
}
}, this.watchDelaySec * 1000) as any
}
}
private generateFilterFromChangedFiles(filesWeCareAbout: string[]): string {
const filter = filesWeCareAbout
.filter((file) => file.search('test.js') > -1)
.map((file) => this.fileToFilterPattern(file))
.join(' ')
return filter
}
private doWeCareAboutThisFileChanging(path: string) {
const ext = pathUtil.extname(path)
if (
path.search('testDirsAndFiles') > -1 ||
path.search('.change_cache') > -1
) {
return false
}
if (ext === '.js') {
return true
}
return false
}
private handleToggleDebug() {
if (this.inspect) {
this.inspect = undefined
} else {
this.inspect = this.originalInspect
}
this.testReporter?.setIsDebugging(!!this.inspect)
this.restart()
}
private handletoggleStandardWatch() {
if (this.watchMode === 'standard') {
this.testReporter?.setWatchMode('off')
} else {
this.setWatchMode('standard')
}
}
private handleToggleSmartWatch() {
if (this.watchMode === 'smart') {
this.setWatchMode('off')
} else {
this.setWatchMode('smart')
}
}
private async handleToggleRpTraining() {
// this.isRpTraining = !this.isRpTraining
// this.testReporter?.setIsRpTraining(this.isRpTraining)
// if (this.isRpTraining) {
await this.testReporter?.askForTrainingToken()
// }
}
public setWatchMode(mode: WatchMode) {
this.watchMode = mode
this.testReporter?.setWatchMode(mode)
this.hasWatchEverBeenEnabled = true
}
private restart() {
this.runnerStatus = 'restart'
this.kill()
}
private handleQuit() {
this.runnerStatus = 'quit'
this.kill()
}
private handleRerunTestFile(file: string) {
const name = this.fileToFilterPattern(file)
this.testReporter?.setFilterPattern(name)
this.handleFilterPatternChange(name)
}
private fileToFilterPattern(file: string) {
const filename = pathUtil
.basename(file, '.ts')
.replace('.tsx', '')
.replace('.js', '')
const dirname = pathUtil.dirname(file).split(pathUtil.sep).pop() ?? ''
const name = pathUtil.join(dirname, filename)
return name
}
private handleFilterPatternChange(filterPattern?: string) {
this.pattern = filterPattern
this.testReporter?.setFilterPattern(filterPattern)
this.restart()
}
private handleStartStop() {
if (this.runnerStatus === 'hold') {
this.runnerStatus = 'run'
this.holdPromiseResolve?.()
this.holdPromiseResolve = undefined
} else if (this.runnerStatus === 'run') {
this.runnerStatus = 'hold'
this.kill()
}
}
private handleRestart() {
this.restart()
}
public kill() {
this.testRunner?.kill()
this.holdPromiseResolve?.()
this.holdPromiseResolve = undefined
}
private async handleOpenTestFile(fileName: string) {
await this.openTestFile(fileName)
}
private async startTestRunner(
options: SchemaValues<OptionsSchema>
): Promise<SpruceTestResults> {
if (this.runnerStatus === 'hold') {
await this.waitForStart()
}
if (this.runnerStatus === 'quit') {
return this.lastTestResults
}
this.testReporter?.setStatus('ready')
this.testReporter?.stopCountdownTimer()
this.testRunner = new TestRunner({
cwd: this.cwd,
commandService: this.Service('command'),
})
let firstUpdate = true
if (this.testReporter) {
await this.testRunner.on('did-update', (payload) => {
if (firstUpdate) {
firstUpdate = false
this.testReporter?.setStatus('running')
this.testReporter?.reset()
}
if (
this.watchMode === 'smart' &&
payload.results.totalFailed > 0
) {
const failed = payload.results.testFiles.find(
(file: any) => file.status === 'failed'
)
if (failed) {
const pattern = this.fileToFilterPattern(failed.path)
if (this.pattern !== pattern) {
this.handleFilterPatternChange(pattern)
}
return
}
}
this.testReporter?.updateResults(payload.results)
this.testReporter?.render()
})
await this.testRunner.on('did-error', (payload) => {
this.testReporter?.appendError(payload.message)
this.testReporter?.render()
})
}
this.runnerStatus = 'run'
let testResults: SpruceTestResults = await this.testRunner.run({
pattern: this.pattern,
debugPort: this.inspect,
})
if (
//@ts-ignore
this.runnerStatus !== 'restart' &&
(!options.shouldReportWhileRunning ||
!this.hasWatchEverBeenEnabled ||
(this.runnerStatus as any) === 'quit')
) {
return testResults
}
if (
this.runnerStatus === 'run' &&
this.watchMode === 'smart' &&
this.testRunner?.hasFailedTests() === false &&
!this.testRunner?.hasSkippedTests() &&
(this.pattern ?? []).length > 0
) {
this.testReporter?.setStatusLabel('Restarting...')
this.runnerStatus = 'restart'
this.testReporter?.startCountdownTimer(3)
return await new Promise((resolve) => {
setTimeout(() => {
this.pattern = ''
this.testReporter?.setFilterPattern('')
resolve(this.startTestRunner(options))
}, 3000)
})
}
if (this.runnerStatus === 'run') {
this.runnerStatus = 'hold'
}
this.testReporter?.setStatus('stopped')
this.lastTestResults = testResults
return this.startTestRunner(options)
}
public async waitForStart() {
await new Promise((resolve: any) => {
this.runnerStatus = 'hold'
this.holdPromiseResolve = resolve
})
}
private async openTestFile(fileName: string): Promise<void> {
const path = diskUtil.resolvePath(
this.cwd,
'src',
'__tests__',
fileName
)
await open(path)
}
private generateErrorsFromTestResults(testResults: SpruceTestResults) {
const errors: SpruceError[] = []
testResults.testFiles?.forEach((file) => {
file.tests?.forEach((test) => {
test.errorMessages?.forEach((message) => {
const err = this.mapErrorResultToSpruceError(
test,
file,
message
)
errors.push(err)
})
})
})
if (errors.length > 0) {
return errors
}
return undefined
}
private mapErrorResultToSpruceError(
test: SpruceTestFileTest,
file: SpruceTestFile,
message: string
) {
return new SpruceError({
code: 'TEST_FAILED',
testName: test.name,
fileName: file.path,
errorMessage: message,
})
}
public getWatchMode() {
return this.watchMode
}
}