UNPKG

@litexa/core

Version:

Litexa, a programming language for writing Alexa skills

1,040 lines (853 loc) 35 kB
### # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ ### fs = require 'fs' path = require 'path' mkdirp = require 'mkdirp' coffee = require 'coffeescript' testing = require './testing.coffee' { ParserError, formatLocationStart } = require("./errors.coffee").lib exp = module.exports lib = exp.lib = require './parserlib.coffee' makeReferenceTester = (litexaRoot, source) -> try # build a closure that can check for the existence of a symbol # within the main body of the inline code closure process.env.NODE_PATH = path.join litexaRoot, 'node_modules' require("module").Module._initPaths() func = eval """ (function() { #{source} return (test) => eval("typeof(" + test + ") != 'undefined';"); })(); """ catch err # complex code could die in that closure, and if it does we're # unable to validate local variable usage as they may be pointing # to a JavaScript variable console.error "warning: user code is either failing to compile, or is too complex for simple reference tester: #{err}. Variable names from code cannot be checked in Litexa." func = (test) -> true return func Files = require './files.coffee' class lib.Skill constructor: (@projectInfo) -> unless @projectInfo throw new Error "Cannot construct a skill without a project info" unless @projectInfo.name throw new Error "Cannot construct a skill without a name" @name = @projectInfo.name # might be a custom parser, if there are extensions @parser = null if window?.literateAlexaParser @parser = window.literateAlexaParser else litexaParser = require './parser.coffee' @parser = eval litexaParser.buildExtendedParser @projectInfo # cache these to customize the handler @extendedEventNames = {} for extensionName, extensionInfo of @projectInfo.extensions continue unless extensionInfo.compiler?.validEventNames? for eventName in extensionInfo.compiler?.validEventNames @extendedEventNames[eventName] = true @extendedEventNames = ( e for e of @extendedEventNames ) # cache these for testing later @directiveValidators = {} @directiveFormatters = {} for extensionName, extensionInfo of @projectInfo.extensions vals = extensionInfo.compiler?.validators?.directives ? {} for directiveName, validator of vals if directiveName of @directiveValidators v = @directiveValidators[directiveName] throw new Error "duplicate directive validator for the directive #{directiveName} found in both #{extensionName} and #{v.sourceExtension}" validator.sourceExtension = extensionName @directiveValidators[directiveName] = validator forms = extensionInfo.compiler?.formatters?.directives ? {} for directiveName, formatter of forms if directiveName of @directiveFormatters v = @directiveFormatters[directiveName] throw new Error "duplicate directive formatter for the directive #{directiveName} found in both #{extensionName} and #{v.sourceExtension}" formatter.sourceExtension = extensionName @directiveFormatters[directiveName] = formatter # sources @files = {} @languages = 'default': {} @reparseLiterateAlexa() setFile: (filename, language, contents) -> unless contents? console.log filename, language, contents throw new Error "probably missing language at skill set file" unless contents? unless contents? console.error "could not set contents of #{filename} to #{contents}" return if filename in ['config.json'] # ignore these, they're used by the system not the skill return @languages[language] = {} unless language of @languages existingFile = @files[filename] if existingFile? existingFile.replaceContent language, contents return match = Files.infoFromFilename filename unless match? throw new Error "couldn't find extension in filename #{filename}" { category, name, extension } = match if name == 'skill' category = 'config' switch extension when "litexa" @files[filename] = new Files.LiterateAlexaFile name, language, "litexa", contents, category @files[filename].parsed = false @files[filename].dirty = true when "js" @files[filename] = new Files.JavaScriptFile name, language, extension, contents, category when "coffee" @files[filename] = new Files.CoffeeScriptFile name, language, extension, contents, category when "json" @files[filename] = new Files.JSONDataFile name, language, extension, contents, category else throw new Error "couldn't work out what to do with extension #{extension} in file #{filename}" getFileContents: (searchFilename, language) -> unless language of @languages language = @getLanguageForRegion(language) for filename, file of @files if filename == searchFilename return file.contentForLanguage(language) return null getExtensions: -> @projectInfo?.extensions ? {} createDefaultStates: -> result = {} result.launch = launch = new lib.State 'launch' result.global = global = new lib.State "global" pushCode = (target, line) -> line.location = { source: 'default state constructor', language: 'default' } target.pushCode line location = intent = global.pushOrGetIntent null, "AMAZON.StopIntent", null intent.defaultedResetOnGet = true pushCode intent, new lib.SetSkillEnd() intent = global.pushOrGetIntent null, "AMAZON.CancelIntent", null intent.defaultedResetOnGet = true pushCode intent, new lib.SetSkillEnd() intent = global.pushOrGetIntent null, "AMAZON.StartOverIntent", null intent.defaultedResetOnGet = true pushCode intent, new lib.Transition("launch", false) result.launch.resetParsePhase() result.global.resetParsePhase() return result reparseLiterateAlexa: -> # parsed parts @states = @createDefaultStates() @tests = {} @dataTables = {} @sayMapping = {} @dbTypes = {} @maxStateNameLength = 16 for extensionName, extension of @projectInfo.extensions continue unless extension.statements? statements = extension.statements continue unless statements.lib? for k, v of statements.lib lib[k] = v for language of @languages # begin from the top of the state again each time for name, state of @states state.resetParsePhase() for name, file of @files if file.extension == 'litexa' try litexaSource = file.contentForLanguage(language) shouldIncludeFile = @parser.parse litexaSource, { lib: lib skill: @ source: file.filename() startRule: 'AllFileExclusions' context: skill: @ } continue unless shouldIncludeFile @parser.parse litexaSource, { lib: lib skill: @ source: file.filename() language: language } catch err err.location = err.location ? {} err.location.source = name err.location.language = language throw err # now that we have all the states, validate connectivity stateNames = ( name for name of @states ) for language of @languages for name, state of @states state.validateTransitions(stateNames, language) # check to see that every slot type is built in or defined # note, custom slots may be defined out of order, so we let # those slide until we get here for language of @languages context = skill: @ language: language types: [] customSlotTypes = [] for name, state of @states state.collectDefinedSlotTypes(context, customSlotTypes) for name, state of @states state.validateSlotTypes(context, customSlotTypes) pushOrGetState: (stateName, location) -> unless stateName of @states @states[stateName] = new lib.State stateName for name of @states @maxStateNameLength = Math.max( @maxStateNameLength, name.length + 2 ) state = @states[stateName] # a state may only exist once per language if state.locations[location.language]? throw new ParserError location, "a state named #{stateName} was already defined at #{formatLocationStart state.locations[location.language]}" # record this as the one state.locations[location.language] = location state.prepareForLanguage(location) # record the default location as primary if location.language == 'default' state.location = location return state pushIntent: (state, location, utterance, intentInfo) -> # scan for duplicates, we'll merge if we see them intent = state.pushOrGetIntent(location, utterance, intentInfo) for stateName, existingState of @states when existingState != state existingIntent = existingState.getIntentInLanguage(location.language, intent.name) if existingIntent? and not existingIntent.referenceIntent? intent.referenceIntent = existingIntent return intent return intent pushCode: (line) -> @startFunction = @startFunction ? new lib.Function @startFunction.lines.push(line) pushTest: (test) -> @tests[test.location.language] = @tests[test.location.language] ? [] @tests[test.location.language].push test pushDataTable: (table) -> # todo name stomp? @dataTables[table.name] = table pushSayMapping: (location, from, to) -> if location.language of @sayMapping for mapping in @sayMapping[location.language] if (mapping.from is from) and (mapping.to is not to) throw new ParserError location, "duplicate pronunciation mapping for \'#{from}\' as \'#{to}\' in \'#{location.language}\' language, previously \'#{mapping.to}\'" @sayMapping[location.language].push({ from, to }) else @sayMapping[location.language] = [{ from, to }] pushDBTypeDefinition: (definition) -> defLocation = definition.location defLanguage = definition.location.language defName = definition.name defType = definition.type @dbTypes[defLanguage] = @dbTypes[defLanguage] ? {} if @dbTypes[defLanguage]?[defName]? throw new ParserError defLocation, "The DB variable #{@dbTypes[defLanguage][defName]} already has the previously defined type #{@dbTypes[defLanguage][defName]} in language #{defLanguage}" @dbTypes[defLanguage][defName] = defType refreshAllFiles: -> litexaDirty = false for name, file of @files if file.extension == 'litexa' and file.dirty litexaDirty = true file.dirty = false if @projectInfoDirty litexaDirty = true @projectInfoDirty = false if litexaDirty @reparseLiterateAlexa() toSkillManifest: -> skillFile = @files['skill.json'] unless skillFile? return "missing skill file" output = JSON.parse(JSON.stringify(skillFile.content)) output.skillManifest?.apis?.custom?.endpoint?.uri = "arn" return JSON.stringify(output, null, 2) toLambda: (options) -> @refreshAllFiles() require('./sayCounter').reset() options = options ? {} @libraryCode = [ "var litexa = exports.litexa;" "if (typeof(litexa) === 'undefined') { litexa = {}; }" "if (typeof(litexa.modulesRoot) === 'undefined') { litexa.modulesRoot = process.cwd(); }" ] if @projectInfo.DEPLOY? @libraryCode.push "litexa.DEPLOY = #{JSON.stringify(@projectInfo.DEPLOY)};" if options.preamble? @libraryCode.push options.preamble else source = fs.readFileSync(__dirname + '/lambda-preamble.coffee', 'utf8') source = coffee.compile(source, {bare: true}) @libraryCode.push source if @projectInfo.useSessionAttributesForPersistentStore source = fs.readFileSync(__dirname + '/lambda-db-session.coffee', 'utf8') else source = fs.readFileSync(__dirname + '/lambda-db-dynamo.coffee', 'utf8') source = coffee.compile(source, {bare: true}) @libraryCode.push source # some functions we'd like to allow developers to override @libraryCode.push "litexa.overridableFunctions = {" @libraryCode.push " generateDBKey: function(identity) {" @libraryCode.push " return `${identity.deviceId}`;" @libraryCode.push " }" @libraryCode.push "};" # @TODO: remove dynamoDb from core litexa into the deploy-aws module ttlConfiguration = @projectInfo.deployments?[@projectInfo.variant]?.dynamoDbConfiguration?.timeToLive if ttlConfiguration?.AttributeName? and ttlConfiguration?.secondsToLive? if typeof(ttlConfiguration.AttributeName) != "string" throw new Error("`dynamoDbConfiguration.AttributeName` must be a string.") if typeof(ttlConfiguration.secondsToLive) != "number" throw new Error("`dynamoDbConfiguration.secondsToLive` must be a number.") @libraryCode.push "litexa.ttlConfiguration = {" @libraryCode.push " AttributeName: '#{ttlConfiguration.AttributeName}'," @libraryCode.push " secondsToLive: #{ttlConfiguration.secondsToLive}" @libraryCode.push "};" else if ttlConfiguration?.AttributeName? or ttlConfiguration?.secondsToLive? console.log "Not setting TTL. If you want to set a TTL, Litexa config requires both `AttributeName` and `secondsToLive` fields in `dynamoDbConfiguration.timeToLive`." librarySource = fs.readFileSync(__dirname + '/litexa-library.coffee', 'utf8') librarySource = coffee.compile(librarySource, {bare: true}) @libraryCode.push librarySource source = fs.readFileSync(__dirname + '/litexa-gadget-animation.coffee', 'utf8') @libraryCode.push coffee.compile(source, {bare: true}) @libraryCode.push @extensionRuntimeCode() @libraryCode.push "litexa.extendedEventNames = #{JSON.stringify @extendedEventNames};" @libraryCode = @libraryCode.join("\n") output = new Array output.push @libraryCode output.push "// END OF LIBRARY CODE" output.push "\n// version summary" output.push "const userAgent = #{JSON.stringify(@projectInfo.userAgent)};\n" output.push "litexa.projectName = '#{@name}';" output.push "var __languages = {};" for language of @languages output.push "__languages['#{language}'] = { enterState:{}, processIntents:{}, exitState:{}, dataTables:{} };" do => output.push "litexa.sayMapping = {" for language of @sayMapping lines = [] output.push " '#{language}': [" for mapping in @sayMapping[language] from = mapping.from.replace /'/g, '\\\'' to = mapping.to.replace /'/g, '\\\'' lines.push " { from: new RegExp(' #{from}','gi'), to: ' #{to}' }" lines.push " { from: new RegExp('#{from} ','gi'), to: '#{to} ' }" output.push lines.join ",\n" output.push " ]," output.push "};" do => shouldIncludeFile = (file) -> return false unless file.extension == 'json' return false unless file.fileCategory == 'regular' return true # write out the default language file data as # an inlined in memory cache output.push "var jsonSourceFiles = {}; " defaultFiles = [] for name, file of @files continue unless shouldIncludeFile file continue unless file.content['default']? output.push "jsonSourceFiles['#{name}'] = #{JSON.stringify(file.content['default'], null, 2)};" defaultFiles.push name output.push "\n" output.push "__languages.default.jsonFiles = {" props = [] for name in defaultFiles props.push " '#{name}': jsonSourceFiles['#{name}']" output.push props.join ",\n" output.push "};\n" # each language is then either a pointer back # to the main cache, or a local override data block for language of @languages continue if language == 'default' files = {} for name, file of @files continue unless shouldIncludeFile file if language of file.content files[name] = JSON.stringify(file.content[language], null, 2) else if 'default' of file.content files[name] = true output.push "__languages['#{language}'].jsonFiles = {" props = [] for name, data of files if data == true props.push " '#{name}': jsonSourceFiles['#{name}']" else props.push " '#{name}': #{data}" output.push props.join ",\n" output.push "};\n" #output.push "exports.dataTables = {};" source = fs.readFileSync(__dirname + '/handler.coffee', 'utf8') source = coffee.compile(source, {bare: true}) output.push source for language of @languages options.language = language output.push "(function( __language ) {" output.push "var enterState = __language.enterState;" output.push "var processIntents = __language.processIntents;" output.push "var exitState = __language.exitState;" output.push "var dataTables = __language.dataTables;" output.push "var jsonFiles = __language.jsonFiles;" output.push @lambdaCodeForLanguage(language, output) do => referenceSourceCode = "var litexa = {};\n" referenceSourceCode += librarySource + "\n" referenceSourceCode += @extensionRuntimeCode() referenceSourceCode += @testLibraryCodeForLanguage(language) + "\n" try # for pro debugging when you get the error about complexity, write # the contents of the reference tester to the .test directory mkdirp.sync path.join @projectInfo.root, '.test' fs.writeFileSync (path.join @projectInfo.root, '.test', 'referenceTester.js'), referenceSourceCode options.referenceTester = makeReferenceTester (path.join @projectInfo.root, 'litexa'), referenceSourceCode # inject code to map typed DB objects to their # types from inside this closure output.push "__language.dbTypes = {" lines = [] for dbTypeName, dbType of @dbTypes[language] lines.push " #{dbTypeName}: #{dbType}" # Copy over any default DB type definitions that aren't explicitly overriden. if language != "default" for dbTypeName, dbType of @dbTypes.default @dbTypes[language] = @dbTypes[language] ? {} @dbTypes[language][dbTypeName] = @dbTypes[language][dbTypeName] ? dbType output.push lines.join ",\n" output.push "};" for name, state of @states state.toLambda output, options output.push "\n" for name, table of @dataTables table.toLambda output, options output.push "\n" output.push "})( __languages['#{language}'] );" output.push "\n" return output.join('\n') extensionRuntimeCode: -> return "" unless @projectInfo? code = [] names = {} list = [] for extensionName, extension of @projectInfo.extensions runtime = extension.runtime continue unless runtime? unless runtime.apiName? throw new Error "Extension `#{extensionName}` specifies it has a runtime component, but didn't provide an apiName key" apiName = runtime.apiName if runtime.apiName of names throw new Error "Extension `#{extensionName}` specifies a runtime component with the apiName `#{apiName}`, but that name is already in use by the `#{names[apiName]}` extension." names[apiName] = extensionName list.push " // #{extensionName} extension" if runtime.require? list.push " ref = require('#{runtime.require}')(context);" else if runtime.source? list.push " ref = (#{runtime.source})(context);" else throw new Error "Extension `#{extensionName}` specified a runtime component, but provides neither require nor source keys." list.push " #{apiName} = ref.userFacing;" list.push " extensionEvents['#{extensionName}'] = ref.events;" list.push " if (ref.requests) { extensionRequests['#{extensionName}'] = ref.requests; }" if list.length > 0 code.push "// *** Runtime objects from loaded extensions" for name of names code.push "let #{name} = null;" code.push "\n" code.push "// *** Initializer functions from loaded extensions" code.push "let extensionEvents = {};" code.push "let extensionRequests = {};" code.push "function initializeExtensionObjects(context){" code.push " let ref = null;" code.push list.join "\n" code.push "};" return code.join '\n' testLibraryCodeForLanguage: (language) -> output = [] output.push "var jsonFiles = {};" for name, file of @files continue unless file.extension == 'json' continue unless file.fileCategory == 'regular' content = file.contentForLanguage(language) if content? output.push "jsonFiles['#{name}'] = #{JSON.stringify(content)};" output.push @lambdaCodeForLanguage language return output.join '\n' lambdaCodeForLanguage: (language) -> output = [] appendFiles = (filter) => for name, file of @files continue unless file.extension == filter continue unless file.fileCategory == 'regular' if file.exception? throw file.exception output.push file.rawForLanguage(language) appendFiles('js') jsCode = output.join '\n' output = [] appendFiles('coffee') try allCode = output.join '\n' coffeeCode = coffee.compile allCode, { bare: true } catch err err.filename = 'allcoffee' return jsCode + '\n' + coffeeCode hasStatementsOfType: (types) -> for name, state of @states return true if state.hasStatementsOfType(types) return false collectRequiredAPIs: (apis) -> @refreshAllFiles() for name, state of @states state.collectRequiredAPIs?(apis) toUtterances: -> @refreshAllFiles() output = [] for name, state of @states state.toUtterances output return output.join('\n') getLanguageForRegion: (region) -> throw new Error "missing region" unless region? language = region unless language of @languages language = language[0...2] if language == 'en' language = 'default' unless language of @languages language = 'default' message = "cannot find language for region #{region} in skill #{@name}, only have #{k for k of @languages}" if @strictMode throw new Error message else console.error message return language toModelV2: (region) -> @refreshAllFiles() unless region? throw new Error "missing region for toModelV2" language = @getLanguageForRegion(region) context = intents: {} language: language skill: @ types: {} output = languageModel: invocationName: "" types: [] intents: [] for name, state of @states state.toModelV2 output, context, @extendedEventNames for name, type of context.types output.languageModel.types.push type if output.languageModel.types.length == 0 delete output.languageModel.types addRequiredIntents = (list) -> intentMap = {} for i in output.languageModel.intents intentMap[i.name] = true for i in list unless i of intentMap output.languageModel.intents.push { name: i } if @hasStatementsOfType ['music'] # audio player required if true addRequiredIntents [ "AMAZON.PauseIntent" "AMAZON.ResumeIntent" ] # audio player optional if false addRequiredIntents [ "AMAZON.CancelIntent" "AMAZON.LoopOffIntent" "AMAZON.LoopOnIntent" "AMAZON.NextIntent" "AMAZON.PreviousIntent" "AMAZON.RepeatIntent" "AMAZON.ShuffleOffIntent" "AMAZON.ShuffleOnIntent" ] # display optional if false addRequiredIntents [ "AMAZON.NavigateHomeIntent" ] # ?? addRequiredIntents [ "AMAZON.StartOverIntent" ] # This one is required, and SMAPI will actually auto insert it addRequiredIntents [ "AMAZON.NavigateHomeIntent" ] invocation = @name.replace /[^a-zA-Z0-9 ]/g, ' ' if @files['skill.json'] read = @files['skill.json'].content.manifest.publishingInformation?.locales?[region]?.invocationName invocation = read if read? output.languageModel.invocationName = invocation.toLowerCase() return output hasIntent: (name, language) -> for n, state of @states return true if state.hasIntent name, language return false toLocalization: () -> @refreshAllFiles() localization = { intents: {}, speech: {} } for name, state of @states state.toLocalization(localization) return localization runTests: (options, cb, tests) -> testContext = new lib.TestContext @, options testContext.litexaRoot = '' if @config?.root? #process.chdir @config.root testContext.litexaRoot = path.join @config.root, 'litexa' if @projectInfo?.root? testContext.litexaRoot = path.join @projectInfo.root, 'litexa' testContext.testRoot = path.join testContext.litexaRoot, '..', '.test' for k in ['abbreviateTestOutput', 'strictMode', 'testDevice'] if options?[k]? @[k] = options[k] options.reportProgress = options.reportProgress ? () -> unless @abbreviateTestOutput? @abbreviateTestOutput = true testRegion = options.region ? 'en-US' testContext.language = @testLanguage = @getLanguageForRegion testRegion # test the language model doesn't have any errors languageModel = @toModelV2 testRegion # mock some things external to the handler db = new (require('./mockdb.coffee'))() testContext.db = db Entitlements = require './mockEntitlements.coffee' # for better error reporting, while testing prefer to have tracing on unless process.env.enableStateTracing? process.env.enableStateTracing = true # capture the lambda compilation exports = {} testContext.lambda = exports exports.litexa = assetsRoot: 'test://' localTesting: true localTestRoot: @projectInfo.testRoot localAssetsRoot: path.join testContext.litexaRoot, 'assets' modulesRoot: path.join testContext.litexaRoot reportProgress: options.reportProgress testContext.litexa = exports.litexa exports.executeInContext = (line) -> eval(line) try @lambdaSource = @toLambda({preamble: "", strictMode: options.strictMode}) @lambdaSource += """ escapeSpeech = function(line) { return ("" + line).replace(/ /g, '\u00A0'); } """ catch err console.error "failed to construct skill function" return cb err, { summary: err.stack } try process.env.NODE_PATH = path.join testContext.litexaRoot, 'node_modules' require("module").Module._initPaths() if @projectInfo?.testRoot? fs.writeFileSync (path.join @projectInfo.testRoot, 'test.js'), @lambdaSource, 'utf8' eval @lambdaSource catch err # see if we can catch the source # console.error err ### try Module = require 'module' tmp = new Module tmp._compile @lambdaSource, 'test.js' catch err2 if err2.toString() == err.toString() console.error err2.stack ### return cb err, { summary: "Failed to bind skill function, check your inline code for errors." } Logging = exports.Logging Logging.log = -> console.log.apply console, arguments Logging.error = -> console.error.apply console, arguments exports.Logging = Logging # determine which tests to run remainingTests = [] if tests remainingTests.push t for t in tests else focusTest = (testfilename, testname) -> return true unless options.focusedFiles? for f in options.focusedFiles if testfilename.indexOf(f) >= 0 return true if testname and (testname.indexOf(f) >= 0) return true return false includedTests = {} if @testLanguage of @tests for test in @tests[@testLanguage] continue unless focusTest(test.sourceFilename, test.name) remainingTests.push test includedTests[test.sourceFilename] = true if @tests.default? for test in @tests['default'] continue unless focusTest(test.sourceFilename, test.name) continue if includedTests[test.sourceFilename] remainingTests.push test for name, file of @files when file.isCode and file.fileCategory == 'test' test = new testing.lib.CodeTest file unless focusTest(file.filename(), null) test.filters = options.focusedFiles ? null remainingTests.push test # resolve dependent captures # if the focused tests rely on resuming state # from another test, then we need to pull them # into the list captureNeeds = {} captureHaves = {} for t in remainingTests if t.resumesNames? for n in t.resumesNames captureNeeds[n] = true if t.capturesNames? for n in t.capturesNames captureHaves[n] = true for need of captureNeeds unless need of captureHaves do => if @testLanguage of @tests for test in @tests[@testLanguage] if test.capturesNames? and need in test.capturesNames captureHaves[need] = true remainingTests.push test return for test in @tests['default'] if test.capturesNames? and need in test.capturesNames captureHaves[need] = true remainingTests.push test return do => # order by capture dependency: # rebuild list by looping repeatedly through inserting, # but only when capture dependency is already in list testCount = remainingTests.length presorted = remainingTests remainingTests = [] savedNames = [] for looping in [0...testCount] break if remainingTests.length == testCount for test in presorted ready = true if test.resumesNames for name in test.resumesNames ready = false unless name in savedNames if ready remainingTests.push test if test.capturesNames savedNames.push n for n in test.capturesNames test.capturesSorted = true presorted = ( t for t in presorted when not t.capturesSorted ) unless remainingTests.length == testCount names = ( t.name for t in presorted ) throw Error "Couldn't find states to resume for #{testCount - remainingTests.length} tests: #{JSON.stringify names}" testContext.collectAllSays() # accumulate output successes = 0 fails = 0 failedTests = [] output = { log:[], cards:[], directives:[], raw:[] } unless options.singleStep output.log.push "Testing in region #{options.region}, language #{@testLanguage} out of #{JSON.stringify (k for k of @languages)}" for name, file of @files if file.exception? output.log.push "Error with file #{file.filename()}: #{file.exception}" # step through each test asynchronously testCounter = 1 totalTests = remainingTests.length lastTimeStamp = new Date firstTimeStamp = new Date nextTest = => if remainingTests.length == 0 totalTime = new Date - firstTimeStamp options.reportProgress( "test steps complete #{testCounter-1}/#{totalTests} #{totalTime}ms total" ) if fails output.summary = "✘ #{successes + fails} tests run, #{fails} failed (#{totalTime}ms)\nFailed tests were:\n " + failedTests.join("\n ") else output.summary = "✔ #{successes} tests run, all passed (#{totalTime}ms)\n" unless options.singleStep output.log.unshift output.summary output.tallies = successes: successes fails: fails output.success = fails == 0 cb null, output, testContext return testContext.db.reset() test = remainingTests.shift() options.reportProgress( "test step #{testCounter++}/#{totalTests} +#{new Date - lastTimeStamp}ms: #{test.name ? test.file?.filename()}" ) lastTimeStamp = new Date test.test testContext, output, (err, successCount, failCount, failedTestName) => successes += successCount fails += failCount if failedTestName failedTests.push(failedTestName) if remainingTests.length > 0 and (successCount + failCount) > 0 output.log.push "\n" nextTest() nextTest() resumeSingleTest: (testContext, test, cb) -> output = { log:[], cards:[], directives:[], raw:[] } try test.test testContext, output, (err, successCount, failCount) => cb null, output catch err cb err, output reportIntents: (language) -> @refreshAllFiles() result = {} for name, state of @states state.reportIntents language, result return ( k for k of result ) reportError = (e, src, filename) -> if e.location? loc = e.location console.log "ERROR: #{filename}(#{loc.start?.line}:#{loc.start?.column}) " console.log e.message for line, lineno in src.split '\n' if Math.abs( lineno - loc.start.line ) < 2 console.log "#{lineno+1} > #{line}" if lineno == loc.start.line - 1 pad = 3 + "#{lineno+1}".length pad = (' ' for i in [0...pad]).join '' ind = pad + (' ' for i in [1...loc.start.column]).join('') ind += ('^' for i in [loc.start.column .. loc.end.column]).join('') console.log ind else console.error "parse error with no location" console.error e exp.Skill = lib.Skill exp.reportError = reportError exp.parse = (text, filename, language, reportErrors) -> try skill = new lib.Skill dot = filename.lastIndexOf('.') skill.name = filename.substr(0, dot) skill.setFile filename, language, text return skill catch e if reportErrors reportError(e, text, filename) throw e return null