@litexa/core
Version:
Litexa, a programming language for writing Alexa skills
1,040 lines (853 loc) • 35 kB
text/coffeescript
###
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# 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