From 440d09f8b790d0b09ddb34f84db25b0f52081814 Mon Sep 17 00:00:00 2001 From: Trent Mick Date: Mon, 7 Dec 2015 11:28:59 -0800 Subject: [PATCH] joyent/node-triton#59 `triton create -m,--metadata` etc. for adding metadata on instance creation --- CHANGES.md | 10 +- lib/do_create_instance.js | 285 ++++++++++++++++++- lib/errors.js | 68 +++-- package.json | 4 +- test/integration/cli-manage-workflow.test.js | 17 +- test/integration/script-log-boot.sh | 4 + test/unit/corpus/metadata-illegal-types.json | 4 + test/unit/corpus/metadata-invalid-json.json | 3 + test/unit/corpus/metadata.json | 5 + test/unit/corpus/metadata.kv | 3 + test/unit/corpus/user-script.sh | 2 + test/unit/metadataFromOpts.test.js | 225 +++++++++++++++ 12 files changed, 591 insertions(+), 39 deletions(-) create mode 100644 test/integration/script-log-boot.sh create mode 100644 test/unit/corpus/metadata-illegal-types.json create mode 100644 test/unit/corpus/metadata-invalid-json.json create mode 100644 test/unit/corpus/metadata.json create mode 100644 test/unit/corpus/metadata.kv create mode 100644 test/unit/corpus/user-script.sh create mode 100644 test/unit/metadataFromOpts.test.js diff --git a/CHANGES.md b/CHANGES.md index 9d7ba0b..d1404ee 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,8 +1,14 @@ # node-triton changelog -## 3.2.1 (not yet released) +## 3.3.0 (not yet released) -(nothing yet) +- #59 CLI options to `triton create` to add metadata on instance creation: + - `triton create -m,--metadata KEY=VALUE` to add a single value + - `triton create -m,--metadata @FILE` to add values from a JSON + or key/value-per-line file + - `triton create -M,--metadata-file KEY=FILE` to set a key from a file + - `triton create --script FILE` to set the special "user-script" key + from a file ## 3.2.0 diff --git a/lib/do_create_instance.js b/lib/do_create_instance.js index cd782c8..d01f722 100644 --- a/lib/do_create_instance.js +++ b/lib/do_create_instance.js @@ -10,8 +10,12 @@ * `triton create ...` */ +var assert = require('assert-plus'); var format = require('util').format; +var fs = require('fs'); +var strsplit = require('strsplit'); var tabula = require('tabula'); +var tilde = require('tilde-expansion'); var vasync = require('vasync'); var common = require('./common'); @@ -19,19 +23,242 @@ var distractions = require('./distractions'); var errors = require('./errors'); -function do_create_instance(subcmd, opts, args, callback) { +// ---- loading/parsing metadata from relevant options + +/* + * Load and validate metadata from these options: + * -m,--metadata DATA + * -M,--metadata-file KEY=FILE + * --script FILE + * + * + * + * says values may be string, num or bool. + */ +function metadataFromOpts(opts, log, cb) { + assert.arrayOfObject(opts._order, 'opts._order'); + assert.object(log, 'log'); + assert.func(cb, 'cb'); + + var metadata = {}; + + vasync.forEachPipeline({ + inputs: opts._order, + func: function metadataFromOpt(o, next) { + log.trace({opt: o}, 'metadataFromOpt'); + if (o.key === 'metadata') { + if (!o.value) { + next(new errors.UsageError( + 'empty metadata option value')); + return; + } else if (o.value[0] === '{') { + _addMetadataFromJsonStr(metadata, o.value, null, next); + } else if (o.value[0] === '@') { + _addMetadataFromFile(metadata, o.value.slice(1), next); + } else { + _addMetadataFromKvStr(metadata, o.value, null, next); + } + } else if (o.key === 'metadata_file') { + _addMetadataFromKfStr(metadata, o.value, null, next); + } else if (o.key === 'script') { + _addMetadatumFromFile(metadata, 'user-script', o.value, + o.value, next); + } else { + next(); + } + } + }, function (err) { + if (err) { + cb(err); + } else if (Object.keys(metadata).length) { + cb(null, metadata); + } else { + cb(); + } + }); +} + + +var allowedTypes = ['string', 'number', 'boolean']; +function _addMetadatum(metadata, key, value, from, cb) { + assert.object(metadata, 'metadata'); + assert.string(key, 'key'); + assert.optionalString(from, 'from'); + assert.func(cb, 'cb'); + + if (allowedTypes.indexOf(typeof (value)) === -1) { + cb(new errors.UsageError(format( + 'invalid metadata value type: must be one of %s: %s=%j', + allowedTypes.join(', '), key, value))); + return; + } + + if (metadata.hasOwnProperty(key)) { + var valueStr = value.toString(); + console.error( + 'warning: metadata "%s=%s"%s replaces earlier value for "%s"', + key, + (valueStr.length > 10 + ? valueStr.slice(0, 7) + '...' : valueStr), + (from ? ' (from ' + from + ')' : ''), + key); + } + metadata[key] = value; + cb(); +} + +function _addMetadataFromObj(metadata, obj, from, cb) { + assert.object(metadata, 'metadata'); + assert.object(obj, 'obj'); + assert.optionalString(from, 'from'); + assert.func(cb, 'cb'); + + vasync.forEachPipeline({ + inputs: Object.keys(obj), + func: function _oneField(key, next) { + _addMetadatum(metadata, key, obj[key], from, next); + } + }, cb); +} + +function _addMetadataFromJsonStr(metadata, s, from, cb) { + try { + var obj = JSON.parse(s); + } catch (parseErr) { + cb(new errors.TritonError(parseErr, + format('metadata%s is not valid JSON', + (from ? ' (from ' + from + ')' : '')))); + return; + } + _addMetadataFromObj(metadata, obj, from, cb); +} + +function _addMetadataFromFile(metadata, file, cb) { + tilde(file, function (metaPath) { + fs.stat(metaPath, function (statErr, stats) { + if (statErr || !stats.isFile()) { + cb(new errors.TritonError(format( + '"%s" is not an existing file', file))); + return; + } + fs.readFile(metaPath, 'utf8', function (readErr, data) { + if (readErr) { + cb(readErr); + return; + } + /* + * The file is either a JSON object (first non-space + * char is '{'), or newline-separated key=value + * pairs. + */ + var dataTrim = data.trim(); + if (dataTrim.length && dataTrim[0] === '{') { + _addMetadataFromJsonStr(metadata, dataTrim, file, cb); + } else { + var lines = dataTrim.split(/\r?\n/g).filter( + function (line) { return line.trim(); }); + vasync.forEachPipeline({ + inputs: lines, + func: function oneLine(line, next) { + _addMetadataFromKvStr(metadata, line, file, next); + } + }, cb); + } + }); + }); + }); +} + +function _addMetadataFromKvStr(metadata, s, from, cb) { + var parts = strsplit(s, '=', 2); + if (parts.length !== 2) { + cb(new errors.UsageError( + 'invalid KEY=VALUE metadata argument: ' + s)); + return; + } + var value = parts[1]; + var valueTrim = value.trim(); + if (valueTrim === 'true') { + value = true; + } else if (valueTrim === 'false') { + value = false; + } else { + var num = Number(value); + if (!isNaN(num)) { + value = num; + } + } + _addMetadatum(metadata, parts[0].trim(), value, from, cb); +} + +/* + * Add metadata from `KEY=FILE` argument. + * Here "Kf" stands for "key/file". + */ +function _addMetadataFromKfStr(metadata, s, from, cb) { + var parts = strsplit(s, '=', 2); + if (parts.length !== 2) { + cb(new errors.UsageError( + 'invalid KEY=FILE metadata argument: ' + s)); + return; + } + var key = parts[0].trim(); + var file = parts[1]; + + _addMetadatumFromFile(metadata, key, file, file, cb); +} + +function _addMetadatumFromFile(metadata, key, file, from, cb) { + tilde(file, function (filePath) { + fs.stat(filePath, function (statErr, stats) { + if (statErr || !stats.isFile()) { + cb(new errors.TritonError(format( + 'metadata path "%s" is not an existing file', + file))); + return; + } + fs.readFile(filePath, 'utf8', function (readErr, content) { + if (readErr) { + cb(readErr); + return; + } + _addMetadatum(metadata, key, content, from, cb); + }); + }); + }); +} + + + +// ---- the command + +function do_create_instance(subcmd, opts, args, cb) { var self = this; if (opts.help) { - this.do_help('help', {}, [subcmd], callback); + this.do_help('help', {}, [subcmd], cb); return; } else if (args.length !== 2) { - return callback(new errors.UsageError('incorrect number of args')); + return cb(new errors.UsageError('incorrect number of args')); } var log = this.tritonapi.log; var cloudapi = this.tritonapi.cloudapi; vasync.pipeline({arg: {}, funcs: [ + function loadMetadata(ctx, next) { + metadataFromOpts(opts, self.log, function (err, metadata) { + if (err) { + next(err); + return; + } + if (metadata) { + log.trace({metadata: metadata}, + 'metadata loaded from opts'); + ctx.metadata = metadata; + } + next(); + }); + }, function getImg(ctx, next) { var _opts = { name: args[0], @@ -87,6 +314,11 @@ function do_create_instance(subcmd, opts, args, callback) { networks: ctx.nets && ctx.nets.map( function (net) { return net.id; }) }; + if (ctx.metadata) { + Object.keys(ctx.metadata).forEach(function (key) { + createOpts['metadata.'+key] = ctx.metadata[key]; + }); + } for (var i = 0; i < opts._order.length; i++) { var opt = opts._order[i]; @@ -111,7 +343,9 @@ function do_create_instance(subcmd, opts, args, callback) { cloudapi.createMachine(createOpts, function (err, inst) { if (err) { - return next(err); + next(new errors.TritonError(err, + 'error creating instance')); + return; } ctx.inst = inst; if (opts.json) { @@ -172,7 +406,7 @@ function do_create_instance(subcmd, opts, args, callback) { }); } ]}, function (err) { - callback(err); + cb(err); }); } @@ -187,8 +421,9 @@ do_create_instance.options = [ }, { names: ['name', 'n'], + helpArg: 'NAME', type: 'string', - help: 'Instance name. If not given, a random one will be created.' + help: 'Instance name. If not given, one will be generated server-side.' }, { // TODO: add boolNegationPrefix:'no-' when that cmdln pull is in @@ -197,23 +432,47 @@ do_create_instance.options = [ help: 'Enable Cloud Firewall on this instance. See ' + '' }, + { + names: ['metadata', 'm'], + type: 'arrayOfString', + helpArg: 'DATA', + help: 'Add metadata to when creating the instance. Metadata are ' + + 'key/value pairs available on the instance API object on the ' + + '"metadata" field, and inside the instance via the "mdata-*" ' + + 'commands. DATA is one of: a "key=value" string (bool and ' + + 'numeric "value" are converted to that type), a JSON object ' + + '(if first char is "{"), or a "@FILE" to have metadata be ' + + 'loaded from FILE. This option cal be used multiple times.' + }, + { + names: ['metadata-file', 'M'], + type: 'arrayOfString', + helpArg: 'KEY=FILE', + help: 'Set a metadata key KEY from the contents of FILE.' + }, + { + names: ['script'], + type: 'arrayOfString', + helpArg: 'FILE', + help: 'Load a file to be used for the "user-script" metadata key. In ' + + 'Joyent-provided images, the user-script is run at every boot ' + + 'of the instance. This is a shortcut for `-M user-script=FILE`.' + }, // XXX arrayOfCommaSepString dashdash type //{ // names: ['networks', 'nets'], // type: 'arrayOfCommaSepString', // help: 'One or more (comma-separated) networks IDs.' //}, - // XXX script (user-script) // XXX tag // XXX locality: near, far - // XXX metadata, metadata-file { group: 'Other options' }, { names: ['dry-run'], type: 'bool', - help: 'Go through the motions without actually creating an instance.' + help: 'Go through the motions without actually creating.' }, { names: ['wait', 'w'], @@ -227,6 +486,7 @@ do_create_instance.options = [ help: 'JSON stream output.' } ]; + do_create_instance.help = ( /* BEGIN JSSTYLED */ 'Create a new instance.\n' + @@ -238,6 +498,13 @@ do_create_instance.help = ( /* END JSSTYLED */ ); +do_create_instance.helpOpts = { + maxHelpCol: 25 +}; + do_create_instance.aliases = ['create']; + + module.exports = do_create_instance; +do_create_instance.metadataFromOpts = metadataFromOpts; // export for testing diff --git a/lib/errors.js b/lib/errors.js index ea7834e..aee636b 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -25,23 +25,38 @@ var verror = require('verror'), * Base error. Instances will always have a string `message` and * a string `code` (a CamelCase string). */ -function _TritonBaseVError(options) { - assert.object(options, 'options'); - assert.string(options.message, 'options.message'); - assert.optionalString(options.code, 'options.code'); - assert.optionalObject(options.cause, 'options.cause'); - assert.optionalNumber(options.statusCode, 'options.statusCode'); +function _TritonBaseVError(opts) { + assert.object(opts, 'opts'); + assert.string(opts.message, 'opts.message'); + assert.optionalString(opts.code, 'opts.code'); + assert.optionalObject(opts.cause, 'opts.cause'); + assert.optionalNumber(opts.statusCode, 'opts.statusCode'); var self = this; - var args = []; - if (options.cause) args.push(options.cause); - args.push(options.message); - VError.apply(this, args); + /* + * If the given cause has `body.errors` a la + * https://github.com/joyent/eng/blob/master/docs/index.md#error-handling + * then lets add text about those specifics to the error message. + */ + var message = opts.message; + if (opts.cause && opts.cause.body && opts.cause.body.errors) { + opts.cause.body.errors.forEach(function (e) { + message += format('\n %s: %s', e.field, e.code); + if (e.message) { + message += ': ' + e.message; + } + }); + } - var extra = Object.keys(options).filter( + var veArgs = []; + if (opts.cause) veArgs.push(opts.cause); + veArgs.push(message); + VError.apply(this, veArgs); + + var extra = Object.keys(opts).filter( function (k) { return ['cause', 'message'].indexOf(k) === -1; }); extra.forEach(function (k) { - self[k] = options[k]; + self[k] = opts[k]; }); } util.inherits(_TritonBaseVError, VError); @@ -51,29 +66,34 @@ util.inherits(_TritonBaseVError, VError); * This is useful in cases where we are wrapping CloudAPI errors with * onces that should *replace* the CloudAPI error message. */ -function _TritonBaseWError(options) { - assert.object(options, 'options'); - assert.string(options.message, 'options.message'); - assert.optionalString(options.code, 'options.code'); - assert.optionalObject(options.cause, 'options.cause'); - assert.optionalNumber(options.statusCode, 'options.statusCode'); +function _TritonBaseWError(opts) { + assert.object(opts, 'opts'); + assert.string(opts.message, 'opts.message'); + assert.optionalString(opts.code, 'opts.code'); + assert.optionalObject(opts.cause, 'opts.cause'); + assert.optionalNumber(opts.statusCode, 'opts.statusCode'); var self = this; - var args = []; - if (options.cause) args.push(options.cause); - args.push(options.message); - WError.apply(this, args); + var weArgs = []; + if (opts.cause) weArgs.push(opts.cause); + weArgs.push(opts.message); + WError.apply(this, weArgs); - var extra = Object.keys(options).filter( + var extra = Object.keys(opts).filter( function (k) { return ['cause', 'message'].indexOf(k) === -1; }); extra.forEach(function (k) { - self[k] = options[k]; + self[k] = opts[k]; }); } util.inherits(_TritonBaseWError, WError); + /* * A generic (i.e. a cop out) code-less error. + * + * Usage: + * new TritonError() + * new TritonError(, ) */ function TritonError(cause, message) { if (message === undefined) { diff --git a/package.json b/package.json index 7413502..537fa5e 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "triton", "description": "Joyent Triton CLI and client (https://www.joyent.com/triton)", - "version": "3.2.1", + "version": "3.3.0", "author": "Joyent (joyent.com)", "dependencies": { - "assert-plus": "0.1.5", + "assert-plus": "0.2.0", "backoff": "2.4.1", "bigspinner": "3.1.0", "bunyan": "1.5.1", diff --git a/test/integration/cli-manage-workflow.test.js b/test/integration/cli-manage-workflow.test.js index 2c5457a..cbe235b 100644 --- a/test/integration/cli-manage-workflow.test.js +++ b/test/integration/cli-manage-workflow.test.js @@ -142,9 +142,16 @@ test('triton manage workflow', opts, function (tt) { // create a test machine (blocking) and output JSON tt.test(' triton create', function (t) { - h.safeTriton(t, ['create', '-wjn', INST_ALIAS, imgId, pkgId], - function (stdout) { + var argv = [ + 'create', + '-wj', + '-m', 'foo=bar', + '--script', __dirname + '/script-log-boot.sh', + '-n', INST_ALIAS, + imgId, pkgId + ]; + h.safeTriton(t, argv, function (stdout) { // parse JSON response var lines = stdout.trim().split('\n'); t.equal(lines.length, 2, 'correct number of JSON lines'); @@ -159,6 +166,8 @@ test('triton manage workflow', opts, function (tt) { instance = lines[1]; t.equal(lines[0].id, lines[1].id, 'correct UUID given'); + t.equal(lines[0].metadata.foo, 'bar', 'foo metadata set'); + t.ok(lines[0].metadata['user-script'], 'user-script set'); t.equal(lines[1].state, 'running', 'correct machine state'); t.end(); @@ -204,6 +213,7 @@ test('triton manage workflow', opts, function (tt) { t.end(); } + t.equal(output[0].metadata.foo, 'bar', 'foo metadata set'); output.forEach(function (res) { t.deepEqual(output[0], res, 'same data'); }); @@ -219,6 +229,9 @@ test('triton manage workflow', opts, function (tt) { }); }); + // TODO: would be nice to have a `triton ssh cat /var/log/boot.log` to + // verify the user-script worked. + // create a test machine (non-blocking) tt.test(' triton create', function (t) { h.safeTriton(t, ['create', '-jn', INST_ALIAS, imgId, pkgId], diff --git a/test/integration/script-log-boot.sh b/test/integration/script-log-boot.sh new file mode 100644 index 0000000..56931df --- /dev/null +++ b/test/integration/script-log-boot.sh @@ -0,0 +1,4 @@ +#!/bin/sh +LOGFILE=/var/log/boot.log +touch $LOGFILE +echo "booted: $(date -u "+%Y%m%dT%H%M%SZ")" >>$LOGFILE diff --git a/test/unit/corpus/metadata-illegal-types.json b/test/unit/corpus/metadata-illegal-types.json new file mode 100644 index 0000000..783be06 --- /dev/null +++ b/test/unit/corpus/metadata-illegal-types.json @@ -0,0 +1,4 @@ +{ + "array": [1,2,3], + "obj": {"a": "A"} +} diff --git a/test/unit/corpus/metadata-invalid-json.json b/test/unit/corpus/metadata-invalid-json.json new file mode 100644 index 0000000..3ec91d9 --- /dev/null +++ b/test/unit/corpus/metadata-invalid-json.json @@ -0,0 +1,3 @@ +{ + "foo": "bar", +} diff --git a/test/unit/corpus/metadata.json b/test/unit/corpus/metadata.json new file mode 100644 index 0000000..3e14bba --- /dev/null +++ b/test/unit/corpus/metadata.json @@ -0,0 +1,5 @@ +{ + "foo": "bar", + "one": "four", + "num": 42 +} diff --git a/test/unit/corpus/metadata.kv b/test/unit/corpus/metadata.kv new file mode 100644 index 0000000..9a1875b --- /dev/null +++ b/test/unit/corpus/metadata.kv @@ -0,0 +1,3 @@ +foo=bar +one=four +num=42 diff --git a/test/unit/corpus/user-script.sh b/test/unit/corpus/user-script.sh new file mode 100644 index 0000000..279508d --- /dev/null +++ b/test/unit/corpus/user-script.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo "hi" diff --git a/test/unit/metadataFromOpts.test.js b/test/unit/metadataFromOpts.test.js new file mode 100644 index 0000000..8dc7d18 --- /dev/null +++ b/test/unit/metadataFromOpts.test.js @@ -0,0 +1,225 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright (c) 2015, Joyent, Inc. + */ + +/* + * Unit tests for `metadataFromOpts()` used by `triton create ...`. + */ + +var assert = require('assert-plus'); +var dashdash = require('dashdash'); +var format = require('util').format; +var test = require('tape'); + +var metadataFromOpts = require('../../lib/do_create_instance').metadataFromOpts; + + +// ---- globals + +var log = require('../lib/log'); + +var debug = function () {}; +// debug = console.warn; + + +// ---- test cases + +var OPTIONS = [ + { + names: ['metadata', 'm'], + type: 'arrayOfString' + }, + { + names: ['metadata-file', 'M'], + type: 'arrayOfString' + }, + { + names: ['script'], + type: 'arrayOfString' + } +]; + +var cases = [ + { + argv: ['triton', 'create', '-m', 'foo=bar'], + expect: { + metadata: {foo: 'bar'} + } + }, + { + argv: ['triton', 'create', '-m', 'foo=bar', '-m', 'bling=bloop'], + expect: { + metadata: { + foo: 'bar', + bling: 'bloop' + } + } + }, + { + argv: ['triton', 'create', + '-m', 'num=42', + '-m', 'pi=3.14', + '-m', 'yes=true', + '-m', 'no=false', + '-m', 'array=[1,2,3]'], + expect: { + metadata: { + num: 42, + pi: 3.14, + yes: true, + no: false, + array: '[1,2,3]' + } + } + }, + + { + argv: ['triton', 'create', + '-m', '@' + __dirname + '/corpus/metadata.json'], + expect: { + metadata: { + 'foo': 'bar', + 'one': 'four', + 'num': 42 + } + } + }, + { + argv: ['triton', 'create', + '-m', '@' + __dirname + '/corpus/metadata.kv'], + expect: { + metadata: { + 'foo': 'bar', + 'one': 'four', + 'num': 42 + } + } + }, + { + argv: ['triton', 'create', + '--script', __dirname + '/corpus/user-script.sh'], + expect: { + metadata: { + 'user-script': '#!/bin/sh\necho "hi"\n' + } + } + }, + { + argv: ['triton', 'create', + '-m', 'foo=bar', + '-M', 'user-script=' + __dirname + '/corpus/user-script.sh'], + expect: { + metadata: { + foo: 'bar', + 'user-script': '#!/bin/sh\necho "hi"\n' + } + } + }, + { + argv: ['triton', 'create', + '-m', 'foo=bar', + '--metadata-file', 'foo=' + __dirname + '/corpus/user-script.sh'], + expect: { + metadata: { + 'foo': '#!/bin/sh\necho "hi"\n' + }, + /* JSSTYLED */ + stderr: /warning: metadata "foo=.* replaces earlier value for "foo"/ + } + }, + { + argv: ['triton', 'create', + '-m', '@' + __dirname + '/corpus/metadata-illegal-types.json'], + expect: { + /* JSSTYLED */ + err: /invalid metadata value type: must be one of string, number, boolean: array=\[1,2,3\]/ + } + }, + { + argv: ['triton', 'create', + '-m', '@' + __dirname + '/corpus/metadata-invalid-json.json'], + expect: { + err: [ + /* jsl:ignore */ + /is not valid JSON/, + /corpus\/metadata-invalid-json.json/ + /* jsl:end */ + ] + } + }, + + { + argv: ['triton', 'create', + '-m', '{"foo":"bar","num":12}'], + expect: { + metadata: { + 'foo': 'bar', + 'num': 12 + } + } + } +]; + + +// ---- test driver + +test('metadataFromOpts', function (tt) { + cases.forEach(function (c, num) { + var testName = format('case %d: %s', num, c.argv.join(' ')); + tt.test(testName, function (t) { + debug('--', num); + debug('c: %j', c); + var parser = new dashdash.Parser({options: OPTIONS}); + var opts = parser.parse({argv: c.argv}); + debug('opts: %j', opts); + + // Capture stderr for warnings while running. + var stderrChunks = []; + var _oldStderrWrite = process.stderr.write; + process.stderr.write = function (s) { + stderrChunks.push(s); + }; + + metadataFromOpts(opts, log, function (err, metadata) { + // Restore stderr. + process.stderr.write = _oldStderrWrite; + var stderr = stderrChunks.join(''); + + if (c.expect.err) { + var errRegexps = (Array.isArray(c.expect.err) + ? c.expect.err : [c.expect.err]); + errRegexps.forEach(function (regexp) { + assert.regexp(regexp, 'case.expect.err'); + t.ok(err, 'expected an error'); + t.ok(regexp.test(err.message), format( + 'error message matches %s, actual %j', + regexp, err.message)); + }); + } else { + t.ifError(err); + } + if (c.expect.hasOwnProperty('metadata')) { + t.deepEqual(metadata, c.expect.metadata); + } + if (c.expect.hasOwnProperty('stderr')) { + var stderrRegexps = (Array.isArray(c.expect.stderr) + ? c.expect.stderr : [c.expect.stderr]); + stderrRegexps.forEach(function (regexp) { + assert.regexp(regexp, 'case.expect.stderr'); + t.ok(regexp.test(stderr), format( + 'error message matches %s, actual %j', + regexp, stderr)); + }); + + } + t.end(); + }); + }); + }); +});