import _ from 'lodash' import axios from 'axios' import DeviceDTO from './device-dto.js' import JobDTO from './job-dto.js' import TestResultDTO from './test-result-dto.js' import TestRunDTO from './test-run-dto.js' import TestSuiteDTO from './test-suite-dto.js' import JobRecordDTO from './job-record-dto.js' import JobRunDTO from './job-run-dto.js' import JobTemplateDTO from './job-template-dto.js' import JobResultDTO from './job-result-dto.js' import JobNotificationDTO from './job-notification-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 (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 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) } /** * @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 } /** * 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>} [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 { 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') } 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 while (status === 'IN_PROGRESS') { await this._sleep(1000) try { runStatusResponse = await axios.get(statusURL) status = runStatusResponse.data.status consecutiveErrors = 0 } 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 } /** * 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()}` const response = await axios.post(url, json) return new JobDTO(response.data) } /** * 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 } /** * @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 * @returns {Promise<void>} */ async saveTestSuite (testSuite) { const url = `${this.baseURL}/test-suite?${this._queryString()}` await axios.post(url, testSuite) } /** * 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