UNPKG

@sprucelabs/spruce-cli

Version:

Command line interface for building Spruce skills.

884 lines (762 loc) 27.3 kB
import chalk from 'chalk' import durationUtil from '../../utilities/duration.utility' import { ButtonWidget } from '../../widgets/types/button.types' import { InputWidget } from '../../widgets/types/input.types' import { LayoutWidget } from '../../widgets/types/layout.types' import { MenuBarWidget } from '../../widgets/types/menuBar.types' import { PopupWidget } from '../../widgets/types/popup.types' import { ProgressBarWidget } from '../../widgets/types/progressBar.types' import { TextWidget } from '../../widgets/types/text.types' import { WindowWidget } from '../../widgets/types/window.types' import WidgetFactory from '../../widgets/WidgetFactory' import { SpruceTestResults, TestRunnerStatus } from './test.types' import TestLogItemGenerator from './TestLogItemGenerator' export default class TestReporter { private started = false private table?: any private bar!: ProgressBarWidget private bottomLayout!: LayoutWidget private testLog!: TextWidget private errorLog?: TextWidget private errorLogItemGenerator: TestLogItemGenerator private lastResults: TestReporterResults = { totalTestFiles: 0, customErrors: [], } private updateInterval?: any private menu!: MenuBarWidget private statusBar!: TextWidget private window!: WindowWidget private widgets: WidgetFactory private selectTestPopup?: PopupWidget private topLayout!: LayoutWidget private filterInput!: InputWidget private filterPattern?: string private clearFilterPatternButton!: ButtonWidget private isDebugging = false private watchMode: WatchMode = 'off' private status: TestRunnerStatus = 'ready' private countDownTimeInterval?: any private cwd: string | undefined private orientation: TestReporterOrientation = 'landscape' private handleStartStop?: () => void private handleRestart?: () => void private handleQuit?: () => void private handleRerunTestFile?: (fileName: string) => void private handleFilterChange?: (pattern?: string) => void private handleOpenTestFile?: (testFile: string) => void private handleToggleDebug?: () => void private handleToggleRpTraining?: () => void private handletoggleStandardWatch?: () => void private handleToggleSmartWatch?: () => any private minWidth = 50 private isRpTraining: boolean private trainingTokenPopup?: PopupWidget // private orientationWhenErrorLogWasShown: TestReporterOrientation = // 'landscape' public constructor(options?: TestReporterOptions) { this.cwd = options?.cwd this.filterPattern = options?.filterPattern this.handleRestart = options?.handleRestart this.handleStartStop = options?.handleStartStop this.handleQuit = options?.handleQuit this.handleRerunTestFile = options?.handleRerunTestFile this.handleOpenTestFile = options?.handleOpenTestFile this.handleFilterChange = options?.handleFilterPatternChange this.status = options?.status ?? 'ready' this.handleToggleDebug = options?.handleToggleDebug this.handletoggleStandardWatch = options?.handletoggleStandardWatch this.handleToggleRpTraining = options?.handleToggleRpTraining this.isDebugging = options?.isDebugging ?? false this.watchMode = options?.watchMode ?? 'off' this.isRpTraining = options?.isRpTraining ?? false this.handleToggleSmartWatch = options?.handleToggleSmartWatch this.errorLogItemGenerator = new TestLogItemGenerator() this.widgets = new WidgetFactory() } public setFilterPattern(pattern: string | undefined) { this.filterPattern = pattern this.filterInput.setValue(pattern ?? '') this.clearFilterPatternButton.setText(buildPatternButtonText(pattern)) } public setIsDebugging(isDebugging: boolean) { this.setLabelStatus('toggleDebug', 'Debug', isDebugging) this.isDebugging = isDebugging } public setIsRpTraining(isRpTraining: boolean) { this.setLabelStatus('rp', 'Train AI', isRpTraining) this.isRpTraining = isRpTraining } public startCountdownTimer(durationSec: number) { clearInterval(this.countDownTimeInterval) this.countDownTimeInterval = undefined let remaining = durationSec function renderCountdownTime(time: number) { return `Starting ${time} ` } this.setWatchLabel(renderCountdownTime(remaining)) this.countDownTimeInterval = setInterval(() => { remaining-- if (remaining < 0) { this.stopCountdownTimer() } else { this.setWatchLabel(renderCountdownTime(remaining)) } }, 1000) as any } public stopCountdownTimer() { clearInterval(this.countDownTimeInterval) this.countDownTimeInterval = undefined this.setWatchMode(this.watchMode) } public setWatchMode(watchMode: WatchMode) { this.watchMode = watchMode if (!this.countDownTimeInterval) { let label = watchMode === 'smart' ? 'Smart Watch' : 'Standard Watch' if (watchMode === 'off') { label = 'Not Watching' } this.setWatchLabel(label) } } private setWatchLabel(label: string) { const isEnabled = this.watchMode !== 'off' this.setLabelStatus('watchDropdown', label, isEnabled) this.menu.setTextForItem( 'toggleStandardWatch', this.watchMode === 'standard' ? '√ Standard' : 'Standard' ) this.menu.setTextForItem( 'toggleSmartWatch', this.watchMode === 'smart' ? '√ Smart' : 'Smart' ) } private setLabelStatus(menuKey: string, label: string, isEnabled: boolean) { this.menu.setTextForItem( menuKey, `${label} ^${isEnabled ? 'k' : 'w'}^#^${isEnabled ? 'g' : 'r'}${isEnabled ? ' • ' : ' • '}^` ) } public async start() { this.started = true this.window = this.widgets.Widget('window', {}) this.window.hideCursor() const { width } = this.window.getFrame() if (width < this.minWidth) { throw new Error( `Your screen must be at least ${this.minWidth} characters wide.` ) } void this.window.on('key', this.handleGlobalKeypress.bind(this)) void this.window.on('kill', this.destroy.bind(this)) void this.window.on('resize', this.handleWindowResize.bind(this)) this.dropInTopLayout() this.dropInProgressBar() this.dropInMenu() this.dropInBottomLayout() this.dropInStatusBar() this.dropInTestLog() this.dropInFilterControls() this.updateOrientation() this.setIsDebugging(this.isDebugging) this.setWatchMode(this.watchMode) this.setStatus(this.status) try { this.setIsRpTraining(this.isRpTraining) } catch {} this.updateInterval = setInterval( this.handleUpdateInterval.bind(this), 1000 ) } private handleWindowResize() { this.updateOrientation() } private updateOrientation() { const frame = this.window.getFrame() if (frame.width * 0.4 > frame.height) { this.orientation = 'landscape' } else { this.orientation = 'portrait' } } private dropInMenu() { this.menu = this.widgets.Widget('menuBar', { parent: this.window, left: 0, top: 0, shouldLockWidthWithParent: true, items: [ { label: 'Restart ', value: 'restart', }, { label: 'Debug ', value: 'toggleDebug', }, { label: 'Not Watching ', value: 'watchDropdown', items: [ { label: 'Watch all', value: 'toggleStandardWatch', }, { label: 'Smart watch', value: 'toggleSmartWatch', }, ], }, { label: 'Train AI ', value: 'rp', }, { label: 'Quit', value: 'quit', }, ], }) void this.menu.on('select', this.handleMenuSelect.bind(this)) } public setStatus(status: TestRunnerStatus) { this.status = status this.updateMenuLabels() this.closeSelectTestPopup() this.bottomLayout.updateLayout() if (status === 'ready') { this.setStatusLabel('Starting...') } else if (this.status === 'stopped') { this.refreshResults() this.setStatusLabel('') } else if (this.status === 'running') { this.setStatusLabel('Running tests...') } } private updateMenuLabels() { let restartLabel = 'Start ^#^r › ^' switch (this.status) { case 'running': restartLabel = 'Stop ^k^#^g › ^' break case 'stopped': restartLabel = `Start ^w^#^r › ^` break case 'ready': restartLabel = 'Booting ^#^K › ^' break } this.menu.setTextForItem('restart', restartLabel) } private handleMenuSelect(payload: { value: string }) { switch (payload.value) { case 'quit': this.handleQuit?.() break case 'restart': this.handleStartStop?.() break case 'toggleDebug': this.handleToggleDebug?.() break case 'toggleStandardWatch': this.handletoggleStandardWatch?.() break case 'toggleSmartWatch': this.handleToggleSmartWatch?.() break case 'rp': this.handleToggleRpTraining?.() break } } private handleUpdateInterval() { if (this.status !== 'stopped') { this.refreshResults() } } private refreshResults() { if (this.lastResults) { this.updateLogs() } } private async handleGlobalKeypress(payload: { key: string }) { if (this.window.getFocusedWidget() === this.filterInput) { return } switch (payload.key) { case 'ENTER': this.handleRestart?.() break case 'CTRL_C': this.handleQuit?.() process.exit() break } } private dropInTestLog() { const parent = this.bottomLayout.getChildById('results') if (parent) { this.testLog = this.widgets.Widget('text', { parent, isScrollEnabled: true, left: 0, top: 0, height: '100%', width: '100%', shouldLockHeightWithParent: true, shouldLockWidthWithParent: true, }) void this.testLog.on('click', this.handleClickTestLog.bind(this)) } } private async handleClickTestLog(payload: { row: number; column: number }) { const testFile = this.getFileForLine(payload.row) const { row, column } = payload this.closeSelectTestPopup() if (testFile) { this.dropInSelectTestPopup({ testFile, column, row }) } } public async askForTrainingToken() { if (this.trainingTokenPopup) { return } this.trainingTokenPopup = this.widgets.Widget('popup', { parent: this.window, top: 10, left: 10, width: 50, height: 10, }) this.widgets.Widget('text', { parent: this.trainingTokenPopup, left: 4, top: 3, height: 4, width: this.trainingTokenPopup.getFrame().width - 2, text: 'Coming soon...', }) const button = this.widgets.Widget('button', { parent: this.trainingTokenPopup, left: 20, top: 7, text: ' Ok ', }) await button.on('click', async () => { await this.trainingTokenPopup?.destroy() delete this.trainingTokenPopup }) } private closeSelectTestPopup() { if (this.selectTestPopup) { void this.selectTestPopup.destroy() this.selectTestPopup = undefined } } private dropInSelectTestPopup(options: { testFile: string column: number row: number }) { const { testFile, row, column } = options this.selectTestPopup = this.widgets.Widget('popup', { parent: this.window, left: Math.max(1, column - 25), top: Math.max(4, row - 2), width: 50, height: 10, }) this.widgets.Widget('text', { parent: this.selectTestPopup, left: 1, top: 1, height: 4, width: this.selectTestPopup.getFrame().width - 2, text: `What do you wanna do with:\n\n${testFile}`, }) const open = this.widgets.Widget('button', { parent: this.selectTestPopup, left: 1, top: 6, text: 'Open', }) const rerun = this.widgets.Widget('button', { parent: this.selectTestPopup, left: 20, top: 6, text: 'Test', }) const cancel = this.widgets.Widget('button', { parent: this.selectTestPopup, left: 37, top: 6, text: 'Nevermind', }) void rerun.on('click', () => { this.handleRerunTestFile?.(testFile) this.closeSelectTestPopup() }) void cancel.on('click', this.closeSelectTestPopup.bind(this)) void open.on('click', () => { this.openTestFile(testFile) }) } private openTestFile(testFile: string) { this.handleOpenTestFile?.(testFile) this.closeSelectTestPopup() } public getFileForLine(row: number): string | undefined { let line = this.testLog.getScrollY() for (let file of this.lastResults.testFiles ?? []) { const minRow = line const maxRow = line + (file.tests ?? []).length if (row >= minRow && row <= maxRow) { return file.path } line = maxRow } return undefined } private dropInProgressBar() { const parent = this.topLayout.getChildById('progress') ?? this.window this.bar = this.widgets.Widget('progressBar', { parent, left: 0, top: 0, width: parent.getFrame().width, shouldLockWidthWithParent: true, label: 'Ready and waiting...', progress: 0, }) } private dropInFilterControls() { const parent = this.topLayout.getChildById('filter') ?? this.window const buttonWidth = 3 this.filterInput = this.widgets.Widget('input', { parent, left: 0, label: 'Pattern', width: parent.getFrame().width - buttonWidth, height: 1, shouldLockWidthWithParent: true, value: this.filterPattern, }) void this.filterInput.on('cancel', () => { this.filterInput.setValue(this.filterPattern ?? '') }) void this.filterInput.on('submit', (payload) => { this.handleFilterChange?.(payload.value ?? undefined) }) this.clearFilterPatternButton = this.widgets.Widget('button', { parent, left: this.filterInput.getFrame().width, width: buttonWidth, top: 0, text: buildPatternButtonText(this.filterPattern), shouldLockRightWithParent: true, }) void this.clearFilterPatternButton.on('click', () => { if (this.filterPattern || this.filterPattern?.length === 0) { this.handleFilterChange?.(undefined) } else { this.filterInput.setValue('') } }) } private dropInBottomLayout() { this.bottomLayout = this.widgets.Widget('layout', { parent: this.window, width: '100%', top: 4, height: this.window.getFrame().height - 5, shouldLockWidthWithParent: true, shouldLockHeightWithParent: true, rows: [ { height: '100%', columns: [ { id: 'results', width: '100%', }, ], }, ], }) } private dropInStatusBar() { this.statusBar = this.widgets.Widget('text', { parent: this.window, top: this.window.getFrame().height - 1, width: '100%', shouldLockWidthWithParent: true, shouldLockBottomWithParent: true, backgroundColor: 'yellow', foregroundColor: 'black', text: '...', }) } private dropInTopLayout() { this.topLayout = this.widgets.Widget('layout', { parent: this.window, width: '100%', top: 1, height: 3, shouldLockWidthWithParent: true, shouldLockHeightWithParent: false, rows: [ { height: '100%', columns: [ { id: 'progress', width: 50, }, { id: 'filter', }, ], }, ], }) } public updateResults(results: SpruceTestResults) { if (!this.started) { throw new Error('You must call start() before anything else.') } this.lastResults = { ...this.lastResults, ...results, } this.updateProgressBar(results) const percentPassing = this.generatePercentPassing(results) const percentComplete = this.generatePercentComplete(results) this.window.setTitle( `Testing: ${percentComplete}% complete.${ percentComplete > 0 ? ` ${percentPassing}% passing.` : '' }` ) this.updateLogs() } private updateLogs() { if (this.selectTestPopup) { return } let { logContent, errorContent } = this.resultsToLogContents( this.lastResults ) this.testLog.setText(logContent) if (!errorContent) { // this.errorLog && this.destroyErrorLog() this.errorLog?.setText(' Nothing to report...') } else { !this.errorLog && this.dropInErrorLog() const cleanedLog = this.cwd ? errorContent.replace(new RegExp(this.cwd + '/', 'gim'), '') : errorContent this.errorLog?.setText(cleanedLog) } } private resultsToLogContents(results: SpruceTestResults) { let logContent = '' let errorContent = '' results.testFiles?.forEach((file) => { logContent += this.errorLogItemGenerator.generateLogItemForFile( file, this.status ) errorContent += this.errorLogItemGenerator.generateErrorLogItemForFile(file) }) if (this.lastResults.customErrors.length > 0) { errorContent = this.lastResults.customErrors .map((err) => chalk.red(err)) .join(`\n`) + `\n${errorContent}` } return { logContent, errorContent } } private dropInErrorLog() { // this.orientationWhenErrorLogWasShown = this.orientation if (this.bottomLayout.getRows().length === 1) { if (this.orientation === 'portrait') { this.bottomLayout.addRow({ id: 'row_2', columns: [{ id: 'errors', width: '100%' }], }) this.bottomLayout.setRowHeight(0, '60%') this.bottomLayout.setRowHeight(1, '40%') } else { this.bottomLayout.addColumn(0, { id: 'errors', width: '40%' }) this.bottomLayout.setColumnWidth({ rowIdx: 0, columnIdx: 0, width: '60%', }) } this.bottomLayout.updateLayout() const cell = this.bottomLayout.getChildById('errors') if (!cell) { throw new Error('Pulling child error') } this.errorLog = this.widgets.Widget('text', { parent: cell, width: '100%', height: '100%', isScrollEnabled: true, shouldAutoScrollWhenAppendingContent: false, shouldLockHeightWithParent: true, shouldLockWidthWithParent: true, padding: { left: 1 }, }) } } private destroyErrorLog() { // if (this.errorLog) { // void this.errorLog?.destroy() // this.errorLog = undefined // if (this.orientationWhenErrorLogWasShown === 'landscape') { // this.bottomLayout.removeColumn(0, 1) // this.bottomLayout.setColumnWidth({ // rowIdx: 0, // columnIdx: 0, // width: '100%', // }) // } else { // this.bottomLayout.removeRow(1) // this.bottomLayout.setRowHeight(0, '100%') // } // this.bottomLayout.updateLayout() // } } private updateProgressBar(results: SpruceTestResults) { if (results.totalTestFilesComplete ?? 0 > 0) { const testsRemaining = results.totalTestFiles - (results.totalTestFilesComplete ?? 0) if (testsRemaining === 0) { const { percent, totalTests, totalPassedTests, totalTime } = this.generateProgressStats(results) this.bar.setLabel( `Finished! ${totalPassedTests} of ${totalTests} (${percent}%) passed in ${durationUtil.msToFriendly( totalTime )}.${percent < 100 ? ` Don't give up! 💪` : ''}` ) } else { this.bar.setLabel( `${results.totalTestFilesComplete} of ${ results.totalTestFiles } (${this.generatePercentComplete( results )}%) complete. ${testsRemaining} remaining...` ) } } else { this.bar.setLabel('0%') } this.bar.setProgress(this.generatePercentComplete(results) / 100) } private generateProgressStats(results: SpruceTestResults): { percent: number totalTests: number totalPassedTests: number totalTime: number } { let totalTests = 0 let totalPassedTests = 0 let totalTime = 0 results.testFiles?.forEach((file) => { file.tests?.forEach((test) => { totalTime += test.duration if (test.status === 'passed') { totalPassedTests++ } if (test.status === 'passed' || test.status === 'failed') { totalTests++ } }) }) const percent = Math.floor((totalPassedTests / totalTests) * 100) return { percent: percent > 0 ? percent : 0, totalTests, totalPassedTests, totalTime, } } private generatePercentComplete(results: SpruceTestResults): number { const percent = (results.totalTestFilesComplete ?? 0) / results.totalTestFiles if (isNaN(percent)) { return 0 } return Math.round(percent * 100) } private generatePercentPassing(results: SpruceTestResults): number { const percent = (results.totalPassed ?? 0) / this.getTotalTestFilesRun(results) if (isNaN(percent)) { return 0 } return Math.floor(percent * 100) } private getTotalTestFilesRun(results: SpruceTestResults) { return ( (results.totalTests ?? 0) - (results.totalSkipped ?? 0) - (results.totalTodo ?? 0) ) } public render() { this.table?.computeCells() this.table?.draw() } public async destroy() { clearInterval(this.updateInterval) await this.window.destroy() } public reset() { this.testLog.setText('') this.lastResults = { totalTestFiles: 0, customErrors: [], } this.destroyErrorLog() this.errorLogItemGenerator.resetStartTimes() } public setStatusLabel(text: string) { this.statusBar.setText(text) } public appendError(message: string) { this.lastResults.customErrors.push(message) } } function buildPatternButtonText(pattern: string | undefined): string { return pattern ? ' x ' : ' - ' } interface TestReporterOptions { handleStartStop?: () => void handleRestart?: () => void handleQuit?: () => void onRequestOpenTestFile?: () => void handleRerunTestFile?: (fileName: string) => void handleOpenTestFile?: (fileName: string) => void handleFilterPatternChange?: (pattern?: string) => void handleToggleDebug?: () => void handletoggleStandardWatch?: () => void handleToggleSmartWatch?: () => void handleToggleRpTraining?: () => void filterPattern?: string isDebugging?: boolean isRpTraining?: boolean watchMode?: WatchMode status?: TestRunnerStatus cwd?: string } type TestReporterResults = SpruceTestResults & { customErrors: string[] } export type TestReporterOrientation = 'landscape' | 'portrait' export type WatchMode = 'off' | 'standard' | 'smart'