@sprucelabs/spruce-cli
Version:
Command line interface for building Spruce skills.
386 lines (321 loc) • 12 kB
text/typescript
import { FieldDefinitionValueType } from '@sprucelabs/schema'
import { namesUtil, testLog } from '@sprucelabs/spruce-skill-utils'
import { assert } from '@sprucelabs/test-utils'
import { FieldDefinitions } from '#spruce/schemas/fields/fields.types'
import testUtil from '../tests/utilities/test.utility'
import { ExecutionResults } from '../types/cli.types'
import { GraphicsInterface } from '../types/cli.types'
import {
GraphicsTextEffect,
ProgressBarOptions,
ProgressBarUpdateOptions,
ImageDimensions,
} from '../types/graphicsInterface.types'
import durationUtil from '../utilities/duration.utility'
import TerminalInterface from './TerminalInterface'
export default class SpyInterface implements GraphicsInterface {
public invocations: { command: string; options?: any }[] = []
private cursorPosition = { x: 0, y: 0 }
private promptResolver?: (
input: FieldDefinitionValueType<FieldDefinitions>
) => void | undefined
private confirmResolver?: (pass: boolean) => void | undefined
private waitForEnterResolver?: () => void | undefined
private promptDefaultValue: any
private term?: TerminalInterface
private startTime: number
private promptTimeout?: any
private error?: Error
public constructor() {
this.startTime = new Date().getTime()
this.term = this.shouldRenderTestLogs()
? new TerminalInterface(process.cwd(), true, (...strs: []) => {
this.optionallyRenderLine(strs.join(' '))
})
: undefined
}
public setTitle(_title: string): void {}
private shouldRenderTestLogs() {
return process.env.SHOULD_RENDER_TEST_LOGS === 'true'
}
public renderWarning(
message: string,
effects?: GraphicsTextEffect[] | undefined
): void {
this.trackInvocation('renderWarning', { message, effects })
this.term?.renderWarning(message)
}
public renderHint(
message: string,
effects?: GraphicsTextEffect[] | undefined
): void {
this.trackInvocation('renderHint', { message, effects })
this.optionallyRenderLine(`Hint: ${message}`)
this.term?.renderHint(message)
}
private trackInvocation(command: string, options?: any) {
// testUtil.log(command, JSON.stringify(options), '\n')
this.invocations.push({ command, options })
}
public isWaitingForInput() {
return !!(
this.promptResolver ||
this.confirmResolver ||
this.waitForEnterResolver
)
}
public reset() {
this.promptResolver = undefined
this.confirmResolver = undefined
this.waitForEnterResolver = undefined
clearTimeout(this.promptTimeout)
}
public setError(err: Error) {
this.error = err
}
public getLastInvocation() {
return this.invocations[this.invocations.length - 1]
}
public async sendInput(input: Input): Promise<void> {
this.trackInvocation('sendInput', input)
this.optionallyRenderLine(
`Sending input: "${
(input as FilePromptInput).path
? (input as FilePromptInput).path
: (input as string).length > 0
? input
: 'ENTER'
}"`
)
if (this.waitForEnterResolver) {
const resolver = this.waitForEnterResolver
this.waitForEnterResolver = undefined
resolver()
} else if (this.promptResolver) {
const resolver = this.promptResolver
this.promptResolver = undefined
resolver(
input !== '\n' && input !== '' ? input : this.promptDefaultValue
)
} else if (this.confirmResolver) {
const resolver = this.confirmResolver
this.confirmResolver = undefined
resolver(
input === '\n' ||
(input as string).length === 0 ||
(typeof input === 'string' && input.toLowerCase() === 'y')
)
} else {
throw new Error('Sent input before prompted for input')
}
return new Promise((resolve) => {
setTimeout(resolve, 50)
})
}
public renderSection(options: {
headline: string
lines?: string[] | string[]
headlineEffects?: GraphicsTextEffect[]
dividerEffects?: GraphicsTextEffect[]
bodyEffects?: GraphicsTextEffect[]
object?: any
}): void {
this.trackInvocation('renderSection', options)
this.term?.renderSection(options)
}
public renderObject(obj: any): void {
this.trackInvocation('renderObject', obj)
this.term?.renderObject(obj)
}
public renderError(err: Error): void {
this.trackInvocation('renderError', err)
this.term?.renderError(err)
}
public renderCodeSample(code: string): void {
this.trackInvocation('renderCodeSample', code)
this.term?.renderCodeSample(code)
}
public renderActionSummary(results: ExecutionResults): void {
this.trackInvocation('renderCommandSummary', results)
this.term?.renderActionSummary(results)
}
public renderHero(
message: string,
effects?: GraphicsTextEffect[] | undefined
): void {
this.trackInvocation('renderHero', { message, effects })
this.term?.renderHero(message, effects)
}
public renderHeadline(
message: string,
effects: GraphicsTextEffect[],
dividerEffects: GraphicsTextEffect[]
): void {
this.trackInvocation('renderHeadline', {
message,
effects,
dividerEffects,
})
this.term?.renderHeadline(message, effects, dividerEffects)
}
public renderDivider(effects?: GraphicsTextEffect[] | undefined): void {
this.trackInvocation('renderDivider', effects)
this.term?.renderDivider(effects)
}
public renderLine(
message: string,
effects?: GraphicsTextEffect[] | undefined
): void {
this.trackInvocation('renderLine', { message, effects })
message && message.length > 0 && this.optionallyRenderLine(message)
}
private optionallyRenderLine(message: string) {
if (this.shouldRenderTestLogs()) {
const duration = new Date().getTime() - this.startTime
const friendly = durationUtil.msToFriendly(duration)
testLog.info(
`${
//@ts-ignore
global.activeTest?.test
? //@ts-ignore
global.activeTest?.test + ' :: '
: ''
}${friendly} :: ${message}`
)
}
}
public async renderImage(
path: string,
options?: ImageDimensions
): Promise<void> {
this.trackInvocation('renderImage', { path, options })
}
public renderLines(
messages: string[],
effects?: GraphicsTextEffect[] | undefined
): void {
this.trackInvocation('renderLines', { messages, effects })
this.term?.renderLines(messages, effects)
}
public async prompt<T extends FieldDefinitions>(definition: T) {
this.trackInvocation('prompt', definition)
if (this.promptResolver) {
throw new Error(
'Tried to double prompt. Try this.term?.sendInput() before calling prompt next.'
)
}
let msg = `${namesUtil.toPascal(definition.type)} Prompt: ${
definition.label
}`
if (definition.type === 'select') {
msg += '\n\nChoices:\n'
definition.options.choices.forEach((choice) => {
msg += `\n${choice.value}: ${choice.label}`
})
msg += '\n'
}
this.optionallyRenderLine(msg)
return new Promise<FieldDefinitionValueType<FieldDefinitions>>(
(resolve, reject) => {
this.promptResolver = (...args: any[]) => {
clearTimeout(this.promptTimeout)
//@ts-ignore
resolve(...args)
}
this.promptTimeout = setTimeout(() => {
this.reset()
try {
assert.fail(
`Timed out waiting for input with label: '${definition.label}'\n\nConsider passing what you need to Action().execute({ something })`
)
} catch (err: any) {
reject(err)
}
}, 10000)
this.promptDefaultValue = definition.defaultValue
}
)
}
public startLoading(message?: string | undefined): void {
this.trackInvocation('startLoading', message)
this.optionallyRenderLine(message ? `${message}` : 'Start loading...')
}
public stopLoading(): void {
this.trackInvocation('stopLoading')
this.optionallyRenderLine('Stop loading...')
}
public async waitForEnter(message?: string | undefined): Promise<void> {
this.trackInvocation('waitForEnter', message)
this.optionallyRenderLine(
`${message ? ` ${message}\n\n` : ``}Waiting for enter...`
)
return new Promise((resolve) => {
this.waitForEnterResolver = resolve
})
}
public async waitForInput() {
const ttl = 1000 * 60 * 2
const checkInterval = 100
let loops = ttl / checkInterval
let lastWriteCount = this.invocations.length
while (!this.isWaitingForInput()) {
if (loops-- === 0) {
assert.fail(`Waiting for input timed out.`)
}
const hasWritten = lastWriteCount != this.invocations.length
if (hasWritten) {
loops = ttl / checkInterval
lastWriteCount = this.invocations.length
if (this.shouldRenderTestLogs()) {
testUtil.log(
'waitForInput timeout reset because of new output.'
)
}
}
if (this.error) {
throw this.error
}
await new Promise((resolve) => setTimeout(resolve, checkInterval))
}
}
public confirm(question: string): Promise<boolean> {
this.trackInvocation('confirm', question)
this.optionallyRenderLine(`${question} :: Y/N...`)
return new Promise((resolve) => {
this.confirmResolver = resolve
})
}
public clear(): void {
this.trackInvocation('clear')
}
public renderProgressBar(options: ProgressBarOptions): void {
this.trackInvocation('renderProgressBar', options)
this.optionallyRenderLine(
`Showing progress${options.title ? ` ${options.title}` : ``}`
)
}
public updateProgressBar(options: ProgressBarUpdateOptions): void {
this.trackInvocation('updateProgressBar', options)
}
public removeProgressBar(): void {
this.trackInvocation('removeProgressBar')
this.optionallyRenderLine(`Hiding progress`)
}
public async getCursorPosition(): Promise<{ x: number; y: number } | null> {
this.trackInvocation('getCursorPosition')
return this.cursorPosition
}
public setCursorPosition(pos: { x: number; y: number }) {
this.cursorPosition = pos
}
public moveCursorTo(x: number, y: number): void {
this.trackInvocation('moveCursorTo', { x, y })
}
public clearBelowCursor(): void {
this.trackInvocation('clearBelowCursor')
}
}
interface FilePromptInput {
path: string
}
type Input = string | string[] | FilePromptInput