UNPKG

@litexa/core

Version:

Litexa, a programming language for writing Alexa skills

1,240 lines (1,027 loc) 38.5 kB
### # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ ### lib = {} uuid = require 'uuid' fs = require 'fs' path = require 'path' directiveValidators = {} { JSONValidator } = require('./jsonValidator').lib directiveValidators['AudioPlayer.Play'] = -> [] directiveValidators['AudioPlayer.Stop'] = -> [] directiveValidators['Hint'] = -> [] validateSSML = (skill, line) -> errors = [] audioCount = 0 audioFinder = /\<\s*audio/gi match = audioFinder.exec line while match audioCount += 1 match = audioFinder.exec line if audioCount > 5 errors.push "more than 5 <audio/> tags in one response" return errors class ParserError extends Error constructor: (@location, @message) -> super() class TrapLog constructor: (passLog, passError) -> @logs = logs = [] @errors = errors = [] @oldLog = oldLog = console.log @oldError = oldError = console.error console.log = -> for a in arguments if typeof(a) == 'string' logs.push a if passLog? passLog a else text = JSON.stringify(a) logs.push text if passLog? passLog text console.error = -> for a in arguments logs.push "ERROR: " + a errors.push a if passError? passError a stop: (flush) -> console.log = @oldLog console.error = @oldError if flush console.log @logs.join('\n') makeBaseRequest = (skill) -> # all requests start out looking like this req = { session: sessionId: "SessionId.uuid", application: applicationId: "amzn1.ask.skill.uuid" attributes: {}, user: userId: "amzn1.ask.account.stuff" new: false context: System: device: deviceId: "someDeviceId" supportedInterfaces: [] user: userId: "amzn1.ask.account.stuff" application: applicationId: "amzn1.ask.skill.uuid" request : null version: "1.0" } device = skill.testDevice ? 'dot' blockedInterfaces = skill.testBlockedInterfaces ? [] pushInterface = (interfaceName) => return if interfaceName in blockedInterfaces req.context.System.device.supportedInterfaces[interfaceName] = {} switch device when 'dot', 'echo' dev = req.context.System.device when 'show' dev = req.context.System.device pushInterface('Display') pushInterface('Alexa.Presentation.APL') pushInterface('Alexa.Presentation.HTML') else throw new Error "Unknown test device type #{device}" req.__logStateTraces = skill.testLoggingTraceStates req.__reportStateTrace = true return req makeHandlerIdentity = (skill) -> event = makeBaseRequest(skill) identity = {} if event.context?.System? identity.requestAppId = event.context.System.application?.applicationId identity.userId = event.context.System.user?.userId identity.deviceId = event.context.System.device?.deviceId else if event.session? identity.requestAppId = event.session.application?.applicationId identity.userId = event.session.user?.userId identity.deviceId = 'no-device' return identity makeRequestId = -> "litexaRequestId.#{uuid.v4()}" makeLaunchRequest = (skill, time, locale) -> # launch requests are uniform, they just have this tacked onto the base req = makeBaseRequest(skill) req.session.new = true req.request = type: "LaunchRequest" requestId: makeRequestId() timestamp: new Date(time).toISOString() locale: locale return req makeIntentRequest = (skill, name, slots, time, locale) -> # intent requests need the name and slots interpolated in unless skill.hasIntent name, locale throw "Skill does not have intent #{name}" req = makeBaseRequest(skill) req.request = type: "IntentRequest" requestId: makeRequestId() timestamp: new Date(time).toISOString() locale: locale intent: name: name slots: {} if slots? for name, value of slots req.request.intent.slots[name] = {name:name, value:value} return req makeSessionEndedRequest = (skill, reason, time, locale) -> req = makeBaseRequest(skill) req.request = type: "SessionEndedRequest" requestId: makeRequestId() timestamp: new Date(time).toISOString() reason: reason locale: locale error: type: "string" message: "string" return req findIntent = (skill, line) -> # given an expressed utterance, figure out which intent # it could match, and what its slots would be candidates = [] for stateName, state of skill.states continue unless state.intents? for intentName, intent of state.intents for utterance in intent.utterances [score, slots] = utterance.parse line if slots? candidates.push [score, [intent, slots]] unless candidates.length > 0 return [null, null] candidates.sort (a,b) -> return -1 if a[0] > b[0] return 1 if a[0] < b[0] return 0 return candidates[0][1] # Function that evenly left/right pads a String with a paddingChar, until targetLength is reached. padStringWithChars = ({ str, targetLength, paddingChar }) -> str = str ? 'MISSING_STRING' numCharsPreStr = Math.max(Math.floor((targetLength - str.length) / 2), 0) str = "#{paddingChar.repeat(numCharsPreStr)}#{str}" numCharsPostStr = Math.max(targetLength - str.length, 0) str = "#{str}#{paddingChar.repeat(numCharsPostStr)}" return str collectSays = (skill, lambda) -> result = [] state = null collect = (part) -> if part.isSay sample = "" try sample = part.express({ slots: {}, lambda: lambda, noDatabase: true, language: skill.testLanguage }) catch e console.log e throw new ParserError part.location, "Failed to express say `#{part}`: #{e.toString()}" if sample result.push { part: part state: state sample: sample } else if part.isSoundEffect sample = part.toSSML(skill.testLanguage) result.push { part: part state: state sample: sample } if part.startFunction? part.startFunction.forEachPart skill.testLanguage, collect for stateName, state of skill.states state.startFunction?.forEachPart skill.testLanguage, collect state.endFunction?.forEachPart skill.testLanguage, collect for intentName, intent of state.intents intent.startFunction?.forEachPart skill.testLanguage, collect result.sort (a, b) -> return -1 if a.sample.length > b.sample.length return 1 if a.sample.length < b.sample.length return 0 return result class lib.ExpectedExactSay # verbatim text match, rather than say string search constructor: (@location, @line) -> test: (skill, context, result) -> # compare without the say markers test = result.speech ? "" test = test.replace /\s/g, ' ' test = abbreviateTestOutput test, context @line = abbreviateTestOutput @line, context unless @line == test throw new ParserError @location, "speech did not exactly match `#{@line}`" class lib.ExpectedRegexSay # given a regex, rather than constructing one constructor: (@location, @regex) -> test: (skill, context, result) -> # compare without the say markers test = result.speech ? "" test = test.replace /\s/g, ' ' abbreviatedTest = abbreviateTestOutput test, context regexp = new RegExp(@regex.expression, @regex.flags) unless test.match(regexp) or abbreviatedTest.match(regexp) throw new ParserError @location, "speech did not match regex `/#{@regex.expression}/#{@regex.flags}`" grindSays = (language, allSays, line) -> # try to categorize every part of this string into # one of the say statements, anywhere in the skill ctx = { remainder: line says: [] } while ctx.remainder.length > 0 found = false for s in allSays match = s.part.matchFragment(language, ctx.remainder, true) if match? if match.offset == 0 found = true ctx.remainder = match.reduced ctx.says.push [line.indexOf(match.removed), match.part, match.removed] break return ctx unless found return ctx class lib.ExpectedSay # expect all say statements that concatenate into @line constructor: (@location, @line) -> test: (skill, context, result) -> # step 1 identify the say statements testLine = @line.express(context) collected = grindSays(skill.testLanguage, context.allSays, testLine) collected.remainder = collected.remainder.replace /(^[ ]+)/, '' collected.remainder = collected.remainder.replace /([ ]+$)/, '' unless collected.remainder.length == 0 throw new ParserError @location, "no say statements match `#{collected.remainder}` out of `#{testLine}`" # step 2, check to see that the response can # match each say, in order remainder = result.speech remainder = abbreviateTestOutput remainder, context for sayInfo, sayIndex in collected.says match = sayInfo[1].matchFragment(skill.testLanguage, remainder, true) unless match? throw new ParserError @location, "failed to match expected segment #{sayIndex} `#{sayInfo[2]}`, seeing `#{remainder}` instead" unless match.offset == 0 throw new ParserError @location, "say statement appeared out of order `#{match.removed}`" remainder = match.reduced unless remainder.length == 0 throw new ParserError @location, "unexpected extra speech, `#{remainder}`" class lib.ExpectedState # expect the state in the response to be @name constructor: (@location, @name) -> test: (skill, context, result) -> data = context.db.getVariables(makeHandlerIdentity(skill)) if @name == 'null' unless data.__currentState == null throw new ParserError @location, "response was in state `#{data.__currentState}` instead of expected empty null state" return unless @name of skill.states throw new ParserError @location, "test specifies unknown state `#{@name}`" unless data.__currentState == @name throw new ParserError @location, "response was in state `#{data.__currentState}` instead of expected `#{@name}`" comparatorNames = { "==": "equal to" "!=": "unequal to" ">=": "greater than equal to" "<=": "less than or equal to" ">": "greater than" "<": "less than" } class lib.ExpectedDB # expect the value in the db to be this constructor: (@location, @reference, @op, @tail) -> test: (skill, context, result) -> data = context.db.getVariables(makeHandlerIdentity(skill)) value = @reference.readFrom(data) unless value? throw new ParserError @location, "db value `#{@reference}` didn't exist" tail = JSON.stringify(eval(@tail)) unless eval("value #{@op} #{tail}") value = JSON.stringify(value) throw new ParserError @location, "db value `#{@reference}` was `#{value}`, not #{comparatorNames[@op]} `#{tail}`" class lib.ExpectedEndSession # expect the response to indicate the session should end constructor: (@location) -> isExpectedEndSession: true test: (skill, context, result) -> unless result.data.response.shouldEndSession throw new ParserError @location, "session did not indicate it should end as expected" class lib.ExpectedContinueSession constructor: (@location, @kinds) -> isExpectedContinueSession: true test: (skill, context, result) -> if 'microphone' in @kinds unless result.data.response.shouldEndSession == false throw new ParserError @location, "skill is not listening for microphone" else if result.data.response.shouldEndSession? throw new ParserError @location, "skill is not listening for events, without microphone" class lib.ExpectedDirective # expect the response to indicate the session should end constructor: (@location, @name) -> isExpectedDirective: true test: (skill, context, result) -> unless result.data.response.directives? throw new ParserError @location, "response did not contain any directives, expected #{@name}" found = false for directive in result.data.response.directives if directive.type == @name found = true break unless found types = ( d.type for d in result.data.response.directives ) throw new ParserError @location, "response did not contain expected directive #{@name}, instead had [#{types}]" class lib.ResponseGeneratingStep constructor: -> @expectations = [] isResponseGeneratingStep: true pushExpectation: (obj) -> @expectations.push obj checkForSessionEnd: -> should = null shouldnot = null for e in @expectations if e.isExpectedContinueSession should = e if e.isExpectedEndSession shouldnot = e if should? and shouldnot? throw new Error "TestError: step expects the session to end and not to end" unless should? unless shouldnot? @expectations.push new lib.ExpectedContinueSession @location makeResult: -> { expectationsMet: true errors: [] logs: [] } processEvent: ({ result, skill, lambda, context, resultCallback }) -> event = result.event event.session.attributes = context.attributes trap = null try trap = new TrapLog lambda.handler event, {}, (err, data) => trap.stop(false) cleanSpeech = (data) -> speech = data?.text unless speech speech = data?.ssml if speech speech = speech.replace "<speak>", '' speech = speech.replace "</speak>", '' return speech result.err = err result.data = data result.speech = cleanSpeech result.data?.response?.outputSpeech if result.data?.response?.reprompt? result.reprompt = cleanSpeech result.data.response.reprompt.outputSpeech if result.data?.sessionAttributes? context.attributes = result.data.sessionAttributes if result.data?.response?.card? card = result.data?.response?.card result.card = card result.cardReference = card.title if result.data?.response?.directives? result.directives = [] for d, index in result.data.response.directives if typeof(d) != 'object' result.errors.push "directive #{index} was not even an object. Pushed something wrong into the array?" continue try result.directives.push JSON.parse JSON.stringify d catch err result.errors.push "directive #{index} could not be JSON serialized, maybe contains circular reference and or non primitive values?" for expectation in @expectations try expectation.test(skill, context, result) catch ex result.errors.push ex.message result.expectationsMet = false result.logs.push l for l in trap.logs result.errors.push e for e in trap.errors result.shouldEndSession = result.data?.response?.shouldEndSession resultCallback null, result catch ex result.err = ex if trap? trap.stop(true) result.logs.push l for l in trap.logs result.errors.push e for e in trap.errors resultCallback ex, result class RequestStep extends lib.ResponseGeneratingStep constructor: (@location, @requestType, @source) -> super() isVoiceStep: true run: ({ skill, lambda, context, resultCallback }) -> result = @makeResult() context.attributes = {} event = makeBaseRequest( skill ) if @requestType # support for just generating an empty request with a given type event.request = { type: @requestType } if @source? # support for loading a request from a file unless skill.files[@source] return resultCallback new ParserError @location, "couldn't find file #{@source} for this request" event.request = skill.files[@source].contentForLanguage('default') # try to fish out an intent name for the test report # if there is one, otherwise show the request type result.intent = event.request.intent?.name ? event.request.type result.event = event @processEvent { result, skill, lambda, context, resultCallback } class LaunchStep extends lib.ResponseGeneratingStep constructor: (@location, @say, @intent) -> super() isVoiceStep: true run: ({ skill, lambda, context, resultCallback }) -> result = @makeResult() context.attributes = {} result.intent = "LaunchRequest" event = makeLaunchRequest( skill, context.time, skill.testLanguage ) result.event = event @processEvent { result, skill, lambda, context, resultCallback } class VoiceStep extends lib.ResponseGeneratingStep constructor: (@location, @say, @intent, values) -> super() @values = {} if values? # the parser gives this to use an array of k/v pair arrays for v in values @values[v[0]] = { value:v[1] } if @say?.alternates?.default? for alt in @say.alternates.default for part, i in alt if part?.isSlot unless part.name of @values throw new ParserError @location, "test say statements has named slot $#{part.name}, but no value for it" part.fixedValue = @values[part.name].value @values[part.name].found = true for k, v of @values unless v.found throw new ParserError @location, "test say statement specifies value for unknown slot #{k}" isVoiceStep: true run: ({ skill, lambda, context, resultCallback }) -> result = @makeResult() if @intent? result.intent = @intent result.slots = {} for k, v of @values result.slots[k] = v.value event = makeIntentRequest( skill, @intent, result.slots, context.time, skill.testLanguage ) else if @say? result.expressed = @say.express(context) [intent, slots] = findIntent(skill, result.expressed) for name, value of slots if name of @values slots[name] = @values[name].value unless intent? resultCallback new Error("couldn't match `#{result.expressed}` to any intents") return result.intent = intent.name result.slots = slots event = makeIntentRequest( skill, intent.name, slots, context.time, skill.testLanguage ) else resultCallback new Error("Voice step has neither say nor intent") return result.event = event @processEvent { result, skill, lambda, context, resultCallback } class DBFixupStep constructor: (@reference, @code) -> isDBFixupStep: true run: ({ skill, lambda, context, resultCallback }) -> try identity = makeHandlerIdentity(skill) data = context.db.getVariables(identity) @reference.evalTo(data, @code) context.db.setVariables(identity, data) resultCallback null, {} catch err resultCallback err, {} class WaitStep constructor: (@duration) -> isWaitStep: true run: ({ skill, lambda, context, resultCallback }) -> context.time += @duration context.alreadyWaited = true resultCallback null, {} class StopStep constructor: (@reason) -> @requestReason = switch @reason when 'quit' then 'USER_INITIATED' when 'drop' then 'EXCEEDED_MAX_REPROMPTS' else 'USER_INITIATED' isStopStep: true run: ({ skill, lambda, context, resultCallback }) -> try event = makeSessionEndedRequest( skill, @requestReason, context.time, skill.testLanguage ) lambda.handler event, {}, (err, data) => resultCallback err, data catch err resultCallback err, {} class SetRegionStep constructor: (@region) -> run: ({ skill, lambda, context, resultCallback }) -> skill.testLanguage = @region resultCallback null, {} report: ({ err, logs, sourceLine, step, output, result, context }) -> logs.push "setting region to #{step.region}" class SetLogStateTraces constructor: (@location, @value) -> run: ({ skill, lambda, context, resultCallback }) -> skill.testLoggingTraceStates = @value resultCallback null, {} report: ({ err, logs, sourceLine, step, output, result, context }) -> if @value logs.push "enabling state tracing" else logs.push "disabling state tracing" class CaptureStateStep constructor: (@location, @name) -> run: ({ skill, lambda, context, resultCallback }) -> context.captures[@name] = db: context.db.getVariables(makeHandlerIdentity(skill)) attr: JSON.stringify(context.attributes) resultCallback null, {} report: ({ err, logs, sourceLine, step, output, result, context }) -> logs.push "#{sourceLine} captured state as '#{@name}'" class ResumeStateStep constructor: (@location, @name) -> run: ({ skill, lambda, context, resultCallback }) -> unless @name of context.captures throw new ParserError @location, "No state named #{@name} to resume here" state = context.captures[@name] context.db.setVariables(makeHandlerIdentity(skill),state.db) context.attributes = JSON.parse state.attr resultCallback null, {} report: ({ err, logs, sourceLine, step, output, result, context }) -> logs.push "#{sourceLine} resumed from state '#{@name}'" validateDirective = (directive, context) -> validatorFunction = directiveValidators[directive?.type] unless validatorFunction? # no? Try the ones from any loaded extensions validatorFunction = context.skill.directiveValidators[directive?.type] unless validatorFunction? if context.skill.projectInfo?.directiveWhitelist? return null if directive?.type in context.skill.projectInfo?.directiveWhitelist if context.skill.projectInfo?.validDirectivesList? return null if directive?.type in context.skill.projectInfo?.validDirectivesList return [ "unknown directive type #{directive?.type}" ] try validator = new JSONValidator directive validatorFunction(validator) if validator.errors.length > 0 return ( e.toString() for e in validator.errors ) catch e return [ e.toString() ] return null abbreviateTestOutput = (line, context) -> return null unless line? # shorten audio cleanedBucket = context.testContext.litexa?.assetsRoot ? '' cleanedBucket += context.testContext.language + "/" cleanedBucket = cleanedBucket.replace /\-/gi, '\\-' cleanedBucket = cleanedBucket.replace /\./gi, '\\.' cleanedBucket = cleanedBucket.replace /\//gi, '\\/' # audio src= audioFinderRegex = "<audio\\s+src='#{cleanedBucket}([\\w\\/\\-_\\.]*)\\.mp3'/>" line = abbreviateRegexReplacer line, audioFinderRegex, "<", ".mp3>" # SFX URLs/soundbanks audioUrlFinderRegex = "<audio\\s+src=['\"]([a-zA-Z0-9_\\-\\.\\/\\:]*)['\"]/>" line = abbreviateRegexReplacer line, audioUrlFinderRegex # SFX shorthand sfxUrlFinderRegex = "<sfx\\s+['\"]?([a-zA-Z0-9_\\-\\.\\/\\:]*)['\"]?>" line = abbreviateRegexReplacer line, sfxUrlFinderRegex # also interjections interjectionFinderRegex = "<say-as.interpret-as='interjection'>([^<]*)<\\/say-as>" line = abbreviateRegexReplacer line, interjectionFinderRegex, "<!" # also breaks breakFinderRegex = "<break.time='(([0-9]+)((s)|(ms)))'\\/>" line = abbreviateRegexReplacer line, breakFinderRegex, "<..." # clean up any white space oddities line = line.replace /\s/g, ' ' return line abbreviateRegexReplacer = (line, regex, matchPrefix = '<', matchSuffix = '>') -> regex = new RegExp regex, 'i' match = regex.exec line while match? line = line.replace match[0], "#{matchPrefix}#{match[1]}#{matchSuffix}" match = regex.exec line return line functionStripper = /function\s*\(\s*\)\s*{\s*return\s*([^}]+);\n\s*}$\s*$/ class TestLibrary constructor: (@target, @testContext) -> @counter = 0 error: (message) -> throw new Error "[#{@counter}] " + message equal: (a, b) -> @counter += 1 unless a == b @error "#{a} didn't equal #{b}" check: (condition) -> @counter += 1 result = condition() unless result match = functionStripper.exec condition.toString() @error "false on #{match?[1] ? condition}" report: (message) -> if typeof(message) != 'string' message = JSON.stringify(message) @target.messages.push " t! #{message}" warning: (message) -> if typeof(message) != 'string' message = JSON.stringify(message) @target.messages.push " t✘! #{message}" expect: (name, condition) -> startLine = @target.messages.length @counter = 0 try doTest = true if @target.filters doTest = false for f in @target.filters if name.indexOf(f) >= 0 doTest = true if doTest condition() @target.reportTestCase null, name, startLine catch err @target.reportTestCase err, name, startLine directives: (title, directives) -> @counter += 1 unless Array.isArray(directives) directives = [directives] failed = false for d, idx in directives report = validateDirective d, @testContext continue if report == null if report.length == 1 failed = true @target.messages.push " ✘ #{title}[#{idx}]: #{report[0]}" else if report.length > 1 failed = true @target.messages.push " ✘ #{title}[#{idx}]" for r in report @target.messages.push " ✘ #{r}" unless failed @report "#{title} OK" class lib.CodeTest constructor: (@file) -> test: (testContext, output, resultCallback) -> { skill, db, lambda } = testContext Test = new TestLibrary @, testContext @messages = [] @successes = 0 @failures = 0 Test.target = @ exception = @file.exception catchLog = (str) => @messages.push " c! " + str catchError = (str) => @messages.push " c✘! " + str trap = new TrapLog catchLog, catchError @testCode = null fileCode = null unless exception? try fileCode = @file.contentForLanguage(skill.testLanguage) localTestRootFormatted = skill.projectInfo.testRoot.replace(/\\/g, '/') localAssetsRootFormatted = path.join(testContext.litexaRoot, 'assets').replace(/\\/g, '/') modulesRootFormatted = path.join(testContext.litexaRoot).replace(/\\/g, '/') @testCode = [ """ exports.litexa = { assetsRoot: 'test://', localTesting: true, localTestRoot: '#{localTestRootFormatted}', localAssetsRoot: '#{localAssetsRootFormatted}', modulesRoot: '#{modulesRootFormatted}' }; """ skill.libraryCode skill.testLibraryCodeForLanguage(skill.testLanguage) "initializeExtensionObjects({})" fileCode.js ? fileCode ].join('\n') fs.writeFileSync path.join(testContext.testRoot, @file.name + '.log'), @testCode, 'utf8' eval @testCode catch e exception = e trap.stop(false) if exception? output.log.push "✘ code test: #{@file.name}, failed" location = '' if exception.location? l = exception.location location = "[#{l.first_line}:#{l.first_column}] " else if exception.stack match = (/at eval \((.*)\)/i).exec exception.stack location = "[#{match[1]}] " if match output.log.push " ✘ #{location}#{exception.message ? ("" + exception)}" output.log.push " c!: #{l}" for l in trap.logs resultCallback @file.exception, false else if @failures == 0 if @successes > 0 @messages.unshift "✔ #{@file.filename()}, #{@successes} tests passed" else @messages.unshift "✘ #{@file.filename()}, #{@failures} tests failed, #{@successes} passed" if @messages.length > 0 output.log.push @messages.join('\n') resultCallback null, @successes, @failures reportTestCase: (err, name, startLine) -> startLine = startLine ? @messages.length if err? @failures += 1 @messages.splice startLine, 0, " ✘ #{@file.filename()} '#{name}': #{err.message}" else @successes += 1 @messages.splice startLine, 0, " ✔ #{@file.filename()} '#{name}'" @messages.push '' class lib.TestContext constructor: (@skill, @options) -> @output = log: [] cards: [] directives: [] collectAllSays: -> @allSays = collectSays @skill, @lambda class lib.Test constructor: (@location, @name, @sourceFilename) -> @steps = [] @capturesNames = [] @resumesNames = [] isTest: true pushUser: (location, line, intent, slots) -> if line? or intent? @steps.push new VoiceStep(location, line, intent, slots) else @steps.push new LaunchStep(location) pushRequest: (location, name, source) -> @steps.push new RequestStep(location, name, source) pushTestStep: (step) -> @steps.push step pushExpectation: (obj) -> end = @steps.length - 1 for i in [end..0] by -1 if @steps[i].pushExpectation? @steps[i].pushExpectation(obj) return throw new ParserError obj.location, "alexa test expectation pushed without prior intent" findLastStep: (predicate) -> return null if @steps.length <= 0 for i in [@steps.length-1..0] if predicate(@steps[i]) return @steps[i] return null pushDatabaseFix: (name, code) -> @steps.push new DBFixupStep(name, code) pushWait: (duration) -> @steps.push new WaitStep(duration) pushStop: (reason) -> @steps.push new StopStep(reason) pushSetRegion: (region) -> @steps.push new SetRegionStep(region) pushCaptureNamedState: (location, name) -> @steps.push new CaptureStateStep(location, name) @capturesNames.push name pushResumeNamedState: (location, name) -> @steps.push new ResumeStateStep(location, name) @resumesNames.push name pushSetLogStateTraces: (location, value) -> @steps.push new SetLogStateTraces(location, value) reportEndpointResponses: ({ result, context, output, logs }) -> success = true skill = context.skill rawObject = ref: logs?[logs.length - 1] request: result?.event ? {} response: result?.data ? {} db: context.db.getVariables(makeHandlerIdentity(skill)) trace: result?.data?.__stateTrace # filter out test control items for obj in [rawObject.response, rawObject.request] for k of obj if k[0] == '_' delete obj[k] # If turned on via test options, this logs all raw responses/requests and DB contents. # @TODO: For extensive tests, dumping this raw object aborts with a JS Heap OOM error # (during writeFileSync in test.coffee) -> should be addressed. if context.testContext.options?.logRawData? output.raw.push rawObject if result.err rawObject.error = result.err.stack ? '' + result.err logs.push " ✘ handler error: #{result.err}" if result.err.stack? stack = '' + result.err.stack lines = stack.split '\n' for l in lines l = l.replace /\([^\)]*\)/g, '' logs.push " #{l}" if l.indexOf('processIntents') >= 0 break success = false else if result.event stateName = "" for ident, vars of context.db.identities stateName = vars['__currentState'] state = "◖#{padStringWithChars({ str: stateName ? '' targetLength: skill.maxStateNameLength paddingChar: '-' })}◗" if skill.abbreviateTestOutput speech = abbreviateTestOutput( result.speech, context ) reprompt = abbreviateTestOutput( result.reprompt, context ) else speech = result.speech reprompt = result.reprompt speech = speech.replace /"/g, '❝' if speech? reprompt = reprompt.replace /"/g, '❝' if reprompt? if speech? speech = "\"#{speech}\"" else speech = "NO SPEECH" if reprompt? reprompt = "\"#{reprompt}\"" else reprompt = "NO REPROMPT" if result.expectationsMet logs.push " #{state} #{speech} ... #{reprompt}" else logs.push " ✘ #{state} #{speech} ... #{reprompt}" success = false do => check = (key) => errors = validateSSML skill, result[key] if errors.length > 0 success = false for error in errors logs.push " ✘ #{key}: #{error}" check 'speech' check 'reprompt' if result.card? index = output.cards.length output.cards.push result.card logs.push " [CARD #{index}] #{result.cardReference}" if result.directives? for directive in result.directives index = output.directives.length output.directives.push directive if directive? && context.skill.directiveFormatters[directive?.type]? lines = context.skill.directiveFormatters[directive?.type](directive) if Array.isArray(lines) for line in lines logs.push " #{line}" else logs.push " #{lines}" else logs.push " [DIRECTIVE #{index}] #{directive?.type}" validationErrors = validateDirective(directive, context) if validationErrors for error in validationErrors logs.push " ✘ #{error}" success = false if result.shouldEndSession logs.push " ◣ Voice session ended" if result.errors? for e in result.errors logs.push " ✘ #{e}" if result.logs? for l in result.logs logs.push " ! #{l}" return success test: (testContext, output, resultCallback) -> { skill, db, lambda } = testContext logs = [] db.captures = db.captures ? {} context = db: db attributes: {} allSays: collectSays(skill, lambda) lambda: lambda skill: skill captures: db.captures testContext: testContext # reset this for each test skill.testBlockedInterfaces = []; success = true gap = (" " for i in [0...skill.maxStateNameLength+2]).join('') skill.testLoggingTraceStates = false remainingSteps = ( s for s in @steps ) nextStep = => ### if db.db.variables != context.db db.db.variables = context.db db.db.initialized = true ### if remainingSteps.length == 0 unless testContext.options.singleStep if success logs.unshift "✔ test: #{@name}" else logs.unshift "✘ test: #{@name}" output.log.push logs.join('\n') successCount = 0 failCount = 0 failedTestName = undefined if success successCount = 1 else failCount = 1 failedTestName = @name setTimeout (->resultCallback null, successCount, failCount, failedTestName), 1 return step = remainingSteps.shift() unless context.time? # first time in here, we'll initialize to a fixed point in time context.time = (new Date(2017, 9, 1, 15, 0, 0)).getTime() if step.isVoiceStep or step.testingTimeIncrement # unless we had an explicit wait from the test script, # we'll insert a few seconds between every user event unless context.alreadyWaited context.time += step.testingTimeIncrement ? 65 * 1000 context.alreadyWaited = false step.run { skill, lambda, context, resultCallback: (err, result) => if err? or result?.err? success = false sourceLine = step.location?.start?.line ? "--" sourceLine += "." switch when step.isStopStep if err logs.push " ✘ processed #{step.requestReason} session end with error: #{err}" else logs.push " • processed #{step.requestReason} session end without errors" when step.isDBFixupStep if err logs.push " ✘ db fixup error: @#{step.reference}, #{err}" else logs.push " • db fixup @#{step.reference}" when step.isWaitStep minutes = step.duration / 1000 / 60 logs.push " • waited #{minutes.toFixed(2)} minutes" when step.isVoiceStep if err logs.push "#{sourceLine} ❢ Voice intent error: #{err}" else result = result ? {} time = (new Date(context.time)).toLocaleTimeString() textSlots = "" if result.slots? textSlots = ( "$#{k}=#{v}" for k, v of result.slots ).join(', ') if result.intent? paddedIntent = result.intent[0...skill.maxStateNameLength+2] else paddedIntent = "ERROR" paddedIntent = padStringWithChars({ str: paddedIntent targetLength: skill.maxStateNameLength + 2 paddingChar: ' ' }) input = "" #input = "\"#{result.expressed ? step.intent ? "launch"}\" -- " logs.push "#{sourceLine} ❢ #{paddedIntent} #{input}#{textSlots} @ #{time}" if result? unless @reportEndpointResponses { result, context, output, logs } success = false when step.report? step.report({ err, logs, sourceLine, step, output, result, context }) if result? unless @reportEndpointResponses { result, context, output, logs } success = false else throw new Error "unexpected step" nextStep() } nextStep() lib.TestUtils = { makeBaseRequest makeHandlerIdentity makeRequestId padStringWithChars } module.exports = { lib }