UNPKG

@logdna/setup-chain

Version:

Base class for implementing seed and resource data for test suites

244 lines (204 loc) 6.32 kB
'use strict' const crypto = require('crypto') const {object} = require('@logdna/stdlib') const typeOf = require('./lang/type-of.js') const builtins = require('./actions/index.js') const {Parser, Lexer, Visitor} = require('./parser/index.js') const {BANG, HASH} = require('./parser/tokens.js') const PARSE_TOKENS = new Set([BANG.name, HASH.name]) const lookup = Symbol('lookup') const call = Symbol('call') const astToValue = Symbol('astToValue') const COMPLEX_LOOKUP_TYPES = new Set([ 'date' , 'moment' , 'boolean' , 'number' , 'luxondatetime' , 'luxoninterval' , 'luxonduration' ]) module.exports = class SetupChain { constructor(state, actions) { this.parser = new Parser() this.lexer = new Lexer() this.visitor = new Visitor() this.state = state || {} // runtime execution results this.keep = {} // Used by `set` to merge with final results this.tasks = [] // pending tasks to execute this.ctx = [] // execution context stack this.actions = { // public actions handlers ...builtins , ...(actions || {}) } const childMethods = Object.getOwnPropertyNames(Object.getPrototypeOf(this)) for (const name of Object.keys(this.actions)) { if (object.has(SetupChain.prototype, name) || childMethods.includes(name)) continue // If users don't expose their own method (and desired signature), // then use a default of (opts, label) and auto-expose the actions Object.defineProperties(this, { [name]: { enumerable: true , writable: false , configurable: true , value(opts, label) { this.tasks.push([name, label, opts]) return this } } }) } } get context() { return this.ctx.length ? this.ctx[this.ctx.length - 1] : this.state } set(key, value) { this.tasks.push(['set', 'set', key, value]) return this } map(collection, fn, label) { this.tasks.push(['map', label, collection, fn]) return this } repeat(times, action, opts, label) { this.tasks.push(['repeat', label, times, action, opts]) return this } serial(times, action, opts, label) { this.tasks.push(['serial', label, times, action, opts]) return this } sort(collection, comparator, label) { this.tasks.push(['sort', label, collection, comparator]) return this } lookup(path) { const path_type = typeOf(path) // Don't screw up Date's / moment prototype. Terminate iteration if a complex type. if (COMPLEX_LOOKUP_TYPES.has(path_type)) return path if (!path) return null if (path_type === 'string') return this[lookup](this.parse(path)) const out = Object.create(null) this.ctx.push(out) // path is an object remap values with lookup result for (const [key, value] of Object.entries(path)) { // remap array values with lookup result if (Array.isArray(value)) { out[key] = value.map((val) => { return this.lookup(val) }) continue } // remap object values with lookup result if (value && typeOf(value) === 'object') { out[key] = this.lookup(value) continue } out[key] = typeOf(value) === 'string' ? this[lookup](this.parse(value)) : value } this.ctx.pop() if (Array.isArray(path)) return Object.values(out) return out } parse(str) { const lexed = this.lexer.tokenize(str) if (!lexed.tokens.length) return this.visitor.wrapLiteral(str) const firstToken = lexed.tokens[0] if (!PARSE_TOKENS.has(firstToken.tokenType.name)) { return this.visitor.wrapLiteral(str) } this.parser.input = lexed.tokens // returns unist compliant syntax tree root node const ast = this.parser.last() const errors = this.parser._errors if (errors.length) throw errors[0] this.parser.reset() const node = this.visitor.visit(ast) // the root node of a LAST tree only has 1 child return node.children[0] } reset() { this.state = {} this.tasks = [] this.ctx = [] return this } async execute() { this.state = { ...this.state , ...this.keep } if (!this.tasks.length) return this.state for (const task of this.tasks) { if (!task || typeof task !== 'object') { throw new TypeError('tasks must be an array of objects') } const [key, label, ...rest] = task try { const fn = this.actions[key] this.state[label || key] = await fn.apply(this, rest) } catch (err) { console.error(err) const error = new Error(`Setup Chain error in ${key}`) error.stack = err.stack error.chain_params = task error.err = err this.tasks = [] throw error } } this.tasks = [] return this.state } [lookup](ast) { const val = this[astToValue].call(this, ast) return val === undefined ? null : val } [astToValue](ast) { switch (ast.type) { case 'literal': { // literal value return ast.value } case 'lookup': { // path lookup const data = ast.data const context = data.local ? data.context : this.state return object.get(context, ast.value) } case 'function': { // fn call const {value: fn, children} = ast return this[call](fn, children) } case 'ref': { // local context reference const node = ast.children[0] node.data.context = this.context return this[lookup](node) } } } [call](fn, args) { const fnkey = `$${fn}` if (typeof this[fnkey] !== 'function') { throw new TypeError(`'${fn}' is not a callable chain function`) } const mapper = this[astToValue].bind(this) const resolved = args.map(mapper) return this[fnkey].apply(this, resolved) } $random(bytes = 5) { return crypto.randomBytes(bytes).toString('hex') } $template(str) { return str.replace(/{{([^{}]*)}}/g, (match) => { let replacement = '' const key = match.slice(2, match.length - 2) if (key[0] === HASH.LABEL || key[0] === BANG.LABEL) { const val = this[lookup](this.parse(key)) if (val) replacement = val } return replacement }) } }