import axios from 'axios'; import { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; const produceURL = 'https://www.varsnap.com/api/snap/produce/'; const consumeURL = 'https://www.varsnap.com/api/snap/consume/'; const produceTrialURL = 'https://www.varsnap.com/api/trial/produce/'; const trialGroupURL = 'https://www.varsnap.com/api/trial_group/'; export const version = '1.12.0'; export const clientName = 'javascript.' + version; export interface Config { varsnap: boolean; env: string; branch: string; producerToken?: string; consumerToken?: string; logger?: Logger; } type ConfigKeys = keyof Config; const defaultConfig: Config = { varsnap: true, env: 'development', branch: '', producerToken: '', consumerToken: '', logger: console, }; type rawJSONObject = ConsumeSnapResponse | ProduceTrialResponse | TrialGroupRequest | TrialGroupResponse | Record; let Producers: Producer[] = []; let Consumers: Consumer[] = []; export interface Logger { debug(s: string): void; log(s: string): void; error(s: string): void; } export class Util { static getConfig(config: Config, configKey: ConfigKeys): string { if (!config) { return ''; } let val = config[configKey]; if (!val) { return ''; } val = val.toString().toLowerCase(); return val; } static setConfig(config: Config, configKey: ConfigKeys, configValue: string|boolean|Logger|undefined): Config { if (configKey === 'varsnap') { if (typeof configValue === 'string') { configValue = configValue === 'true'; } config[configKey] = configValue as boolean; } if (configKey === 'env' || configKey === 'producerToken' || configKey === 'consumerToken' || configKey === 'branch') { config[configKey] = configValue as string; } if (configKey === 'logger') { config[configKey] = configValue as Logger; } return config; } static getSignature(targetFunc: TargetFunction, funcName: string): string { let signature; if (funcName !== '' && funcName !== undefined) { signature = funcName; } else { // Remove any implementation code from signature signature = targetFunc.toString().split('{')[0]; // Trim preceding and trailing whitespace signature = signature.replace(/^\s+|\s+$/g, ''); // Trim whitespace between comma separated arguments signature = signature.replace(/,\s+/g, ','); } return 'javascript.' + version + '.' + signature; } static ajax(url: string, requestData: rawJSONObject): Promise { const requestParams = new URLSearchParams(requestData as Record); const data = requestParams.toString(); const options: AxiosRequestConfig = { method: 'POST' as const, url: url, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, data: data, responseType: 'json' as const, }; if (Util.isNode() && options.headers) { options.headers['User-Agent'] = 'varsnap.js'; } const ajaxPromise = axios(options).then((response: AxiosResponse) => { return response.data; }).catch((error: AxiosError) => { return error; }); return ajaxPromise; } static debug(config: Config, data: string): void { if (config.logger) { config.logger.debug(data); } } static log(config: Config, data: string): void { if (config.logger) { config.logger.log(data); } } static error(config: Config, data: string): void { if (config.logger) { config.logger.error(data); } } static resetProducers(): void { Producers = []; } static resetConsumers(): void { Consumers = []; } static getProducers(): Producer[] { return Producers; } static getConsumers(): Consumer[] { return Consumers; } static limitString(x: TargetFunctionInput[] | TargetFunctionOutput): string { const limitedString = String(x); const limit = 30; const ellipsis = '...'; if (limitedString.length <= limit) { return limitedString; } return limitedString.substring(0, 30 - ellipsis.length) + ellipsis; } static isNode(): boolean { if (typeof process !== 'object') { return false; } if (typeof window !== 'undefined') { return false; } return true; } } export class Producer { public targetFunc: TargetFunction; public signature: string; public config: Config; constructor(targetFunc: TargetFunction, signature: string, config: Config) { this.targetFunc = targetFunc; this.signature = signature; this.config = config; } isEnabled(): boolean { if (!this.config.varsnap) { return false; } if (Util.getConfig(this.config, 'env') !== 'production') { return false; } if (!Util.getConfig(this.config, 'producerToken')) { return false; } return true; } static serialize(data: TargetFunctionInput[] | TargetFunctionOutput): string { return JSON.stringify(data); } static serialize_formatted(data: TargetFunctionInput[] | TargetFunctionOutput): string { return JSON.stringify(data, null, 2); } produce(args: TargetFunctionInput[], output: TargetFunctionOutput): Promise { if (!this.isEnabled()) { const errorPromise = new Promise((resolve) => { return resolve(false); }); return errorPromise; } Util.debug(this.config, 'Sending call to Varsnap'); return Promise.resolve(output).then((outputValue) => { const data = { 'producer_token': Util.getConfig(this.config, 'producerToken'), 'signature': this.signature, 'inputs': Producer.serialize(args), 'prod_outputs': Producer.serialize(outputValue), }; return Util.ajax(produceURL, data).then((): boolean => { return true; }).catch((err: AxiosError): boolean => { if(err.response) { if(err.response.status === 429) { Util.error(this.config, 'Cannot produce to Varsnap; Signature at rate limit'); return true; } if(err.response.status === 404) { Util.error(this.config, 'Cannot produce to Varsnap; check your producer token'); } else { Util.error(this.config, err.response.statusText); } } return false; }); }); } } interface ConsumeSnapResponse { status: string; results: Snap[]; } interface Snap { id: string; inputs: string; prod_outputs: string; } interface ProduceTrialResponse { status: string; trial_url: string; } export class Consumer { public targetFunc: TargetFunction; public signature: string; public config: Config; constructor(targetFunc: TargetFunction, signature: string, config: Config) { this.targetFunc = targetFunc; this.signature = signature; this.config = config; } isEnabled(): boolean { if (!this.config.varsnap) { return false; } if (Util.getConfig(this.config, 'env') !== 'development') { return false; } if (!Util.getConfig(this.config, 'consumerToken')) { return false; } return true; } static deserialize(data: string): TargetFunctionInput[] | TargetFunctionOutput | undefined { if (data === '') { return undefined; } return JSON.parse(data); } consume(trialGroup: TrialGroupResponse): Promise { if (!this.isEnabled()) { const errorPromise = new Promise((resolve) => { Util.error(this.config, 'Varsnap not enabled for testing'); return resolve(false); }); return errorPromise; } const data = { 'consumer_token': Util.getConfig(this.config, 'consumerToken'), 'signature': this.signature, }; const ajax = Util.ajax(consumeURL, data); const consume = ajax.then((rawResponse: rawJSONObject): Promise[] => { const response = rawResponse as ConsumeSnapResponse; if (!response || response.status !== 'ok') { const errorPromise = new Promise((resolve) => { Util.error(this.config, 'Received error from Varsnap: ' + response.status); return resolve(false); }); return [errorPromise]; } if (response.results.length === 0) { Util.log(this.config, 'No snaps found for signature: ' + data.signature); } const trialPromises: Promise[] = []; for(const snap of response.results) { const inputs: TargetFunctionInput[] = Consumer.deserialize(snap['inputs']) as TargetFunctionInput[]; const prodOutputs: TargetFunctionOutput = Consumer.deserialize(snap['prod_outputs']) as TargetFunctionOutput; let localOutputs: TargetFunctionOutput; try { localOutputs = this.targetFunc(...inputs); } catch(err) { localOutputs = err; } const trialPromise = Promise.resolve(localOutputs).then((localOutputValue) => { const prodOutputsSerialized = Producer.serialize(prodOutputs); const localOutputsSerialized = Producer.serialize(localOutputValue); const matches = prodOutputsSerialized === localOutputsSerialized; this.reportCentral(trialGroup, snap, prodOutputs, localOutputValue, matches).then((trialURL) => { this.reportLog(snap['id'], inputs, prodOutputs, localOutputValue, matches, trialURL); }); return matches; }); trialPromises.push(trialPromise); } return trialPromises; }).then((trialPromises: Promise[]): Promise => { return Promise.all(trialPromises).then((results) => { const allMatches = results.every((result) => result); return allMatches; }); }).catch((error: AxiosError) => { Util.error(this.config, 'Cannot contact Varsnap: ' + error); return false; }); return consume; } reportCentral(trialGroup: TrialGroupResponse, snap: Snap, prodOutputs: TargetFunctionOutput, localOutputs: TargetFunctionOutput, matches: boolean): Promise { const data = { 'trial_group_id': trialGroup.trial_group_id, 'consumer_token': Util.getConfig(this.config, 'consumerToken'), 'snap_id': snap.id, 'test_outputs': Producer.serialize(localOutputs), 'matches': matches.toString(), 'snap_outputs_formatted': Producer.serialize_formatted(prodOutputs), 'test_outputs_formatted': Producer.serialize_formatted(localOutputs), }; const ajax = Util.ajax(produceTrialURL, data); const report = ajax.then((rawResponse: rawJSONObject): string|boolean => { const response = rawResponse as ProduceTrialResponse; if (!response || response.status !== 'ok') { return false; } const trialURL = response['trial_url']; return trialURL; }).catch((error: AxiosError) => { Util.error(this.config, 'Cannot upload test results to Varsnap: ' + error); return false; }); return report; } reportLog(snapID: string, inputs: TargetFunctionInput[], prodOutputs: TargetFunctionOutput, localOutputs: TargetFunctionOutput, matches: boolean, trialURL: string|boolean): void { if (matches) { return; } Util.log(this.config, ''); Util.log(this.config, 'Testing with Varsnap uuid: ' + snapID); Util.log(this.config, 'Signature: ' + this.signature); Util.log(this.config, 'Function input args: ' + Util.limitString(inputs)); Util.log(this.config, 'Production function outputs: ' + Util.limitString(prodOutputs)); Util.log(this.config, 'Your function outputs: ' + Util.limitString(localOutputs)); Util.log(this.config, 'Matching outputs: ' + matches); let trialURLView = 'Cannot upload trial'; if(trialURL) { trialURLView = String(trialURL); } Util.log(this.config, 'Trial URL: ' + trialURLView); } reportTrialGroup(trialGroup: TrialGroupResponse): void { Util.log(this.config, 'Trial Group: ' + trialGroup.trial_group_url); } } export type TargetFunctionInput = any; // eslint-disable-line @typescript-eslint/no-explicit-any export type TargetFunctionOutput = any; // eslint-disable-line @typescript-eslint/no-explicit-any export interface TargetFunction { (...args: TargetFunctionInput[]): TargetFunctionOutput; } export interface WrappedFunction { (...args: TargetFunctionInput[]): TargetFunctionOutput; producer: Producer; consumer: Consumer; signature: string; } export function varsnap(func: TargetFunction, funcName?: string): TargetFunction|WrappedFunction { if (funcName === undefined) { funcName = ''; } const signature = Util.getSignature(func, funcName); const producer = new Producer(func, signature, varsnap._config); const consumer = new Consumer(func, signature, varsnap._config); const wrapped = function(...args: TargetFunctionInput[]): TargetFunctionOutput { let result: TargetFunctionOutput; try { result = func(...args); } catch(err) { producer.produce(args, err); throw(err); } producer.produce(args, result); return result; }; wrapped.producer = producer; wrapped.consumer = consumer; wrapped.signature = signature; Producers.push(producer); Consumers.push(consumer); return wrapped; } varsnap._config = defaultConfig; varsnap.updateConfig = function updateConfig(newConfig: Config): void { let config = varsnap._config; for (const key of Object.keys(varsnap._config) as ConfigKeys[]) { if (Object.prototype.hasOwnProperty.call(newConfig, key)) { config = Util.setConfig(config, key, newConfig[key]); } } varsnap._config = config; }; varsnap.reset = function reset(): void { Util.resetConsumers(); Util.resetProducers(); }; varsnap.version = version; interface TrialGroupRequest { branch: string; consumer_token: string; client: string; } interface TrialGroupResponse { status: string; project_id: string; trial_group_id: string; trial_group_url: string; } interface TrialGroupError { status: string; error: string; } function getTrialGroup(consumerConfig: Config): Promise { const data = { 'branch': Util.getConfig(consumerConfig, 'branch'), 'consumer_token': Util.getConfig(consumerConfig, 'consumerToken'), 'client': clientName, }; const raw = data as rawJSONObject; const ajax = Util.ajax(trialGroupURL, raw); const trialGroup = ajax.then((rawResponse: rawJSONObject): TrialGroupResponse => { const response = rawResponse as TrialGroupResponse; return response; }).catch((error: AxiosError) => { let errorMessage; if (error && error.response && error.response.data) { const response = error.response.data as TrialGroupError; errorMessage = response.error; } else { errorMessage = error.toString(); } Util.error(consumerConfig, 'Cannot generate a new Trial Group: ' + errorMessage); return { status: 'not ok', project_id: '', trial_group_id: '', trial_group_url: '', }; }); return trialGroup; } export function runTests(): Promise { const consumers = Util.getConsumers(); if (consumers.length === 0) { return Promise.resolve(true); } const consumerConfig = consumers[0].config; return getTrialGroup(consumerConfig).then((trialGroup: TrialGroupResponse): Promise> => { const testPromises = []; for(const consumer of consumers) { const testPromise = consumer.consume(trialGroup); testPromises.push(testPromise); } if (consumers.length > 0) { consumers[0].reportTrialGroup(trialGroup); } return Promise.all(testPromises); }).then((results: Array) => { const allPass = results.every((result) => result); return allPass; }); } varsnap.runTests = runTests;