sourceafFancordion::FancordionRunner.fan

using fandoc
using afBeanUtils
using concurrent

** Runs Fancordion fixtures.
class FancordionRunner {
    private static const Log log        := Utils.getLog(FancordionRunner#)
    
    ** Where the generated HTML result files are saved.
    File        outputDir               := Env.cur.tempDir + `fancordion/`
    
    ** The skin applied to generated HTML result files.
    ** 
    ** Defaults to `ClassicSkin`.
    Type        skinType                := ClassicSkin#
    
    // This way there is a clean separation between the cmd and key func 
    ** The commands made available to Fancordion tests.
    ** 
    ** The key may either be a 'Str' (that matches the URI scheme) or an immutable func of '|Str cmdUrl->Bool|'.  
    Obj:Command commands                := Obj:Command[:]

    ** A command chain of 'SpecificationFinders'.
    @NoDoc
    SpecificationFinder[] specFinders   := SpecificationFinder[,]

    ** A hook that creates an 'FancordionSkin' instance.
    ** 
    ** Simply returns 'skinType.make()' by default.
    |->FancordionSkin| gimmeSomeSkin    := |->FancordionSkin| { skinType.make }
    
    ** Section functions determine which headings correspond to sections and should be wrapped in a special border. 
    ** The default function checks if the heading starts with the word *Example*:
    ** 
    **   syntax: fantom
    **   runner.sectionFuncs.add(
    **       |Str heading, Int level->Bool| {
    **           heading.lower.startsWith("example") 
    **       }
    **   )
    |Str, Int->Bool|[] sectionFuncs     := |Str, Int->Bool|[,]
    
    ** Creates a 'FancordionRunner'.
    new make() {
        commands["verifyEq"]        = CmdVerify("verifyEq")
        commands["verifyNotEq"]     = CmdVerify("verifyNotEq")
        commands["verifyType"]      = CmdVerify("verifyType")
        commands["verify"]          = CmdVerify("verify")
        commands["verifyTrue"]      = CmdVerify("verify")
        commands["verifyFalse"]     = CmdVerify("verifyFalse")
        commands["verifyNull"]      = CmdVerify("verifyNull")
        commands["verifyNotNull"]   = CmdVerify("verifyNotNull")
        commands["verifyErrType"]   = CmdVerifyErrType()
        commands["verifyErrMsg"]    = CmdVerifyErrMsg()
        commands["set"]             = CmdSet()
        commands["execute"]         = CmdExecute()
        commands["fail"]            = CmdFail()
        commands["run"]             = CmdRun()
        commands["table"]           = CmdTable()
        commands["embed"]           = CmdEmbed()
        commands["link"]            = CmdLink()
        commands["http"]            = CmdLink()
        commands["https"]           = CmdLink()
        commands["mailto"]          = CmdLink()
        commands["file"]            = CmdLink()
        commands["fandoc"]          = CmdFandoc()
        commands["todo"]            = CmdIgnore()

        commands[|Str cmdUrl->Bool| {
            cmdUrl.startsWith("#")
        }.toImmutable] = CmdLink()

        commands[|Str cmdUrl->Bool| {
            cmdUrl.contains("::") && cmdUrl.all { it.isAlphaNum || it == ':' }
        }.toImmutable] = CmdFandoc()

        // add shortcut aliases
        commands["eq"]      = commands["verifyEq"]
        commands["notEq"]   = commands["verifyNotEq"]
        commands["type"]    = commands["verifyType"]
        commands["true"]    = commands["verifyTrue"]
        commands["false"]   = commands["verifyFalse"]
        commands["null"]    = commands["verifyNull"]
        commands["notNull"] = commands["verifyNotNull"]
        commands["errType"] = commands["verifyErrType"]
        commands["errMsg"]  = commands["verifyErrMsg"]
        commands["exe"]     = commands["execute"]

        specFinders.add(FindSpecFromFacetValue())
        specFinders.add(FindSpecFromTypeFandoc())
        specFinders.add(FindSpecFromTypeInSrcFile())
        specFinders.add(FindSpecFromTypeInPodFile())
        specFinders.add(FindSpecInPodFile())
        specFinders.add(FindSpecOnFileSystem())
        
        sectionFuncs.add(
            |Str heading, Int level->Bool| { heading.lower.startsWith("example") }
        )
    }

    ** Runs the given Fancordion fixture.
    FixtureResult runFixture(Obj fixtureInstance) {
        // we need the fixture *instance* so it can have any state, 
        // and if a Test instance, verify cmd uses it to notch up the verify count
        
        if (!fixtureInstance.typeof.hasFacet(Fixture#))
            throw ArgErr(ErrMsgs.fixtureFacetNotFound(fixtureInstance.typeof))

        locals := Locals.instance
        firstFixture := (locals.originalRunner == null) 
        if (firstFixture) {
            locals.originalRunner = this
            suiteSetup()

            // when multiple tests (e.g. a pod) are run with fant we have NO way of knowing which is 
            // the last test because sadly fant has no hooks. So for a clean teardown the best we can
            // do is a shutdown hook.
            // AND Actor.locals() has been cleaned up by the time we get here, so we need to keep our
            // own references.
            safeRunner   := Unsafe(locals.originalRunner)
            safeResults  := Unsafe(locals.resultsCache)
            shutdownHook := |->| { 
                ((FancordionRunner) safeRunner.val).suiteTearDown(safeResults.val)
                Locals.instance.shutdownHook = null
                Locals.instance.resultsCache = null
            }
            Env.cur.addShutdownHook(shutdownHook)
            
            // stick the hook in Actor locals in case someone wants to remove it
            locals.shutdownHook = shutdownHook
        }
        
        
        // don't run tests twice - e.g. from fant and from a `run:cmd`
        if (locals.resultsCache.containsKey(fixtureInstance.typeof)) {
            log.info("Fixture '${fixtureInstance.typeof.qname}' has already been run - Skipping...")
            return locals.resultsCache[fixtureInstance.typeof]
        }


        // a small hook so Test classes can notch up extra verify counts.
        if (fixtureInstance is Test)
            Actor.locals["afBounce.testInstance"] = fixtureInstance
        
        
        startTime   := DateTime.now(null)
        specMeta    := SpecificationFinders(specFinders).findSpecification(fixtureInstance.typeof)
        doc         := FandocParser().parseStr(specMeta.specificationSrc)
        docTitle    := doc.findHeadings.first?.title ?: specMeta.fixtureType.name.fromDisplayName
        podName     := specMeta.fixtureType.pod?.name ?: "no-name"
        
        if (specMeta.fixtureType.pod != null)
            if (specMeta.fixtureType.pod.meta["pod.isScript"].toBool(false))
                podName = "from-script"

        outputDir.createDir(podName)
        resultFile  := this.outputDir.plus(podName.toUri, false) + `${fixtureInstance.typeof.name}.html` 
        
        fixMeta     := FixtureMeta() {
            it.title            = docTitle
            it.fixtureType      = specMeta.fixtureType
            it.specificationLoc = specMeta.specificationLoc
            it.specificationSrc = specMeta.specificationSrc
            it.baseOutputDir    = this.outputDir
            it.resultFile       = resultFile
            it.StartTime        = startTime
        }
        
        fixCtx      := FixtureCtx() {
            it.fancordionRunner = this
            it.fixtureInstance  = fixtureInstance
            it.skin             = gimmeSomeSkin()
            it.errs             = Err[,]
            it.stash            = Str:Obj[:] { it.caseInsensitive = true }
        }
        
        try {
            ThreadStack.push("afFancordion.runner", this)
            ThreadStack.push("afFancordion.fixtureMeta", fixMeta)
            ThreadStack.push("afFancordion.fixtureCtx", fixCtx)
                    
            fixtureSetup(fixtureInstance)
            
            resultHtml := (Str?) null
            try {
                resultHtml  = renderFixture(doc, fixCtx)    // --> RUN THE TEST!!!
            } catch (Err err) {
                fixCtx.errs.add(err)
                resultHtml = errorPage(fixMeta, err)
            }
            
            resultFile.out.print(resultHtml).close
                        
            result := FixtureResult {
                it.fixtureMeta  = fixMeta
                it.resultHtml   = resultHtml
                it.resultFile   = resultFile
                it.errors       = fixCtx.errs
                it.timestamp    = DateTime.now(null)
                it.duration     = startTime - it.timestamp
            }
            
            // cache the result so we can short-circuit should it called again
            locals.resultsCache[fixtureInstance.typeof] = result

            fixtureTearDown(fixtureInstance, result)

            return result
            
        } finally {
            ThreadStack.pop("afFancordion.runner")
            ThreadStack.pop("afFancordion.fixtureMeta")
            ThreadStack.pop("afFancordion.fixtureCtx")
            
            // no too bother if this never gets cleaned up
            Actor.locals.remove("afBounce.testInstance")
        }
    }
    
    ** Called before the first fixture is run.
    ** 
    ** By default this empties the output dir. 
    virtual Void suiteSetup() {
        // wipe the slate clean to begin with
        try {
            outputDir.create
            // it's better to delete the contents than the actual folder
            // some programs count on the folder always existing
            outputDir.list.each { it.delete }

        } catch (Err err) {
            // sometimes the files are locked...
            msg := "\n"
            msg += "*******************************************************************************\n"
            msg += "** ${err.msg}\n"
            msg += "*******************************************************************************\n"
            msg += "\n"
            log.warn(msg)
        }
    }

    ** Called after the last fixture has run.
    ** 
    ** Writes an 'index.html' file that redirects to the first Fixture run.
    ** 
    ** Logs the final report.
    virtual Void suiteTearDown(Type:FixtureResult resultsCache) {
        indexFile := outputDir + `index.html`
        if (!indexFile.exists && !resultsCache.isEmpty) {
            url := resultsCache.vals.last.resultFile.uri.relTo(outputDir.uri)
            indexFile.out.print("""<html><head><meta http-equiv="refresh" content="0; url=${url.encode.toXml}" /></head></html>""").close
        }
                
        log.info(finalReport(resultsCache))
    }

    ** Creates a pretty report to be printed after the last fixture has been run. 
    virtual Str finalReport(Type:FixtureResult resultsCache) {
        report       := "\n\n"
        noOfFailures := resultsCache.reduce(0) |Int failures, result| { failures += result.errors.isEmpty ? 0 : 1 }
        
        if (noOfFailures > 0) {
            report += "Failed:\n"
            maxPad := resultsCache.reduce(0) |Int pad, result| { pad.max(result.errors.size > 0 ? result.fixtureMeta.fixtureType.qname.size : 0) } as Int
            resultsCache.each |result| {
                if (result.errors.size > 0) {
                    report += "  " + result.fixtureMeta.fixtureType.qname.plus(" ").padr(maxPad+3, '.') + ". " + result.resultFile.normalize.osPath + "\n"
                }
            }           
            report += "\n"
        }
        
        result := noOfFailures == 0 ? "All Fixtures Passed!" : "$noOfFailures Fixtures FAILED"
        report += "***\n"
        report += "*** ${result} [${resultsCache.size} Fixtures]\n"
        report += "***\n"
        return report
    }
    
    ** Called before every fixture.
    ** 
    ** By default does nothing. 
    virtual Void fixtureSetup(Obj fixtureInstance) { }

    ** Called after every fixture.
    ** 
    ** By default prints the location of the result file. 
    virtual Void fixtureTearDown(Obj fixtureInstance, FixtureResult result) {
        pf := result.errors.isEmpty ? " ... Ok" : " ... FAILED!"
        log.info(result.resultFile.normalize.osPath + pf)
    }
    
    ** Rendered when a Fixture fails for an unknown reason - usually due to an error in the skin.
    ** 
    ** By default this just renders the stack trace.
    virtual Str errorPage(FixtureMeta fixMeta, Err err) { 
"""<!DOCTYPE html>
   <html xmlns="http://www.w3.org/1999/xhtml">
   <head>
    <title>${fixMeta.title.toXml} : Fancordion</title>
   </head>
   <body>
   <pre>
   ${err.traceToStr}
   </pre>
   </body>
   </html>
   """
    }
    
//  meh - not convinced this is useful!
//  ** A quick win for calling cmds from within other cmds.
//  Void runCmd(Str cmdText, Str cmdUri) {
//      if (FixtureCtx.cur == null)
//          throw Err("Could not find a current FixtureCtx - cmdRun() can only be called from within an executing fixture")
//      cmds := Commands(commands)
//      cmds.doCmd(FixtureCtx.cur, cmdUri, cmdText, null, null, null)
//  }
    
    @NoDoc @Deprecated { msg="Use 'cur()' instead" } 
    static FancordionRunner? current() {
        ThreadStack.peek("afFancordion.runner", false)
    }
    
    ** Returns the current 'FancordionRunner' in use, or 'null' if no tests are running. 
    static FancordionRunner? cur() {
        ThreadStack.peek("afFancordion.runner", false)
    }
    
    private Str renderFixture(Doc doc, FixtureCtx fixCtx) {
        cmds := Commands(commands)
        fdw  := FixtureDocWriter(cmds, sectionFuncs, fixCtx)
        fixCtx.skin.setup
        try {
            fdw.docStart(doc)
            doc.writeChildren(fdw)
            fdw.docEnd(doc)
        } finally
            fixCtx.skin.tearDown
        return fixCtx.skin.renderBuf.toStr
    }
}