test-scribe
Version:
Your sidekick for analog machine development. Run machines interactively and generate automated test scripts based on the outcome.
666 lines (537 loc) • 23.5 kB
JavaScript
/**
* Module dependencies
*/
var path = require('path');
var _ = require('lodash');
var Machine = require('machine');
var Machines = require('machinepack-machines');
var rttc = require('rttc');
module.exports = function machinePreviewHook (sails) {
var thisHook = {
/**
* Default values for `sails.config.machinepreview`
* @type {Object}
*/
defaults: {
machinepreview: {
pathToPack: path.resolve(__dirname,'../../../../')
}
}, // </thisHook.defaults>
/**
* Logic to run when server lifts.
*
* @param {Function} cb
*/
initialize: function (cb){
// Initialize the "cache" to ensure the exemplars typed up
// for documentation purposes are not interpreted literally.
thisHook.cache = rttc.getBaseVal(thisHook.cache);
// `sails.config.machinepreview.pathToPack` is used below to reference the machinepack
// being previewed. Resolve path as relative against appPath if necessary.
// CONSIDER: normalizing `config` and `cache` into one thing.
sails.config.machinepreview.pathToPack = path.resolve(sails.config.appPath, sails.config.machinepreview.pathToPack);
// `thisHook.machines` is a set of subroutines exposed by this hook.
// (Note that these are not the machines being previewed! Just helpers! Sorry for the confusion!!)
// Each one of them is callable using standard machine semantics (see node-machine.org)
// This allows our hook to expose functionality that can be called at-will by user code,
// or even by other hooks (without needing to expose any `routes`).
_.each(thisHook.machines, function (machineDef, methodName) {
thisHook.machines[methodName] = Machine.build(machineDef);
});
// Reload the pack's package.json metadata, as well as the referenced machines
// and their dependencies.
thisHook.machines.reloadPack({
thisHook: thisHook,
pathToPack: sails.config.machinepreview.pathToPack
}).exec({
error: function (err) {
// If reloading the pack failed, it's ok for now.
return cb();
},
success: function (){
// Done initializing hook.
return cb();
}
});
}, // </thisHook.initialize>
/**
* Routes exposed by this hook.
* @type {Object}
*/
routes: {
after: {
/**
* Show webpage
*/
'/': function (req, res) {
// Reload the pack's package.json metadata, as well as the referenced machines
// and their dependencies.
thisHook.machines.reloadPack({
thisHook: thisHook,
pathToPack: req._sails.config.machinepreview.pathToPack
}).exec(function (err) {
// If reloading the pack failed, set an error in the view locals
// or JSON response so the user-agent consuming this endpoint knows
// what's going on. But otherwise, proceed normally.
var packLoadError = null;
if (err) {
packLoadError = {
message: 'Sorry, this pack could not be loaded.',
details: err.stack
};
}
if (req.wantsJSON) {
return res.json({
error: packLoadError,
packIdentity: thisHook.cache.packMetadata.identity,
machineDefs: thisHook.cache.machineDefs
});
}
// Expose pack identity and preview actions in view locals.
return res.view('homepage', {
error: packLoadError,
packIdentity: thisHook.cache.packMetadata.identity,
machineDefs: thisHook.cache.machineDefs,
pathToPack: req._sails.config.machinepreview.pathToPack
});
});
},
/**
* Save a new test (a set of inputs and the expecte value) for a particular
* machine.
*/
'put /preview/:identity/test': function (req, res) {
// Look up appropriate machine instance.
var machine = _.find(thisHook.cache.machineDefs, {identity: req.param('identity')});
if (!machine) throw (function (){
var _err = new Error('unknown machine: '+req.param('identity'));
_err.code = _err.exit = 'notFound';
return _err;
})();
// Sanitize `using`
var config = thisHook.machines.sanitizeConfig({
config: req.param('using'),
machineDef: machine
}).execSync();
Machines.addTest({
dir: req._sails.config.machinepreview.pathToPack,
identity: req.param('identity'),
using: _.reduce(config, function (_config, configItem){
// Convert config into normal, everyday json format (an object with input names as keys)
_config[configItem.name] = configItem.value;
return _config;
}, {}),
outcome: req.param('outcome'),
returns: _.isUndefined(req.param('returns')) ? undefined : JSON.parse(req.param('returns')),
before: req.param('before'),
after: req.param('after')
}).exec({
error: function (err){
return res.negotiate(err);
},
success: function (){
return res.ok();
}
});
},
/**
* Run one of the machines in this pack.
*
* @param {String} identity
* @param {Object} config
*/
'post /machinepreview/:identity': function (req, res){
// Look up appropriate machine instance.
var machine = _.find(thisHook.cache.machineDefs, {identity: req.param('identity')});
if (!machine) {
var err = new Error('unknown machine: '+req.param('identity'));
err.exit = 'notFound';
err.code = err.exit;
throw err;
}
// Sanitize `config`
var config = thisHook.machines.sanitizeConfig({
config: req.param('config'),
machineDef: machine
}).execSync();
thisHook.machines.previewMachine({
thisHook: thisHook,
internalTmpDirPath: path.resolve(sails.config.paths.tmp, 'previously-used-input-vals'),
pathToPack: sails.config.machinepreview.pathToPack,
identity: req.param('identity'),
config: config
}, {
error: function (err){
return res.negotiate(err);
},
notFound: function (){
return res.notFound();
},
success: function (result) {
return res.send(result);
}
});
}
}// </thisHook.routes.after>
}, // </thisHook.routes>
/**
* `thisHook.cache` is a data store used internally by this hook to store its runtime state.
* It is just a set of key-value pairs. Warning: this data store is not persisted- it is in-memory.
* Consequently it will not be synchronized/available across server instances, and will be wiped
* when the server shuts down.
*
* @type {Object}
*
* CONSIDER: this could perhaps represent a set of examples representing the allowable state
* for members of the cache. Needs experimentation.
*/
cache: {
machineDefs: '===',
packMetadata: '==='
},
/**
* Machines exposed by this hook.
* @type {Object}
*/
machines: {
previewMachine: {
inputs: {
thisHook: {
example: '===',
required: true
},
internalTmpDirPath: {
example: '/Users/mikermcneil/foo/bar/.tmp',
required: true
},
pathToPack: {
example: '/Users/mikermcneil/foo/bar',
required: true
},
identity: {
example: 'some-machine',
required: true
},
config: {
example: [{
name: 'someInput',
value: '==='
}]
},
},
exits: {
notFound: {},
success: {
example :'*'
}
},
fn: function (inputs, exits){
var path = require('path');
var rttc = require('rttc');
var machine = _.find(inputs.thisHook.cache.machineDefs, {identity: inputs.identity});
if (!machine) return exits.notFound();
// Run a machine in the specified local pack using the provided input values.
Machines.runMachine({
machinepackPath: inputs.pathToPack,
identity: inputs.identity,
inputValues: inputs.config,
}).exec({
// An unexpected error occurred.
error: function(err) {
return exits.error(err);
},
// OK.
success: function (machineRunResult){
// console.log('=============\nRan `%s` w/ input configuration: \n', inputs.identity, config);
// Get human-readable type string representing the output
machineRunResult.outputType = rttc.getDisplayType(machineRunResult.output);
// Before sending output back down, dehydrate it so that it won't get coerced
// in a weird way by being stringified to JSON. For example, if the output was
// an instance of Error, we'll transform it into its own `.stack` property...
// ...instead of what would happen by default-- it getting coerced to `{}`.
// (also note that we allow `null` values)
machineRunResult.output = rttc.dehydrate(machineRunResult.output, true);
// ^this step is sort of unnecessary now that this is a proper machine w/ exit example==="*"
// (should happen automatically) Still, we want to do things before exiting (like save the
// previous input val) so it makes sense to do this here.
inputs.thisHook.machines.savePreviousInputVal({
thisHook: inputs.thisHook,
internalTmpDirPath: inputs.internalTmpDirPath,
identity: inputs.identity,
machineInputDefs: machine.inputs,
config: inputs.config
}).exec({
error: function (err) {
console.warn('Sorry, I couldn\'t save this input value for next time because I couldn\'t read or write to the temporary JSON file I use for storing this sort of thing. Details:\n',err);
return exits.success(machineRunResult);
},
success: function (){
return exits.success(machineRunResult);
}
});
}
});
}
},
savePreviousInputVal: {
inputs: {
thisHook: {
example: '==='
},
machineInputDefs: {
example: {}
},
internalTmpDirPath: {
example: '/Users/mikermcneil/foo/bar/.tmp',
required: true
},
identity: {
example: 'some-machine',
required: true
},
config: {
example: [{
name: 'someInput',
value: '==='
}]
},
},
fn: function (inputs, exits) {
var path = require('path');
var _ = require('lodash');
var Filesystem = require('machinepack-fs');
var rttc = require('rttc');
// Save this as a previous input value in our `.tmp/previously-used-input-vals/MACHINEPACK_IDENTITY.json`. file
var tmpFileName = inputs.thisHook.cache.packMetadata.identity + '.json';
var tmpFilePath = path.join(inputs.internalTmpDirPath, tmpFileName);
// Ensure JSON file exists.
Filesystem.ensureJson({
path: tmpFilePath,
schema: [{
machineIdentity: 'some-machine-identity',
inputName: 'someInputName',
value: '*'
}]
}).exec({
// An unexpected error occurred.
error: function(err) {
return exits.error(err);
},
success: function(previousInputVals) {
_.each(inputs.config, function (configuredInput) {
// Look up already saved things for this input.
var alreadySaved = _.where(previousInputVals, { machineIdentity: inputs.identity, inputName: configuredInput.name });
// Don't save duplicates
var typeSchema;
try { typeSchema = rttc.infer(inputs.machineInputDefs[configuredInput.name].example); }
catch (e) { console.warn('Could not infer type schema for "%s" input.', configuredInput.name); }
var isDuplicate = !!_.find(alreadySaved, function(previousValForThisInput){
return rttc.isEqual(previousValForThisInput.value, configuredInput.value, typeSchema);
});
if (isDuplicate){
return;
}
// Only save 3 previous input values for any given input+machine combo.
var MAX_NUM_VALS_TO_SAVE = 3;
if (alreadySaved.length >= MAX_NUM_VALS_TO_SAVE) {
var toRemove = alreadySaved.slice(MAX_NUM_VALS_TO_SAVE-1);
_.remove(previousInputVals, function (previousInputVal){
var matchedAtIndex = _.indexOf(toRemove, previousInputVal);
return (matchedAtIndex !== -1);
});
}
// Save
previousInputVals.unshift({
machineIdentity: inputs.identity,
inputName: configuredInput.name,
value: configuredInput.value
});
});
// Now update the file on disk.
Filesystem.writeJson({
json: previousInputVals,
destination: tmpFilePath,
force: true
}).exec({
// An unexpected error occurred.
error: function(err) {
return exits.error(err);
},
// OK.
success: function() {
return exits.success();
},
});
}
});
}
},
/**
* .reloadPack()
*/
reloadPack: {
description: 'Reload this pack, picking up any new changes that were made to its machines or their dependencies.',
extendedDescription: 'Note that this reloads code files and constructs a new Pack instance by clearing the require cache and re-requiring the machinepack\'s index.js file from disk. This also refreshes the metadata saved on the hook itself (in `.cache.packMetadata` and `.cache.machines`)',
cacheable: true,
inputs: {
thisHook: { example: '===' },
pathToPack: {
example: '/Users/mikermcneil/foo/bar'
}
},
exits: {
notMachinepack: {
description : 'The specified path is not the root directory of a machinepack.'
},
success: {
description: 'A monkey-patched version of the Pack instance, as well as package.json metadata and machine defs have been saved on `thisHook.cache`.'
}
},
fn: function (inputs, exits){
// Expose `thisHook` as a local variable for convenience.
var thisHook = inputs.thisHook;
// Expose machinepack identity
// Read and parse the package.json file of a local pack in the specified directory.
Machines.readPackageJson({
dir: inputs.pathToPack
}).exec({
// An unexpected error occurred.
error: exits.error,
// The specified path is not the root directory of a machinepack
notMachinepack: exits.notMachinepack,
// OK.
success: function(packMetadata) {
// Save (or re-save) reference to pack metadata
thisHook.cache.packMetadata = packMetadata;
// Completely wipe require cache in order to get a fresh instance of pack
// and any packages it requires (in case it's been updated since last refresh)
_.each(require.cache, function(val, key) {
delete require.cache[key];
});
// Require the pack and store it as `thisHook.cache.machineDefs`
try {
thisHook.cache.machineDefs = require(inputs.pathToPack);
}
catch (e) {
// CONSIDER: attempt to run npm install in the pack directory automatically if the pack can't be required
// TODO: but even if that doesn't make sense, negotiate a better error msg.
return exits.error(e);
}
// Loop over the set of machine defs in this pack, adding in some extra properties
// for display purposes; but more importantly ensuring that they are "real" objects.
thisHook.cache.machineDefs = _.reduce(thisHook.cache.machineDefs, function (memo, callableMachinePrototype, methodName){
// If callableMachinePrototype doesn't have a friendlyName, it might be because
// the pack is using an older version of the machine runner that doesn't expose
// the friendlyName on the callable prototype. In order to get it, we need to access
// the raw machine instance. So lets do that.
if (!callableMachinePrototype.friendlyName) {
callableMachinePrototype.friendlyName = callableMachinePrototype().friendlyName;
callableMachinePrototype.description = callableMachinePrototype().description;
callableMachinePrototype.inputs = callableMachinePrototype().inputs;
callableMachinePrototype.exits = callableMachinePrototype().exits;
callableMachinePrototype.cacheable = callableMachinePrototype().cacheable;
}
// Build a normal object from the callable machine prototype.
var dehydratedDef = _.reduce(_.keys(callableMachinePrototype), function (memo, propName){
memo[propName] = callableMachinePrototype[propName];
return memo;
}, {});
// Make sure inputs have "id"s, and also add a few additional properties
// that provide for a richer experience.
_.each(dehydratedDef.inputs, function (inputDef, inputName){
dehydratedDef.inputs[inputName].id = inputName;
dehydratedDef.inputs[inputName].displayType = rttc.getDisplayType(inputDef.example);
dehydratedDef.inputs[inputName].typeSchema = rttc.infer(inputDef.example);
// Build a "displayExample" string so we have something nice as placeholder text
// If input type is "strict" at the top level at least (i.e. not a generic like {}/[]/json/ref)
// use the provided example-- otherwise use rttc.sample().
if (rttc.isStrictType(inputDef.typeSchema)) {
try {
dehydratedDef.inputs[inputName].displayExample = rttc.stringifyHuman(inputDef.example, inputDef.typeSchema);
}
catch (e) {
// If that doesn't work (e.g. for lamda) then just use `rttc.sample()`
dehydratedDef.inputs[inputName].displayExample = rttc.stringifyHuman(rttc.sample(inputDef.typeSchema)[0], inputDef.typeSchema);
}
}
else {
dehydratedDef.inputs[inputName].displayExample = _.map(rttc.sample(inputDef.typeSchema), function (eg){ return rttc.stringifyHuman(eg, inputDef.typeSchema); }).join(' -or- ');
}
});
memo[methodName] = dehydratedDef;
return memo;
}, {});
return exits.success();
},
});
}
},
/**
* .sanitizeConfig()
*/
sanitizeConfig: {
sync: true,
cacheable: true,
description: 'Parse a set of JSON-stringified input values which came from a human.',
inputs: {
machineDef: { example: '===' },
config: { example: 'some json string' }
},
exits: {
success: {
example: [{
name: 'some input name',
value: '*'
}]
}
},
fn: function (inputs, exits) {
var config = inputs.config;
var machineDef = inputs.machineDef;
// Grab `config` parameter, which contains all input values as raw strings--
// i.e. they have not been parsed yet
try {
config = JSON.parse(config);
}
catch (e) {
throw new Error('Could not parse JSON provided for input configurations.');
}
// If no input definition exists for one of the configured input values,
// log a warning and then skip it.
_.remove(config, function(configuredInput) {
var inputDef = machineDef.inputs[configuredInput.name];
if (!inputDef) {
console.warn('');
console.warn('You provided an unknown input, `'+configuredInput.name+'`.');
console.warn('Perhaps it was removed, or its name was changed? Because it\'s not declared in the machine definition.');
console.warn('(skipping it...)');
return true;
}
});
// Understand '' input values (skip optional inputs for which an empty string was provided)
_.remove(config, function(configuredInput) {
var inputDef = machineDef.inputs[configuredInput.name];
// If input is optional and empty string was provided as the configured
// value, omit the input value.
if (!inputDef.required && configuredInput.value === '') {
return true;
}
});
// Parse human-entered input strings
config = _.reduce(config, function (memo, configuredInput) {
var inputDef = machineDef.inputs[configuredInput.name];
// console.log('tRYING TO PARSE HUMAN FROM:\n',configuredInput.value);
// console.log('Which will cause validation against example:\n',require('util').inspect(inputDef.example, false, null));
memo.push({
name: configuredInput.name,
value: rttc.parseHuman(configuredInput.value, rttc.infer(inputDef.example), true)
});
return memo;
}, []);
return exits.success(config);
}
}
}// </thisHook.machines>
};// </thisHook definition>
return thisHook;
};