import _ from 'lodash'
import axios from 'axios'
import DeviceDTO from './device-dto.js'
import JobDTO from './job-dto.js'
import JobRecordDTO from './job-record-dto.js'
import JobResultDTO from './job-result-dto.js'
import JobRunDTO from './job-run-dto.js'
import JobTemplateDTO from './job-template-dto.js'
import TestResultDTO from './test-result-dto.js'
import TestRunDTO from './test-run-dto.js'
import TestSuiteDTO from './test-suite-dto.js'
/**
* Client for Bespoken testing, training and monitoring API
*/
class Client {
/**
* Creates a new client for interacting with the Bespoken Test API
* Must pass the API key which can be found in the Bespoken Dashboard under account settings
* @param {string} apiKey
* @param {string} [organizationId]
* @param {string} [baseURL]
*/
constructor (apiKey, organizationId, baseURL) {
this.apiKey = apiKey
this.baseURL = baseURL || 'https://test-api.bespoken.io/api'
this.organizationId = organizationId
if (!baseURL && typeof window !== 'undefined' && window.location.href.includes('localhost')) {
this.baseURL = 'http://localhost:3000/api'
}
/** @private @type {TestResultCallback | undefined} */
this._testResultCallback = undefined
// We keep track of the results between calls - if there are changes, we notify the caller if the testResultCallback is registered
/** @private @type {TestResultDTO[] | undefined} */
this._lastResults = undefined
}
/**
* Saves a job
* * Creates a new job if none exists
* * Updates if it is already existing
* @param {string} resultID
* @param {'ACCEPT' | 'DISCARD' | 'OVERRIDE'} status
* @returns {Promise<void>}
*/
async annotateResult (resultID, status) {
const url = `${this.baseURL}/job-result/${resultID}/annotate/${status}?${this._queryString()}`
await axios.get(url)
}
/**
* Classifies the specified run
* @param {string} runID
* @returns {Promise<void>}
*/
async classifyRun (runID) {
const runURL = `${this.baseURL}/job-run/${runID}/classify`
await axios.get(runURL)
}
/**
* Creates a new device - must be one of the types allowed by the platform parameter
* @param {any} deviceInfo
* @returns {Promise<DeviceDTO>}
*/
async saveDevice (deviceInfo) {
const devicesURL = `${this.baseURL}/device?${this._queryString()}`
let response
try {
console.info('create device url: ' + devicesURL)
response = await axios.post(devicesURL, deviceInfo)
} catch (e) {
console.info('error: ' + e.status)
if (e.message.includes('401')) {
throw new Error('Invalid API key')
}
throw e
}
return new DeviceDTO(response.data)
}
/**
* Deletes the specified device
* @param {string} deviceToken
* @returns {Promise<boolean>}
*/
async deleteDevice (deviceToken) {
const devicesURL = `${this.baseURL}/device/${deviceToken}?${this._queryString()}`
try {
console.info('fetch devices url: ' + devicesURL)
await axios.delete(devicesURL)
} catch (e) {
console.info('error: ' + e.status)
if (e.message.includes('401')) {
throw new Error('Invalid API key')
} else if (e.message.includes('404')) {
return false
}
throw e
}
return true
}
/**
* Deletes the specified job
* @param {string} id
* @returns {Promise<boolean>}
*/
async deleteJob (id) {
const devicesURL = `${this.baseURL}/job/${id}?${this._queryString()}`
try {
console.info('delete job url: ' + devicesURL)
await axios.delete(devicesURL)
} catch (e) {
console.info('error: ' + e.status)
if (e.message.includes('401')) {
throw new Error('Invalid API key')
} else if (e.message.includes('404')) {
return false
}
throw e
}
return true
}
/**
* Deletes the specified notification
* @param {string} jobID
* @param {string} notificationID
* @returns {Promise<boolean>}
*/
async deleteJobNotification (jobID, notificationID) {
const deleteURL = `${this.baseURL}/job/${jobID}/notification/${notificationID}?${this._queryString()}`
try {
console.info('delete notification url: ' + deleteURL)
await axios.delete(deleteURL)
} catch (e) {
if (e.message.includes('401')) {
throw new Error('Invalid API key')
} else if (e.message.includes('404')) {
return false
}
throw e
}
return true
}
/**
* Deletes the specified notification
* @param {string} jobID
* @param {string} recordID
* @returns {Promise<boolean>}
*/
async deleteJobRecord (jobID, recordID) {
const deleteURL = `${this.baseURL}/job/${jobID}/record/${recordID}?${this._queryString()}`
try {
console.info('delete record url: ' + deleteURL)
await axios.delete(deleteURL)
} catch (e) {
if (e.message.includes('401')) {
throw new Error('Invalid API key')
} else if (e.message.includes('404')) {
return false
}
throw e
}
return true
}
/**
* Deletes the specified run
* @param {string} runID
* @returns {Promise<boolean>}
*/
async deleteRun (runID) {
const deleteRunURL = `${this.baseURL}/job-run/${runID}?${this._queryString()}`
try {
console.info('delete run url: ' + deleteRunURL)
await axios.delete(deleteRunURL)
} catch (e) {
console.info('error: ' + e.status)
if (e.message.includes('401')) {
throw new Error('Invalid API key')
} else if (e.message.includes('404')) {
return false
}
throw e
}
return true
}
/**
* Translates a cron schedule to readable english
* @param {string} cron
* @returns {Promise<string>}
*/
async explainCron (cron) {
const cronURL = `${this.baseURL}/job/${cron}/explain`
const response = await axios.get(cronURL)
return response.data
}
/**
* Fetches information about the named device
* @param {string} deviceToken
* @returns {Promise<DeviceDTO | undefined>}
*/
async fetchDevice (deviceToken) {
const devicesURL = `${this.baseURL}/device/${deviceToken}?${this._queryString()}`
let response
try {
console.info('fetch devices url: ' + devicesURL)
response = await axios.get(devicesURL)
} catch (e) {
console.info('error: ' + e.status)
if (e.message.includes('401')) {
throw new Error('Invalid API key')
} else if (e.message.includes('404')) {
return undefined
}
throw e
}
// console.info('data: ' + JSON.stringify(response.data, null, 2))
return new DeviceDTO(response.data)
}
/**
* Fetches all devices associated with the account
* @returns {Promise<DeviceDTO[]>}
*/
async fetchDevices () {
const devicesURL = `${this.baseURL}/device?${this._queryString()}`
let response
try {
console.info('fetch devices url: ' + devicesURL)
response = await axios.get(devicesURL)
} catch (e) {
console.info('error: ' + e.status)
if (e.message.includes('401')) {
throw new Error('Invalid API key')
}
throw e
}
// console.info('data: ' + JSON.stringify(response.data, null, 2))
return response.data.devices.map(d => new DeviceDTO(d))
}
/**
* Retrieves the named test suite
* @param {string} testSuiteName
* @returns {Promise<TestSuiteDTO>}
*/
async fetchTestSuite (testSuiteName) {
const testSuiteURL = `${this.baseURL}/test-suite/${testSuiteName}?${this._queryString()}`
let response
try {
response = await axios.get(testSuiteURL)
} catch (e) {
if (e.response.status === 401) {
throw new Error('Invalid API key')
}
throw e
}
// console.info('data: ' + JSON.stringify(response.data, null, 2))
return TestSuiteDTO.fromJSON(response.data)
}
/**
* Retrieves all test suites for the user
* @returns {Promise<TestSuiteDTO[]>}
*/
async fetchTestSuites () {
const testSuiteURL = `${this.baseURL}/test-suite?${this._queryString()}`
let response
try {
response = await axios.get(testSuiteURL)
} catch (e) {
if (e.response.status === 401) {
throw new Error('Invalid API key')
}
throw e
}
// console.info('data: ' + JSON.stringify(response.data, null, 2))
return response.data.map(t => TestSuiteDTO.fromJSON(t))
}
/**
* @param {string} id
* @returns {Promise<JobDTO | undefined>}
*/
async fetchJobByID (id) {
try {
const response = await axios.get(`${this.baseURL}/job/${id}?${this._queryString()}`)
return new JobDTO(response.data)
} catch (e) {
if (e.response.status === 404) {
return undefined
}
throw e
}
}
/**
* @param {string} id
* @returns {Promise<JobResultDTO | undefined>}
*/
async fetchJobResultByID (id) {
try {
const response = await axios.get(`${this.baseURL}/job-result/${id}?${this._queryString()}`)
return new JobResultDTO(response.data)
} catch (e) {
if (e.response.status === 404) {
return undefined
}
throw e
}
}
/**
* @param {string} id
* @returns {Promise<JobRunDTO[]>}
*/
async fetchJobRunsByJobID (id) {
try {
const response = await axios.get(`${this.baseURL}/job/${id}/run?${this._queryString()}`)
return response.data.map(o => new JobRunDTO(o))
} catch (e) {
if (e.response.status === 404) {
return []
}
throw e
}
}
/**
* @param {string} organizationID
* @returns {Promise<JobDTO[]>}
*/
async fetchJobs (organizationID) {
const response = await axios.get(`${this.baseURL}/job?organization-id=${organizationID}`)
return response.data.map(o => new JobDTO(o))
}
/**
* @returns {Promise<JobTemplateDTO[]>}
*/
async fetchTemplates () {
const response = await axios.get(`${this.baseURL}/job-template`)
return response.data.map(o => new JobTemplateDTO(o))
}
/**
* @returns {Promise<string>}
*/
async generateID () {
const response = await axios.get(`${this.baseURL}/id`)
return response.data
}
/**
* Calculates PESQ MOS score for buffer
* @param {Buffer} buffer
* @param {number} [duration] duration of audio sample in seconds - assumes 8000 hz sample rate and single byte samples
* @returns {Promise<number>}
*/
async mos (buffer, duration) {
if (duration) {
const bufferLimit = duration * 8000
if (buffer.length > bufferLimit) {
buffer = buffer.subarray(0, bufferLimit)
}
}
const response = await axios.post('https://mos2.bespoken.io/mos/score', buffer, {
headers: {
'Content-Type': 'application/octet-stream'
},
responseType: 'json'
})
return response.data.score
}
/**
* Callback to receive results from a test run as they occur
* @param {TestResultCallback} callback
* @returns {void}
*/
onTestResult (callback) {
this._testResultCallback = callback
}
/**
* Resumes the specified run
* @param {string} runID
* @returns {Promise<JobRunDTO>}
*/
async resumeRun (runID) {
const runURL = `${this.baseURL}/job-run/${runID}/resume`
const response = await axios.get(runURL)
return new JobRunDTO(response.data)
}
/**
* Runs the named test suite
* Optionalls includes variables to be set as passed to the test suite
* @param {string} testSuiteName
* @param {string | undefined} testName
* @param {Object<string, string>} [variables = {}]
* @param {Object<string, string | number>} [configuration = {}]
* @returns {Promise<TestResultDTO[]>}
*/
async runTest (testSuiteName, testName, variables = {}, configuration = {}) {
let runURL
if (testName) {
runURL = `${this.baseURL}/test-suite/${testSuiteName}/test/${testName}/run?${this._queryString()}`
} else {
runURL = `${this.baseURL}/test-suite/${testSuiteName}/run?${this._queryString()}`
}
for (const key of Object.keys(configuration)) {
runURL += `&${key}=${configuration[key]}`
}
let response
try {
//console.info('calling URL: ' + runURL)
response = await axios.post(runURL, variables)
} catch (e) {
console.info('error: ' + e.response.status)
if (e.message.includes('401')) {
throw new Error('Invalid API key')
} else if (e.message.includes('404')) {
throw new Error('Provided test suite ID does not exist')
}
throw e
}
const runID = response.data.id
let status = response.data.status
const statusURL = `${this.baseURL}/test-run/${runID}?${this._queryString()}`
let runStatusResponse
let consecutiveErrors = 0
const sleepTime = process.env.TEST_SDK_SLEEP ? parseInt(process.env.TEST_SDK_SLEEP) : 1000
while (status === 'IN_PROGRESS') {
await this._sleep(sleepTime)
try {
runStatusResponse = await axios.get(statusURL)
// Set the status if we have a new one
if (runStatusResponse.data.status) {
status = runStatusResponse.data.status
}
consecutiveErrors = 0
//console.info('got results on client: ' + JSON.stringify(runStatusResponse.data, null, 2))
} catch (e) {
consecutiveErrors++
if (consecutiveErrors >= 3) {
console.error('Error checking results. Retries finished and giving up.')
throw e
} else {
console.warn('Error checking results. Trying again. Attempts so far: ' + consecutiveErrors)
}
}
}
const results = runStatusResponse?.data.results.map(r => new TestResultDTO(r))
return results
}
/**
* Runs the named test suite
* Optionalls includes variables to be set as passed to the test suite
* @param {string} testSuiteName
* @param {string | undefined} testName
* @param {Object<string, string>} [variables = {}]
* @param {Object<string, string | number>} [configuration = {}]
* @returns {Promise<string>}
*/
async runTestWithWebhook (testSuiteName, testName, variables = {}, configuration = {}) {
let runURL
if (testName) {
runURL = `${this.baseURL}/test-suite/${testSuiteName}/test/${testName}/run?${this._queryString()}`
} else {
runURL = `${this.baseURL}/test-suite/${testSuiteName}/run?${this._queryString()}`
}
for (const key of Object.keys(configuration)) {
runURL += `&${key}=${configuration[key]}`
}
let response
try {
//console.info('calling URL: ' + runURL)
response = await axios.post(runURL, variables)
} catch (e) {
console.info('error: ' + e.response.status)
if (e.message.includes('401')) {
throw new Error('Invalid API key')
} else if (e.message.includes('404')) {
throw new Error('Provided test suite ID does not exist')
}
throw e
}
const runID = response.data.id
// console.info('test run ID: ' + runID)
return runID
}
/**
* Returns the status of the job run
* @param {string} runID
* @param {boolean} [includeResults = true]
* @returns {Promise<JobRunDTO>}
*/
async runStatus (runID, includeResults = true) {
const runURL = `${this.baseURL}/job-run/${runID}?include-results=${includeResults}`
const response = await axios.get(runURL)
return new JobRunDTO(response.data)
}
/**
* Saves a job
* * Creates a new job if none exists
* * Updates if it is already existing
* @param {any} json
* @returns {Promise<JobDTO>}
*/
async saveJob (json) {
const url = `${this.baseURL}/job?${this._queryString()}`
//console.info('save URL: ' + url + ' payload: ' + JSON.stringify(json, null, 2))
const response = await axios.post(url, json)
return new JobDTO(response.data)
}
/**
* @param {string} jobID
* @param {JobNotificationDTO} notification
* @returns {Promise<void>}
*/
async saveNotification (jobID, notification) {
const url = `${this.baseURL}/job/${jobID}/notification?${this._queryString()}`
await axios.post(url, notification.json)
}
/**
* Saves a record
* * Creates a new test suite if none exists
* * Updates if it is already existing
* @param {string} jobID
* @param {JobRecordDTO} record
* @returns {Promise<void>}
*/
async saveRecord (jobID, record) {
const url = `${this.baseURL}/job/${jobID}/record?${this._queryString()}`
await axios.post(url, record.json)
}
/**
* Saves a test suite
* * Creates a new test suite if none exists
* * Updates if it is already existing
* @param {TestSuiteDTO} testSuite
* @param {string | undefined} projectID
* @returns {Promise<TestSuiteDTO>}
*/
async saveTestSuite (testSuite, projectID) {
let url = `${this.baseURL}/test-suite?${this._queryString()}`
if (projectID) {
url += `&project-id=${projectID}`
}
const response = await axios.post(url, testSuite)
return TestSuiteDTO.fromJSON(response.data)
}
/**
* Runs the specified job, returns a unique ID to track the status of the run
* @param {string} jobID
* @returns {Promise<JobRunDTO>}
*/
async startJob (jobID) {
const runURL = `${this.baseURL}/job/${jobID}/start`
const response = await axios.get(runURL)
return new JobRunDTO(response.data)
}
/**
* Stops the specified run
* @param {string} runID
* @returns {Promise<void>}
*/
async stopRun (runID) {
const stopURL = `${this.baseURL}/job-run/${runID}/stop`
await axios.get(stopURL)
}
/**
* Checks the status of a test run
* @param {string} testRunID
* @returns {Promise<TestRunDTO>}
*/
async testStatus (testRunID) {
const url = `${this.baseURL}/test-run/${testRunID}?${this._queryString()}`
const response = await axios.get(url)
return new TestRunDTO(response.data)
}
/**
* @returns {string}
*/
_queryString () {
let apiKey = `api-key=${this.apiKey}`
if (this.organizationId) apiKey += `&org-id=${this.organizationId}`
return apiKey
}
/**
* @private
* @param {TestResultDTO[]} results
* @returns {void}
*/
_triggerTestResultCallback (results) {
if (!this._testResultCallback) {
return
}
if (!this._lastResults) {
this._testResultCallback(results)
this._lastResults = _.cloneDeep(results)
return
}
// If the number of results are different, trigger the callback
if (results.length > this._lastResults.length) {
this._testResultCallback(results)
this._lastResults = _.cloneDeep(results)
return
}
// If the number of interactions for a test are different, trigger the callback
for (let i = 0; i < results.length; i++) {
const newResult = results[i]
const oldResult = this._lastResults[i]
console.info('new result: ' + JSON.stringify(newResult))
console.info('old result: ' + JSON.stringify(oldResult))
if (newResult.interactions.length > oldResult.interactions.length) {
this._testResultCallback(results)
this._lastResults = _.cloneDeep(results)
return
}
}
}
/**
* @private
* @param {number} time
* @returns {Promise<void>}
*/
async _sleep (time) {
return new Promise((resolve) => {
setTimeout(() => {
resolve()
}, time)
})
}
}
/**
* Callback function to receive test results as they happen while a test is being run
* @callback TestResultCallback
* @param {TestResultDTO[]} testResults
*/
export default Client