UNPKG

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
/** * 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; };