@sprucelabs/spruce-cli
Version:
Command line interface for building Spruce skills.
161 lines (141 loc) • 5.08 kB
text/typescript
import pathUtil from 'path'
import { AbstractEventEmitter } from '@sprucelabs/mercury-event-emitter'
import {
buildEventContract,
MercuryEventEmitter,
} from '@sprucelabs/mercury-types'
import { buildSchema } from '@sprucelabs/schema'
import { diskUtil } from '@sprucelabs/spruce-skill-utils'
import SpruceError from '../../errors/SpruceError'
import CommandServiceImpl from '../../services/CommandService'
import JestJsonParser from '../../tests/JestJsonParser'
import { SpruceTestResults } from './test.types'
export default class TestRunner extends AbstractEventEmitter<TestRunnerContract> {
private cwd: string
private commandService: CommandServiceImpl
private wasKilled = false
private testResults: SpruceTestResults = { totalTestFiles: 0 }
public constructor(options: {
cwd: string
commandService: CommandServiceImpl
}) {
super(testRunnerContract)
this.cwd = options.cwd
this.commandService = options.commandService
}
public async run(options?: {
pattern?: string | null
debugPort?: number | null
}): Promise<SpruceTestResults & { wasKilled: boolean }> {
this.wasKilled = false
const jestPath = this.resolvePathToJest()
const debugArgs =
(options?.debugPort ?? 0) > 0
? `--inspect=${options?.debugPort}`
: ``
const pattern = options?.pattern ?? ''
let escapeShell = function (cmd: string) {
return (
'--testPathPatterns="' +
cmd.replace(/(["\s'$`\\])/g, '\\$1') +
'"'
)
}
const command = `node --experimental-vm-modules --unhandled-rejections=strict ${debugArgs} ${jestPath} --reporters="@sprucelabs/jest-json-reporter" --testRunner="jest-circus/runner" --passWithNoTests ${
pattern ? escapeShell(pattern) : ''
}`
const parser = new JestJsonParser()
this.testResults = {
totalTestFiles: 0,
}
try {
await this.commandService.execute(command, {
forceColor: true,
onError: async (data) => {
const isDebugMessaging = this.isDebugMessage(data)
if (!isDebugMessaging) {
await (
this as MercuryEventEmitter<TestRunnerContract>
).emit('did-error', { message: data })
}
},
onData: async (data) => {
parser.write(data)
this.testResults = parser.getResults()
await (
this as MercuryEventEmitter<TestRunnerContract>
).emit('did-update', { results: this.testResults })
},
})
} catch (err) {
if (!this.testResults.totalTestFiles) {
throw err
}
}
return { ...this.testResults, wasKilled: this.wasKilled }
}
private isDebugMessage(data: string) {
return (
data.search(/^ attached/i) === 0 ||
data.search(/^ listening/i) === 0 ||
data.search(/^waiting for the /i) === 0
)
}
public hasFailedTests() {
return (this.testResults.totalFailed ?? 0) > 0
}
public hasSkippedTests() {
return (this.testResults.totalSkipped ?? 0) > 0
}
public kill() {
this.wasKilled = true
this.commandService.kill()
}
private resolvePathToJest() {
const jestPath = 'node_modules/.bin/jest'
const fullPath = diskUtil.resolvePath(this.cwd)
const pathParts = fullPath.split(pathUtil.sep)
while (pathParts.length > 0) {
const path =
pathUtil.sep +
pathUtil.join(...pathParts) +
pathUtil.sep +
jestPath
if (diskUtil.doesFileExist(path)) {
return path
}
pathParts.pop()
}
throw new SpruceError({ code: 'INVALID_TEST_DIRECTORY', dir: this.cwd })
}
}
const testRunnerContract = buildEventContract({
eventSignatures: {
'did-error': {
emitPayloadSchema: buildSchema({
id: 'testRunnerDidErrorEmitPayload',
fields: {
message: {
type: 'text',
isRequired: true,
},
},
}),
},
'did-update': {
emitPayloadSchema: buildSchema({
id: 'testRunnerDidUpdateEmitPayload',
fields: {
results: {
type: 'raw',
isRequired: true,
options: {
valueType: 'SpruceTestResults',
},
},
},
}),
},
},
})
type TestRunnerContract = typeof testRunnerContract