diff --git a/cmd/falco/main.go b/cmd/falco/main.go index eeb81f52..ebabe025 100644 --- a/cmd/falco/main.go +++ b/cmd/falco/main.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "math" "os" "strings" @@ -12,8 +13,11 @@ import ( "github.com/mattn/go-colorable" "github.com/pkg/errors" "github.com/ysugimoto/falco/config" + ife "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/lexer" "github.com/ysugimoto/falco/resolver" "github.com/ysugimoto/falco/terraform" + "github.com/ysugimoto/falco/token" ) var version string = "" @@ -27,6 +31,12 @@ var ( cyan = color.New(color.FgCyan) magenta = color.New(color.FgMagenta) + // Displaying test result needs adding background colors + noTestColor = color.New(color.FgBlack, color.BgWhite, color.Bold) + passColor = color.New(color.FgWhite, color.BgGreen, color.Bold) + failColor = color.New(color.FgWhite, color.BgRed, color.Bold) + redBold = color.New(color.FgRed, color.Bold) + ErrExit = errors.New("exit") ) @@ -35,6 +45,7 @@ const ( subcommandTerraform = "terraform" subcommandLocal = "local" subcommandStats = "stats" + subcommandTest = "test" ) func write(c *color.Color, format string, args ...interface{}) { @@ -63,6 +74,7 @@ Subcommands: lint : Run lint (default) stats : Analyze VCL statistics local : Run local simulate server with provided VCLs + test : Run local testing for provided VCLs Flags: -I, --include_path : Add include path @@ -85,6 +97,9 @@ Get statistics example: Local server with debugger example: falco -I . local -debug /path/to/vcl/main.vcl +Local testing with builtin interpreter: + falco -I . -I ./tests test /path/to/vcl/main.vcl + Linting with terraform: terraform plan -out planned.out terraform show -json planned.out | falco -vv terraform @@ -117,8 +132,8 @@ func main() { resolvers = resolver.NewTerraformResolver(fastlyServices) fetcher = terraform.NewTerraformFetcher(fastlyServices) } - case subcommandLocal, subcommandLint, subcommandStats: - // "lint", "local" and "stats" command provides single file of service, + case subcommandLocal, subcommandLint, subcommandStats, subcommandTest: + // "lint", "local", "stats" and "test" command provides single file of service, // then resolvers size is always 1 resolvers, err = resolver.NewFileResolvers(c.Commands.At(1), c.IncludePaths) default: @@ -141,12 +156,10 @@ func main() { var exitErr error switch c.Commands.At(0) { + case subcommandTest: + exitErr = runTest(runner, v) case subcommandLocal: - if c.Debug { - exitErr = runDebugger(runner, v) - } else { - exitErr = runSimulator(runner, v) - } + exitErr = runSimulate(runner, v) case subcommandStats: exitErr = runStats(runner, v) default: @@ -223,17 +236,9 @@ func runLint(runner *Runner, rslv resolver.Resolver) error { return nil } -func runDebugger(runner *Runner, rslv resolver.Resolver) error { - if err := runner.Debugger(rslv); err != nil { - writeln(red, "Failed to start debugger console: %s", err.Error()) - return ErrExit - } - return nil -} - -func runSimulator(runner *Runner, rslv resolver.Resolver) error { - if err := runner.Simulator(rslv); err != nil { - writeln(red, "Failed to start server: %s", err.Error()) +func runSimulate(runner *Runner, rslv resolver.Resolver) error { + if err := runner.Simulate(rslv); err != nil { + writeln(red, "Failed to start local simulator: %s", err.Error()) return ErrExit } return nil @@ -257,6 +262,9 @@ func runStats(runner *Runner, rslv resolver.Resolver) error { } return ErrExit } + printStats := func(format string, args ...interface{}) { + fmt.Fprintf(os.Stdout, format+"\n", args...) + } printStats(strings.Repeat("=", 80)) printStats("| %-76s |", "falco VCL statistics ") @@ -280,6 +288,76 @@ func runStats(runner *Runner, rslv resolver.Resolver) error { return nil } -func printStats(format string, args ...interface{}) { - fmt.Fprintf(os.Stdout, format+"\n", args...) +func runTest(runner *Runner, rslv resolver.Resolver) error { + write(white, "Running tests...") + results, counter, err := runner.Test(rslv) + if err != nil { + writeln(red, " Failed.") + writeln(red, "Failed to run test: %s", err.Error()) + return ErrExit + } + + // shrthand indent making + indent := func(level int) string { + return strings.Repeat(" ", level*2) + } + // print problem line + printCodeLine := func(lx *lexer.Lexer, tok token.Token) { + problemLine := tok.Line + lineFormat := fmt.Sprintf(" %%%dd", int(math.Floor(math.Log10(float64(problemLine+1))+1))) + for l := problemLine - 1; l <= problemLine+1; l++ { + line, ok := lx.GetLine(l) + if !ok { + continue + } + color := white + if l == problemLine { + color = yellow + } + writeln(color, "%s "+lineFormat+"| %s", indent(1), l, strings.ReplaceAll(line, "\t", " ")) + } + } + + writeln(white, " Done.") + for _, r := range results { + switch { + case len(r.Cases) == 0: + write(noTestColor, " NO TESTS ") + writeln(white, " "+r.Filename) + case r.IsPassed(): + write(passColor, " PASS ") + writeln(white, " "+r.Filename) + default: + write(failColor, " FAIL ") + writeln(white, " "+r.Filename) + } + + for _, c := range r.Cases { + if c.Error != nil { + writeln(redBold, "%s● [%s] %s\n", indent(1), c.Scope, c.Name) + writeln(red, "%s%s", indent(2), c.Error.Error()) + switch e := c.Error.(type) { + case *ife.AssertionError: + write(white, "%sActual Value: ", indent(2)) + writeln(red, "%s\n", e.Actual.String()) + printCodeLine(r.Lexer, e.Token) + case *ife.TestingError: + writeln(white, "") + printCodeLine(r.Lexer, e.Token) + } + writeln(white, "") + } else { + writeln(green, "%s✓ [%s] %s", indent(1), c.Scope, c.Name) + } + } + } + + write(green, "%d passed, ", counter.Passes) + if len(counter.Fails) > 0 { + write(red, "%d failed, ", len(counter.Fails)) + } + write(white, "%d total, ", len(results)) + writeln(white, "%d assertions", counter.Asserts) + + return nil } diff --git a/cmd/falco/runner.go b/cmd/falco/runner.go index 8705f8af..9c5ac537 100644 --- a/cmd/falco/runner.go +++ b/cmd/falco/runner.go @@ -22,6 +22,7 @@ import ( "github.com/ysugimoto/falco/plugin" "github.com/ysugimoto/falco/remote" "github.com/ysugimoto/falco/resolver" + "github.com/ysugimoto/falco/tester" "github.com/ysugimoto/falco/types" ) @@ -442,11 +443,12 @@ func (r *Runner) Stats(rslv resolver.Resolver) (*StatsResult, error) { return stats, nil } -func (r *Runner) Simulator(rslv resolver.Resolver) error { +func (r *Runner) Simulate(rslv resolver.Resolver) error { + local := r.config.Local options := []icontext.Option{ icontext.WithResolver(rslv), - icontext.WithMaxBackends(r.config.OverrideMaxBackends), - icontext.WithMaxAcls(r.config.OverrideMaxAcls), + icontext.WithMaxBackends(local.OverrideMaxBackends), + icontext.WithMaxAcls(local.OverrideMaxAcls), } if r.snippets != nil { options = append(options, icontext.WithFastlySnippets(r.snippets)) @@ -454,35 +456,43 @@ func (r *Runner) Simulator(rslv resolver.Resolver) error { if v := os.Getenv("FALCO_DEBUG"); v != "" { options = append(options, icontext.WithDebug()) } - if r.config.OverrideRequest != nil { - options = append(options, icontext.WithRequest(r.config.OverrideRequest)) + if local.OverrideRequest != nil { + options = append(options, icontext.WithRequest(local.OverrideRequest)) } i := interpreter.New(options...) + + // If debugger flag is on, run debugger mode + if local.Debug { + return debugger.New(interpreter.New(options...)).Run(local.Port) + } + + // Otherwise, simply start simulator server mux := http.NewServeMux() mux.Handle("/", i) s := &http.Server{ Handler: mux, - Addr: ":3124", + Addr: fmt.Sprintf(":%d", local.Port), } writeln(green, "Simulator server starts on 0.0.0.0:3124") return s.ListenAndServe() } -func (r *Runner) Debugger(rslv resolver.Resolver) error { +func (r *Runner) Test(rslv resolver.Resolver) ([]*tester.TestResult, *tester.TestCounter, error) { + testing := r.config.Testing options := []icontext.Option{ icontext.WithResolver(rslv), - icontext.WithMaxBackends(r.config.OverrideMaxBackends), - icontext.WithMaxAcls(r.config.OverrideMaxAcls), + icontext.WithMaxBackends(testing.OverrideMaxBackends), + icontext.WithMaxAcls(testing.OverrideMaxAcls), } if r.snippets != nil { options = append(options, icontext.WithFastlySnippets(r.snippets)) } - if r.config.OverrideRequest != nil { - options = append(options, icontext.WithRequest(r.config.OverrideRequest)) + if r.config.Testing.OverrideRequest != nil { + options = append(options, icontext.WithRequest(testing.OverrideRequest)) } - d := debugger.New(interpreter.New(options...)) - return d.Run(r.config.Port) + i := interpreter.New(options...) + return tester.New(testing, i).Run(r.config.Commands.At(1)) } diff --git a/config/config.go b/config/config.go index c2639c1a..95cb7aa5 100644 --- a/config/config.go +++ b/config/config.go @@ -8,10 +8,37 @@ import ( "github.com/ysugimoto/twist" ) -const ( - configurationFile = ".falco.yaml" +var ( + configurationFiles = []string{".falco.yaml", ".falco.yml"} ) +// Local simulation configuration +type LocalConfig struct { + Port int `cli:"p,port" yaml:"port" default:"3124"` + Debug bool `cli:"debug"` + IncludePaths []string // may copied from root field + + // Override resource limits + OverrideMaxBackends int `cli:"max_backend" yaml:"override_max_backends"` + OverrideMaxAcls int `cli:"mac_acl" yaml:"override_max_acls"` + + // Override Request configuration + OverrideRequest *RequestConfig +} + +// Testing configuration +type TestConfig struct { + Timeout int `cli:"timeout" yaml:"timeout"` + IncludePaths []string // may copied from root field + + // Override resource limits + OverrideMaxBackends int `cli:"max_backend" yaml:"override_max_backends"` + OverrideMaxAcls int `cli:"mac_acl" yaml:"override_max_acls"` + + // Override Request configuration + OverrideRequest *RequestConfig +} + type Config struct { // Root configurations IncludePaths []string `cli:"I,include_path" yaml:"include_paths"` @@ -23,16 +50,7 @@ type Config struct { Version bool `cli:"V"` Remote bool `cli:"r,remote" yaml:"remote"` Json bool `cli:"json"` - Port int `cli:"p,port" yaml:"port" default:"3124"` Request string `cli:"request"` - Debug bool `cli:"debug"` - - // Override resource limits - OverrideMaxBackends int `cli:"max_backend" yaml:"override_max_backends"` - OverrideMaxAcls int `cli:"mac_acl" yaml:"override_max_acls"` - - // Override Request configuration - OverrideRequest *RequestConfig // Remote options, only provided via environment variable FastlyServiceID string `env:"FASTLY_SERVICE_ID"` @@ -43,6 +61,10 @@ type Config struct { // CLI subcommands Commands Commands + + // Local simulator configuration + Local *LocalConfig `yaml:"local"` + Testing *TestConfig `yaml:"testing"` } // Adding type alias in order to implement some methods @@ -60,7 +82,12 @@ func New(args []string) (*Config, error) { options = append(options, twist.WithEnv(), twist.WithCli(args)) c := &Config{ - OverrideRequest: &RequestConfig{}, + Local: &LocalConfig{ + OverrideRequest: &RequestConfig{}, + }, + Testing: &TestConfig{ + OverrideRequest: &RequestConfig{}, + }, } if err := twist.Mix(c, options...); err != nil { return nil, errors.WithStack(err) @@ -75,13 +102,18 @@ func New(args []string) (*Config, error) { c.VerboseInfo = true } - // Load request configuration + // Load request configuration if provided if c.Request != "" { if rc, err := LoadRequestConfig(c.Request); err == nil { - c.OverrideRequest = rc + c.Local.OverrideRequest = rc + c.Testing.OverrideRequest = rc } } + // Copy common fields + c.Local.IncludePaths = c.IncludePaths + c.Testing.IncludePaths = c.IncludePaths + return c, nil } @@ -93,9 +125,11 @@ func findConfigFile() (string, error) { } for { - file := filepath.Join(cwd, configurationFile) - if _, err := os.Stat(file); err == nil { - return file, nil + for _, f := range configurationFiles { + file := filepath.Join(cwd, f) + if _, err := os.Stat(file); err == nil { + return file, nil + } } cwd = filepath.Dir(cwd) diff --git a/config/config_test.go b/config/config_test.go index 60157572..f4612e49 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -41,17 +41,24 @@ func TestConfigFromCLI(t *testing.T) { } expect := &Config{ - IncludePaths: []string{"."}, - Help: true, - VerboseLevel: "", - VerboseWarning: true, - VerboseInfo: true, - Version: true, - Remote: true, - Json: true, - Commands: Commands{"lint"}, - Port: 3124, - OverrideRequest: &RequestConfig{}, + IncludePaths: []string{"."}, + Help: true, + VerboseLevel: "", + VerboseWarning: true, + VerboseInfo: true, + Version: true, + Remote: true, + Json: true, + Commands: Commands{"lint"}, + Local: &LocalConfig{ + Port: 3124, + IncludePaths: []string{"."}, + OverrideRequest: &RequestConfig{}, + }, + Testing: &TestConfig{ + IncludePaths: []string{"."}, + OverrideRequest: &RequestConfig{}, + }, } if diff := cmp.Diff(c, expect, cmpopts.IgnoreFields(Config{}, "FastlyServiceID", "FastlyApiKey")); diff != "" { diff --git a/examples/default.test.vcl b/examples/default.test.vcl new file mode 100644 index 00000000..22fb632c --- /dev/null +++ b/examples/default.test.vcl @@ -0,0 +1,19 @@ +// @scope: recv +// @suite: Foo request header should contains "hoge" +sub test_vcl_recv { + set req.http.Foo = "bar"; + testing.call_subroutine("vcl_recv"); + + assert.equal(req.backend, httpbin_org); + assert.contains(req.http.Foo, "hoge"); +} + +// @scope: deliver +// @suite: X-Custom-Header response header should contains "hoge" +sub test_vcl_deliver { + set req.http.Foo = "bar"; + testing.call_subroutine("vcl_deliver"); + assert.contains(resp.http.X-Custom-Header, "hoge"); +} + + diff --git a/go.mod b/go.mod index aecbecab..ee877f05 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/google/go-cmp v0.5.9 github.com/google/uuid v1.3.0 github.com/k0kubun/pp v2.4.0+incompatible - github.com/kyokomi/emoji v1.5.1 + github.com/kyokomi/emoji v2.2.4+incompatible github.com/mattn/go-colorable v0.1.8 github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.4.0 diff --git a/go.sum b/go.sum index 5bfdb908..5e21ae59 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kyokomi/emoji v1.5.1 h1:qp9dub1mW7C4MlvoRENH6EAENb9skEFOvIEbp1Waj38= github.com/kyokomi/emoji v1.5.1/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA= +github.com/kyokomi/emoji v2.2.4+incompatible h1:np0woGKwx9LiHAQmwZx79Oc0rHpNw3o+3evou4BEPv4= +github.com/kyokomi/emoji v2.2.4+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= diff --git a/interpreter/context/context.go b/interpreter/context/context.go index 4243a3e2..0412ca0c 100644 --- a/interpreter/context/context.go +++ b/interpreter/context/context.go @@ -129,6 +129,9 @@ type Context struct { ObjectStatus *value.Integer ObjectResponse *value.String + // For testing - store subroutine return state + ReturnState *value.String + // Regex captured values like "re.group.N" and local declared variables are volatile, // reset this when process is outgoing for each subroutines RegexMatchedValues map[string]*value.String @@ -217,6 +220,7 @@ func New(options ...Option) *Context { ObjectTTL: &value.RTime{}, ObjectStatus: &value.Integer{Value: 500}, ObjectResponse: &value.String{Value: "error"}, + ReturnState: &value.String{IsNotSet: true}, RegexMatchedValues: make(map[string]*value.String), } diff --git a/interpreter/context/scope.go b/interpreter/context/scope.go index bbec7944..38a61a1d 100644 --- a/interpreter/context/scope.go +++ b/interpreter/context/scope.go @@ -1,21 +1,51 @@ package context +import "strings" + type Scope int const ( - InitScope Scope = 0x000000000 - RecvScope Scope = 0x000000001 - HashScope Scope = 0x000000010 - HitScope Scope = 0x000000100 - MissScope Scope = 0x000001000 - PassScope Scope = 0x000010000 - FetchScope Scope = 0x000100000 - ErrorScope Scope = 0x001000000 - DeliverScope Scope = 0x010000000 - LogScope Scope = 0x100000000 - AnyScope Scope = 0x111111111 + UnknownScope Scope = 0x0000000000 + InitScope Scope = 0x0000000001 + RecvScope Scope = 0x0000000010 + HashScope Scope = 0x0000000100 + HitScope Scope = 0x0000001000 + MissScope Scope = 0x0000010000 + PassScope Scope = 0x0000100000 + FetchScope Scope = 0x0001000000 + ErrorScope Scope = 0x0010000000 + DeliverScope Scope = 0x0100000000 + LogScope Scope = 0x1000000000 + AnyScope Scope = 0x1111111111 ) +func ScopeByString(s string) Scope { + switch strings.ToUpper(s) { + case "INIT": + return InitScope + case "RECV": + return RecvScope + case "HASH": + return HashScope + case "HIT": + return HitScope + case "MISS": + return MissScope + case "PASS": + return PassScope + case "FETCH": + return FetchScope + case "ERROR": + return ErrorScope + case "DELIVER": + return DeliverScope + case "LOG": + return LogScope + default: + return UnknownScope + } +} + func (s Scope) String() string { switch s { case InitScope: diff --git a/interpreter/expression.go b/interpreter/expression.go index fffc085b..e5097b74 100644 --- a/interpreter/expression.go +++ b/interpreter/expression.go @@ -206,7 +206,7 @@ func (i *Interpreter) ProcessFunctionCallExpression(exp *ast.FunctionCallExpress // If function accepts ID type, pass the string as Ident value without processing expression. // This is because some function uses collection value like req.http.Cookie as ID type, // But the processor passes *value.String as primitive value normally. - // In order to treat collection value inside, enthruse with the function logic how value is treated as correspond types. + // In order to treat collection value inside, ensure ident argument is treated as correspond types. if ident, ok := exp.Arguments[j].(*ast.Ident); !ok { args[j] = &value.Ident{Value: ident.Value} } else { diff --git a/interpreter/function/errors/errors.go b/interpreter/function/errors/errors.go index f0524501..f83414a4 100644 --- a/interpreter/function/errors/errors.go +++ b/interpreter/function/errors/errors.go @@ -6,9 +6,10 @@ import ( "github.com/pkg/errors" "github.com/ysugimoto/falco/interpreter/value" + "github.com/ysugimoto/falco/token" ) -func New(name, format string, args ...interface{}) error { +func New(name, format string, args ...any) error { return errors.WithStack(fmt.Errorf("["+name+"] "+format, args...)) } @@ -31,3 +32,38 @@ func ArgumentNotInRange(name string, min, max int, args []value.Value) error { func TypeMismatch(name string, num int, expects, actual value.Type) error { return New(name, "Argument %d expects %s type but %s provided", num, expects, actual) } + +// Testing related errors +type TestingError struct { + // Token info will be injected on interpreter + Token token.Token + Message string +} + +func NewTestingError(format string, args ...any) *TestingError { + return &TestingError{ + Message: fmt.Sprintf(format, args...), + } +} + +func (e *TestingError) Error() string { + return e.Message +} + +type AssertionError struct { + // Token info will be injected by interpreter + Token token.Token + Actual value.Value + Message string +} + +func NewAssertionError(actual value.Value, format string, args ...any) *AssertionError { + return &AssertionError{ + Message: fmt.Sprintf(format, args...), + Actual: actual, + } +} + +func (e *AssertionError) Error() string { + return "Assertion Error: " + e.Message +} diff --git a/interpreter/function/function.go b/interpreter/function/function.go index 9e7b50e6..4e318d93 100644 --- a/interpreter/function/function.go +++ b/interpreter/function/function.go @@ -28,3 +28,15 @@ func Exists(scope context.Scope, name string) (*Function, error) { } return fn, nil } + +func Inject(fns map[string]*Function) error { + for key, fn := range fns { + if _, ok := builtinFunctions[key]; ok { + return errors.WithStack( + fmt.Errorf("Function %s already defiend and could not override", key), + ) + } + builtinFunctions[key] = fn + } + return nil +} diff --git a/interpreter/handler.go b/interpreter/handler.go index df27900c..3e502f6c 100644 --- a/interpreter/handler.go +++ b/interpreter/handler.go @@ -5,66 +5,17 @@ import ( "fmt" "net/http" "os" - "time" "github.com/pkg/errors" - "github.com/ysugimoto/falco/interpreter/context" "github.com/ysugimoto/falco/interpreter/exception" - "github.com/ysugimoto/falco/interpreter/process" - "github.com/ysugimoto/falco/lexer" - "github.com/ysugimoto/falco/parser" ) // Implements http.Handler func (i *Interpreter) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx := context.New(i.options...) - - main, err := ctx.Resolver.MainVCL() - if err != nil { - i.Debugger.Message(err.Error()) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if err := checkFastlyVCLLimitation(main.Data); err != nil { - i.Debugger.Message(err.Error()) + if err := i.ProcessInit(r); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - vcl, err := parser.New( - lexer.NewFromString(main.Data, lexer.WithFile(main.Name)), - ).ParseVCL() - if err != nil { - // parse error - i.Debugger.Message(err.Error()) - http.Error(w, fmt.Sprintf("%+v", err), http.StatusInternalServerError) - return - } - - // If remote snippets exists, prepare parse and prepend to main VCL - if ctx.FastlySnippets != nil { - for _, snip := range ctx.FastlySnippets.EmbedSnippets() { - s, err := parser.New( - lexer.NewFromString(snip.Data, lexer.WithFile(snip.Name)), - ).ParseVCL() - if err != nil { - // parse error - i.Debugger.Message(err.Error()) - http.Error(w, fmt.Sprintf("%+v", err), http.StatusInternalServerError) - return - } - vcl.Statements = append(s.Statements, vcl.Statements...) - } - } - - // If override request configration exists, set them - if ctx.OverrideRequest != nil { - ctx.OverrideRequest.SetRequest(r) - } - - ctx.RequestStartTime = time.Now() - i.ctx = ctx - i.ctx.Request = r - i.process = process.New() handleError := func(err error) { // If debug is true, print with stacktrace @@ -79,9 +30,7 @@ func (i *Interpreter) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - if err := i.ProcessInit(vcl.Statements); err != nil { - handleError(err) - } else if err := i.ProcessRecv(); err != nil { + if err := i.ProcessRecv(); err != nil { handleError(err) } else if err := checkFastlyResponseLimit(i.ctx.Response); err != nil { handleError(err) diff --git a/interpreter/interpreter.go b/interpreter/interpreter.go index 13eadd2f..ef94dfe5 100644 --- a/interpreter/interpreter.go +++ b/interpreter/interpreter.go @@ -15,6 +15,8 @@ import ( "github.com/ysugimoto/falco/interpreter/process" "github.com/ysugimoto/falco/interpreter/value" "github.com/ysugimoto/falco/interpreter/variable" + "github.com/ysugimoto/falco/lexer" + "github.com/ysugimoto/falco/parser" ) type Interpreter struct { @@ -36,6 +38,28 @@ func New(options ...context.Option) *Interpreter { } } +func (i *Interpreter) SetScope(scope context.Scope) { + i.ctx.Scope = context.RecvScope + switch scope { + case context.RecvScope: + i.vars = variable.NewRecvScopeVariables(i.ctx) + case context.HashScope: + i.vars = variable.NewHashScopeVariables(i.ctx) + case context.MissScope: + i.vars = variable.NewMissScopeVariables(i.ctx) + case context.PassScope: + i.vars = variable.NewPassScopeVariables(i.ctx) + case context.FetchScope: + i.vars = variable.NewFetchScopeVariables(i.ctx) + case context.DeliverScope: + i.vars = variable.NewDeliverScopeVariables(i.ctx) + case context.ErrorScope: + i.vars = variable.NewErrorScopeVariables(i.ctx) + case context.LogScope: + i.vars = variable.NewLogScopeVariables(i.ctx) + } +} + func (i *Interpreter) restart() error { i.ctx.Restarts++ i.Debugger.Message("Restarted.") @@ -46,11 +70,49 @@ func (i *Interpreter) restart() error { return nil } -func (i *Interpreter) ProcessInit(vcl []ast.Statement) error { +func (i *Interpreter) ProcessInit(r *http.Request) error { + ctx := context.New(i.options...) + + main, err := ctx.Resolver.MainVCL() + if err != nil { + i.Debugger.Message(err.Error()) + return err + } + if err := checkFastlyVCLLimitation(main.Data); err != nil { + i.Debugger.Message(err.Error()) + return err + } + vcl, err := parser.New( + lexer.NewFromString(main.Data, lexer.WithFile(main.Name)), + ).ParseVCL() + if err != nil { + // parse error + i.Debugger.Message(err.Error()) + return err + } + + // If remote snippets exists, prepare parse and prepend to main VCL + if ctx.FastlySnippets != nil { + for _, snip := range ctx.FastlySnippets.EmbedSnippets() { + s, err := parser.New( + lexer.NewFromString(snip.Data, lexer.WithFile(snip.Name)), + ).ParseVCL() + if err != nil { + // parse error + i.Debugger.Message(err.Error()) + return err + } + vcl.Statements = append(s.Statements, vcl.Statements...) + } + } + ctx.RequestStartTime = time.Now() + i.ctx = ctx + i.ctx.Request = r + i.process = process.New() i.ctx.Scope = context.InitScope i.vars = variable.NewAllScopeVariables(i.ctx) - statements, err := i.resolveIncludeStatement(vcl, true) + statements, err := i.resolveIncludeStatement(vcl.Statements, true) if err != nil { return err } @@ -139,8 +201,7 @@ func (i *Interpreter) ProcessDeclarations(statements []ast.Statement) error { } func (i *Interpreter) ProcessRecv() error { - i.ctx.Scope = context.RecvScope - i.vars = variable.NewRecvScopeVariables(i.ctx) + i.SetScope(context.RecvScope) // Simulate Fastly statement lifecycle // see: https://developer.fastly.com/learning/vcl/using/#the-vcl-request-lifecycle @@ -202,8 +263,7 @@ func (i *Interpreter) ProcessRecv() error { } func (i *Interpreter) ProcessHash() error { - i.ctx.Scope = context.HashScope - i.vars = variable.NewHashScopeVariables(i.ctx) + i.SetScope(context.HashScope) // Make default VCL hash string // https://developer.fastly.com/reference/vcl/subroutines/hash/ @@ -229,8 +289,7 @@ func (i *Interpreter) ProcessHash() error { } func (i *Interpreter) ProcessMiss() error { - i.ctx.Scope = context.MissScope - i.vars = variable.NewMissScopeVariables(i.ctx) + i.SetScope(context.MissScope) if i.ctx.Backend == nil { return exception.Runtime(nil, "No backend determined in MISS") @@ -290,8 +349,7 @@ func (i *Interpreter) ProcessMiss() error { } func (i *Interpreter) ProcessHit() error { - i.ctx.Scope = context.HitScope - i.vars = variable.NewHitScopeVariables(i.ctx) + i.SetScope(context.HitScope) // Simulate Fastly statement lifecycle // see: https://developer.fastly.com/learning/vcl/using/#the-vcl-request-lifecycle @@ -336,8 +394,7 @@ func (i *Interpreter) ProcessHit() error { } func (i *Interpreter) ProcessPass() error { - i.ctx.Scope = context.PassScope - i.vars = variable.NewPassScopeVariables(i.ctx) + i.SetScope(context.PassScope) if i.ctx.Backend == nil { return exception.Runtime(nil, "No backend determined in PASS") @@ -392,8 +449,7 @@ func (i *Interpreter) ProcessPass() error { } func (i *Interpreter) ProcessFetch() error { - i.ctx.Scope = context.FetchScope - i.vars = variable.NewFetchScopeVariables(i.ctx) + i.SetScope(context.FetchScope) if i.ctx.BackendRequest == nil { return exception.System("No backend determined on FETCH") @@ -472,8 +528,7 @@ func (i *Interpreter) ProcessFetch() error { } func (i *Interpreter) ProcessError() error { - i.ctx.Scope = context.ErrorScope - i.vars = variable.NewErrorScopeVariables(i.ctx) + i.SetScope(context.ErrorScope) if i.ctx.Object == nil { if i.ctx.BackendResponse != nil { @@ -542,8 +597,7 @@ func (i *Interpreter) ProcessError() error { } func (i *Interpreter) ProcessDeliver() error { - i.ctx.Scope = context.DeliverScope - i.vars = variable.NewDeliverScopeVariables(i.ctx) + i.SetScope(context.DeliverScope) if i.ctx.Response == nil { if i.ctx.BackendResponse != nil { @@ -597,8 +651,7 @@ func (i *Interpreter) ProcessDeliver() error { } func (i *Interpreter) ProcessLog() error { - i.ctx.Scope = context.LogScope - i.vars = variable.NewLogScopeVariables(i.ctx) + i.SetScope(context.LogScope) if i.ctx.Response == nil { if i.ctx.BackendResponse != nil { diff --git a/interpreter/statement.go b/interpreter/statement.go index 6f38cc1d..4ffa619f 100644 --- a/interpreter/statement.go +++ b/interpreter/statement.go @@ -10,6 +10,7 @@ import ( "github.com/ysugimoto/falco/interpreter/context" "github.com/ysugimoto/falco/interpreter/exception" "github.com/ysugimoto/falco/interpreter/function" + fe "github.com/ysugimoto/falco/interpreter/function/errors" "github.com/ysugimoto/falco/interpreter/process" "github.com/ysugimoto/falco/interpreter/value" ) @@ -67,6 +68,7 @@ func (i *Interpreter) ProcessBlockStatement(statements []ast.Statement, ds Debug if state != NONE { return state, DebugPass, nil } + case *ast.CallStatement: var state State // Enable breakpoint if current debug state is step-in @@ -78,6 +80,7 @@ func (i *Interpreter) ProcessBlockStatement(statements []ast.Statement, ds Debug if state != NONE { return state, DebugPass, nil } + case *ast.IfStatement: var state State var debug DebugState @@ -86,6 +89,7 @@ func (i *Interpreter) ProcessBlockStatement(statements []ast.Statement, ds Debug return state, debug, nil } debugState = debug + case *ast.RestartStatement: if !i.ctx.Scope.Is(context.RecvScope, context.HitScope, context.FetchScope, context.ErrorScope, context.DeliverScope) { return NONE, DebugPass, exception.Runtime( @@ -105,9 +109,12 @@ func (i *Interpreter) ProcessBlockStatement(statements []ast.Statement, ds Debug // restart statement force change state to RESTART return RESTART, DebugPass, nil + case *ast.ReturnStatement: // When return statement is processed, return its state immediately - return i.ProcessReturnStatement(t), DebugPass, nil + state := i.ProcessReturnStatement(t) + return state, DebugPass, nil + case *ast.ErrorStatement: if !i.ctx.Scope.Is(context.RecvScope, context.HitScope, context.MissScope, context.PassScope, context.FetchScope) { return NONE, DebugPass, exception.Runtime( @@ -129,7 +136,7 @@ func (i *Interpreter) ProcessBlockStatement(statements []ast.Statement, ds Debug } } if err != nil { - return INTERNAL_ERROR, DebugPass, exception.Runtime(&stmt.GetMeta().Token, "Unexpected error: %s", err.Error()) + return INTERNAL_ERROR, DebugPass, errors.WithStack(err) } } return NONE, DebugPass, nil @@ -377,7 +384,17 @@ func (i *Interpreter) ProcessFunctionCallStatement(stmt *ast.FunctionCallStateme } } if _, err := fn.Call(i.ctx, args...); err != nil { - return NONE, exception.Runtime(&stmt.GetMeta().Token, err.Error()) + // Testing related error should pass as it is + switch t := err.(type) { + case *fe.AssertionError: + t.Token = stmt.GetMeta().Token + return NONE, errors.WithStack(t) + case *fe.TestingError: + t.Token = stmt.GetMeta().Token + return NONE, errors.WithStack(t) + default: + return NONE, exception.Runtime(&stmt.GetMeta().Token, err.Error()) + } } return NONE, nil } diff --git a/interpreter/subroutine.go b/interpreter/subroutine.go index 3786f42c..1ba31090 100644 --- a/interpreter/subroutine.go +++ b/interpreter/subroutine.go @@ -5,13 +5,21 @@ import ( "github.com/pkg/errors" "github.com/ysugimoto/falco/ast" - icontext "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/context" "github.com/ysugimoto/falco/interpreter/exception" "github.com/ysugimoto/falco/interpreter/process" "github.com/ysugimoto/falco/interpreter/value" "github.com/ysugimoto/falco/interpreter/variable" ) +func (i *Interpreter) ProcessTestSubroutine(scope context.Scope, sub *ast.SubroutineDeclaration) error { + i.SetScope(scope) + if _, err := i.ProcessSubroutine(sub, DebugPass); err != nil { + return errors.WithStack(err) + } + return nil +} + func (i *Interpreter) ProcessSubroutine(sub *ast.SubroutineDeclaration, ds DebugState) (State, error) { i.process.Flows = append(i.process.Flows, process.NewFlow(i.ctx, sub)) // reset all local values and regex capture values @@ -191,7 +199,7 @@ func (i *Interpreter) extractBoilerplateMacro(sub *ast.SubroutineDeclaration) er } // If subroutine name is fastly subroutine, find and extract boilerplate macro - macro, ok := icontext.FastlyReservedSubroutine[sub.Name.Value] + macro, ok := context.FastlyReservedSubroutine[sub.Name.Value] if !ok { return nil } diff --git a/interpreter/testing.go b/interpreter/testing.go new file mode 100644 index 00000000..09cffcb4 --- /dev/null +++ b/interpreter/testing.go @@ -0,0 +1,42 @@ +package interpreter + +import ( + "context" + "io" + "net/http" + "strings" + + "github.com/pkg/errors" +) + +const testBackendResponseBody = "falco_test_response" + +func (i *Interpreter) TestProcessInit(r *http.Request) error { + var err error + if err = i.ProcessInit(r); err != nil { + return errors.WithStack(err) + } + + // On testing process, all request/response variables should be set initially + i.ctx.BackendRequest, err = i.createBackendRequest(i.ctx.Backend) + if err != nil { + return errors.WithStack(err) + } + i.ctx.BackendResponse = &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{}, + Body: io.NopCloser(strings.NewReader(testBackendResponseBody)), + ContentLength: int64(len(testBackendResponseBody)), + Close: true, + Uncompressed: false, + Trailer: http.Header{}, + Request: i.ctx.BackendRequest.Clone(context.Background()), + } + i.ctx.Response = i.cloneResponse(i.ctx.BackendResponse) + i.ctx.Object = i.cloneResponse(i.ctx.BackendResponse) + return nil +} diff --git a/interpreter/value/value.go b/interpreter/value/value.go index 04d94af3..1d757171 100644 --- a/interpreter/value/value.go +++ b/interpreter/value/value.go @@ -17,13 +17,13 @@ type Type string const ( NullType Type = "NULL" IdentType Type = "IDENT" - StringType Type = "STRING" - IpType Type = "IP" - BooleanType Type = "BOOLEAN" IntegerType Type = "INTEGER" FloatType Type = "FLOAT" + StringType Type = "STRING" + BooleanType Type = "BOOLEAN" RTimeType Type = "RTIME" TimeType Type = "TIME" + IpType Type = "IP" BackendType Type = "BACKEND" AclType Type = "ACL" ) diff --git a/interpreter/variable/all.go b/interpreter/variable/all.go index 6b13d7a1..f2c900ac 100644 --- a/interpreter/variable/all.go +++ b/interpreter/variable/all.go @@ -566,6 +566,12 @@ func (v *AllScopeVariables) Get(s context.Scope, name string) (value.Value, erro return val, nil } + if injectedVariable != nil { + if val, err := injectedVariable.Get(v.ctx, s, name); err == nil { + return val, nil + } + } + return value.Null, errors.WithStack(fmt.Errorf( "Undefined variable %s", name, )) @@ -709,6 +715,12 @@ func (v *AllScopeVariables) Set(s context.Scope, name, operator string, val valu return nil } + if injectedVariable != nil { + if err := injectedVariable.Set(v.ctx, s, name, operator, val); err == nil { + return nil + } + } + return errors.WithStack(fmt.Errorf( "Variable %s is not found or could not set in scope: %s", name, s.String(), )) diff --git a/interpreter/variable/inject.go b/interpreter/variable/inject.go new file mode 100644 index 00000000..f98e67a5 --- /dev/null +++ b/interpreter/variable/inject.go @@ -0,0 +1,21 @@ +package variable + +import ( + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/value" +) + +type InjectVariable interface { + Get(*context.Context, context.Scope, string) (value.Value, error) + Set(*context.Context, context.Scope, string, string, value.Value) error + + // Currently no need + // Add(*context.Context, context.Scope, string, value.Value) error + // Unset(*context.Context, context.Scope, string) error +} + +var injectedVariable InjectVariable + +func Inject(v InjectVariable) { + injectedVariable = v +} diff --git a/lexer/lexer.go b/lexer/lexer.go index 6a7f74e4..49c0f953 100644 --- a/lexer/lexer.go +++ b/lexer/lexer.go @@ -18,6 +18,7 @@ type Lexer struct { stack []string file string peeks []token.Token + isEOF bool } func New(r io.Reader, opts ...OptionFunc) *Lexer { @@ -280,6 +281,10 @@ func (l *Lexer) NextToken() token.Token { t.Type = token.EOF t.Line = line t.Position = index + if !l.isEOF { + l.NewLine() + l.isEOF = true + } case 0x0A: // '\n' t = newToken(token.LF, l.char, line, index) default: diff --git a/tester/entity.go b/tester/entity.go new file mode 100644 index 00000000..ab0290c0 --- /dev/null +++ b/tester/entity.go @@ -0,0 +1,51 @@ +package tester + +import "github.com/ysugimoto/falco/lexer" + +type TestCase struct { + Name string + Error error + Scope string + Time int // msec order +} + +type TestResult struct { + Filename string + Cases []*TestCase + Lexer *lexer.Lexer +} + +func (t *TestResult) IsPassed() bool { + for i := range t.Cases { + if t.Cases[i].Error != nil { + return false + } + } + return true +} + +type TestCounter struct { + Asserts int + Passes int + Fails []error +} + +func NewTestCounter() *TestCounter { + return &TestCounter{} +} + +func (c *TestCounter) Pass() { + c.Asserts++ + c.Passes++ +} + +func (c *TestCounter) Fail(err error) { + c.Asserts++ + c.Fails = append(c.Fails, err) +} + +func (c *TestCounter) Reset() { + c.Asserts = 0 + c.Passes = 0 + c.Fails = []error{} +} diff --git a/tester/finder.go b/tester/finder.go new file mode 100644 index 00000000..ec80c533 --- /dev/null +++ b/tester/finder.go @@ -0,0 +1,40 @@ +package tester + +import ( + "io/fs" + "path/filepath" + "strings" + + "github.com/pkg/errors" +) + +type finder struct { + files []string +} + +// fs.WalkDirFunc implementation +func (f *finder) find(path string, entry fs.DirEntry, err error) error { + if err != nil { + if _, ok := err.(*fs.PathError); ok { + return nil + } + return err + } + if !strings.HasSuffix(path, ".test.vcl") { + return nil + } + f.files = append(f.files, path) + return nil +} + +func findTestTargetFiles(root string) ([]string, error) { + abs, err := filepath.Abs(root) + if err != nil { + return nil, errors.WithStack(err) + } + f := &finder{} + if err := filepath.WalkDir(abs, f.find); err != nil { + return nil, errors.WithStack(err) + } + return f.files, nil +} diff --git a/tester/function/assert.go b/tester/function/assert.go new file mode 100644 index 00000000..f77bc47a --- /dev/null +++ b/tester/function/assert.go @@ -0,0 +1,32 @@ +package function + +import ( + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Assert_lookup_Name = "assert" + +var Assert_lookup_ArgumentTypes = []value.Type{value.BooleanType} + +func Assert_lookup_Validate(args []value.Value) error { + if len(args) != 1 { + return errors.ArgumentNotEnough(Assert_lookup_Name, 1, args) + } + for i := range args { + if args[i].Type() != Assert_lookup_ArgumentTypes[i] { + return errors.TypeMismatch(Assert_lookup_Name, i+1, Assert_lookup_ArgumentTypes[i], args[i].Type()) + } + } + return nil +} + +func Assert(ctx *context.Context, args ...value.Value) (value.Value, error) { + if err := Assert_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + v := value.Unwrap[*value.Boolean](args[0]) + return assert(v, v.Value, true, "") +} diff --git a/tester/function/assert_contains.go b/tester/function/assert_contains.go new file mode 100644 index 00000000..d5c6881a --- /dev/null +++ b/tester/function/assert_contains.go @@ -0,0 +1,61 @@ +package function + +import ( + "strings" + + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Assert_contains_lookup_Name = "assert" + +var Assert_contains_lookup_ArgumentTypes = []value.Type{value.StringType, value.StringType} + +func Assert_contains_lookup_Validate(args []value.Value) error { + if len(args) < 2 || len(args) > 3 { + return errors.ArgumentNotInRange(Assert_contains_lookup_Name, 2, 3, args) + } + + for i := range Assert_contains_lookup_ArgumentTypes { + if args[i].Type() != Assert_contains_lookup_ArgumentTypes[i] { + return errors.TypeMismatch(Assert_contains_lookup_Name, i+1, Assert_contains_lookup_ArgumentTypes[i], args[i].Type()) + } + } + + if len(args) == 3 { + if args[2].Type() != value.StringType { + return errors.TypeMismatch(Assert_contains_lookup_Name, 3, value.StringType, args[2].Type()) + } + } + return nil +} + +func Assert_contains(ctx *context.Context, args ...value.Value) (value.Value, error) { + if err := Assert_contains_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + // Check custom message + var message string + if len(args) == 3 { + message = value.Unwrap[*value.String](args[2]).Value + } + + actual := value.Unwrap[*value.String](args[0]) + expect := value.Unwrap[*value.String](args[1]) + + ret := &value.Boolean{Value: strings.Contains(actual.Value, expect.Value)} + if !ret.Value { + if message != "" { + return ret, errors.NewAssertionError(actual, message) + } + return ret, errors.NewAssertionError( + actual, + `"%s" should be contained in "%s"`, + actual.Value, + expect.Value, + ) + } + return ret, nil +} diff --git a/tester/function/assert_contains_test.go b/tester/function/assert_contains_test.go new file mode 100644 index 00000000..bd960a53 --- /dev/null +++ b/tester/function/assert_contains_test.go @@ -0,0 +1,70 @@ +package function + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +func Test_Assert_contains(t *testing.T) { + + tests := []struct { + args []value.Value + err error + expect *value.Boolean + }{ + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: "bar"}, + }, + expect: &value.Boolean{Value: true}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: "bat"}, + }, + expect: &value.Boolean{Value: false}, + err: &errors.AssertionError{}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.Integer{Value: 0}, + }, + expect: nil, + err: &errors.TestingError{}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: "bat"}, + &value.String{Value: "custom_message"}, + }, + expect: nil, + err: &errors.AssertionError{ + Message: "custom_message", + }, + }, + } + + for i := range tests { + _, err := Assert_contains( + &context.Context{}, + tests[i].args..., + ) + if diff := cmp.Diff( + tests[i].err, + err, + cmpopts.IgnoreFields(errors.AssertionError{}, "Message", "Actual"), + cmpopts.IgnoreFields(errors.TestingError{}, "Message"), + ); diff != "" { + t.Errorf("Assert_contains()[%d] error: diff=%s", i, diff) + } + } +} diff --git a/tester/function/assert_ends_with.go b/tester/function/assert_ends_with.go new file mode 100644 index 00000000..a58ebfc6 --- /dev/null +++ b/tester/function/assert_ends_with.go @@ -0,0 +1,61 @@ +package function + +import ( + "strings" + + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Assert_ends_with_lookup_Name = "assert" + +var Assert_ends_with_lookup_ArgumentTypes = []value.Type{value.StringType, value.StringType} + +func Assert_ends_with_lookup_Validate(args []value.Value) error { + if len(args) < 2 || len(args) > 3 { + return errors.ArgumentNotInRange(Assert_ends_with_lookup_Name, 2, 3, args) + } + + for i := range Assert_ends_with_lookup_ArgumentTypes { + if args[i].Type() != Assert_ends_with_lookup_ArgumentTypes[i] { + return errors.TypeMismatch(Assert_ends_with_lookup_Name, i+1, Assert_ends_with_lookup_ArgumentTypes[i], args[i].Type()) + } + } + + if len(args) == 3 { + if args[2].Type() != value.StringType { + return errors.TypeMismatch(Assert_ends_with_lookup_Name, 3, value.StringType, args[2].Type()) + } + } + return nil +} + +func Assert_ends_with(ctx *context.Context, args ...value.Value) (value.Value, error) { + if err := Assert_ends_with_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + // Check custom message + var message string + if len(args) == 3 { + message = value.Unwrap[*value.String](args[2]).Value + } + + actual := value.Unwrap[*value.String](args[0]) + expect := value.Unwrap[*value.String](args[1]) + + ret := &value.Boolean{Value: strings.HasSuffix(actual.Value, expect.Value)} + if !ret.Value { + if message != "" { + return ret, errors.NewAssertionError(actual, message) + } + return ret, errors.NewAssertionError( + actual, + `"%s" should end with string "%s"`, + actual.Value, + expect.Value, + ) + } + return ret, nil +} diff --git a/tester/function/assert_ends_with_test.go b/tester/function/assert_ends_with_test.go new file mode 100644 index 00000000..204c9474 --- /dev/null +++ b/tester/function/assert_ends_with_test.go @@ -0,0 +1,70 @@ +package function + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +func Test_Assert_ends_with(t *testing.T) { + + tests := []struct { + args []value.Value + err error + expect *value.Boolean + }{ + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: "baz"}, + }, + expect: &value.Boolean{Value: true}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: "bat"}, + }, + expect: &value.Boolean{Value: false}, + err: &errors.AssertionError{}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.Integer{Value: 0}, + }, + expect: nil, + err: &errors.TestingError{}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: "bat"}, + &value.String{Value: "custom_message"}, + }, + expect: nil, + err: &errors.AssertionError{ + Message: "custom_message", + }, + }, + } + + for i := range tests { + _, err := Assert_ends_with( + &context.Context{}, + tests[i].args..., + ) + if diff := cmp.Diff( + tests[i].err, + err, + cmpopts.IgnoreFields(errors.AssertionError{}, "Message", "Actual"), + cmpopts.IgnoreFields(errors.TestingError{}, "Message"), + ); diff != "" { + t.Errorf("Assert_ends_with()[%d] error: diff=%s", i, diff) + } + } +} diff --git a/tester/function/assert_equal.go b/tester/function/assert_equal.go new file mode 100644 index 00000000..ddee15bb --- /dev/null +++ b/tester/function/assert_equal.go @@ -0,0 +1,30 @@ +package function + +import ( + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Assert_equal_lookup_Name = "assert" + +func Assert_equal_lookup_Validate(args []value.Value) error { + if len(args) < 2 || len(args) > 3 { + return errors.ArgumentNotInRange(Assert_equal_lookup_Name, 2, 3, args) + } + if len(args) == 3 { + if args[2].Type() != value.StringType { + return errors.TypeMismatch(Assert_equal_lookup_Name, 3, value.StringType, args[2].Type()) + } + } + return nil +} + +func Assert_equal(ctx *context.Context, args ...value.Value) (value.Value, error) { + if err := Assert_equal_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + // assert.equal is alias for assert.strict_equal + return assert_strict_equal(args...) +} diff --git a/tester/function/assert_equal_test.go b/tester/function/assert_equal_test.go new file mode 100644 index 00000000..ed4a48c1 --- /dev/null +++ b/tester/function/assert_equal_test.go @@ -0,0 +1,394 @@ +package function + +import ( + "net" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ysugimoto/falco/ast" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +type testSuite struct { + compare value.Value + err error + expect bool +} + +func assert_test(t *testing.T, v value.Value, suite testSuite, name string) { + ret, err := Assert_equal(&context.Context{}, v, suite.compare) + if diff := cmp.Diff( + suite.err, + err, + cmpopts.IgnoreFields(errors.AssertionError{}, "Message", "Actual"), + cmpopts.IgnoreFields(errors.TestingError{}, "Message"), + ); diff != "" { + t.Errorf("Assert_equal()[%s] error: diff=%s", name, diff) + } + if diff := cmp.Diff(ret, &value.Boolean{Value: suite.expect}); diff != "" { + t.Errorf("Assert_equal()[%s] return value mismatch: diff=%s", name, diff) + } +} + +func Test_Assert_equal(t *testing.T) { + + now := time.Now() + assertionError := &errors.AssertionError{} + + t.Run("NULL", func(t *testing.T) { + v := value.Null + tests := []testSuite{ + {compare: value.Null, expect: true}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_test(t, v, tests[i], "Actual is NULL") + } + }) + + t.Run("INTEGER", func(t *testing.T) { + v := &value.Integer{Value: 1} + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Integer{Value: 1}, expect: true}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_test(t, v, tests[i], "Actual is INTEGER") + } + }) + + t.Run("FLOAT", func(t *testing.T) { + v := &value.Float{Value: 1} + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 1}, expect: true}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_test(t, v, tests[i], "Actual is FLOAT") + } + }) + + t.Run("STRING", func(t *testing.T) { + v := &value.String{Value: "test"} + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.String{Value: "test"}, expect: true}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_test(t, v, tests[i], "Actual is STRING") + } + }) + + t.Run("BOOLEAN", func(t *testing.T) { + v := &value.Boolean{Value: true} + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Boolean{Value: true}, expect: true}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_test(t, v, tests[i], "Actual is BOOLEAN") + } + }) + + t.Run("RTIME", func(t *testing.T) { + v := &value.RTime{Value: time.Second} + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.RTime{Value: time.Second}, expect: true}, + {compare: &value.Time{Value: now}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_test(t, v, tests[i], "Actual is RTIME") + } + }) + + t.Run("TIME", func(t *testing.T) { + v := &value.Time{Value: now} + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now}, expect: true}, + {compare: &value.Time{Value: now.Add(time.Second)}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_test(t, v, tests[i], "Actual is TIME") + } + }) + + t.Run("IP", func(t *testing.T) { + v := &value.IP{Value: net.ParseIP("192.168.0.1")} + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("192.168.0.1")}, expect: true}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now.Add(time.Second)}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_test(t, v, tests[i], "Actual is IP") + } + }) + + t.Run("BACKEND", func(t *testing.T) { + v := &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "test", + }, + }, + } + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now.Add(time.Second)}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "test", + }, + }, + }, expect: true}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_test(t, v, tests[i], "Actual is BACKEND") + } + }) + + t.Run("ACL", func(t *testing.T) { + v := &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "test", + }, + }, + } + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now.Add(time.Second)}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "test", + }, + }, + }, expect: true}, + } + + for i := range tests { + assert_test(t, v, tests[i], "Actual is ACL") + } + }) +} diff --git a/tester/function/assert_false.go b/tester/function/assert_false.go new file mode 100644 index 00000000..cdd3f24e --- /dev/null +++ b/tester/function/assert_false.go @@ -0,0 +1,33 @@ +package function + +import ( + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Assert_false_lookup_Name = "assert" + +func Assert_false_lookup_Validate(args []value.Value) error { + if len(args) != 1 { + return errors.ArgumentNotEnough(Assert_false_lookup_Name, 1, args) + } + return nil +} + +func Assert_false(ctx *context.Context, args ...value.Value) (value.Value, error) { + if err := Assert_false_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + switch args[0].Type() { + case value.BooleanType: + v := value.Unwrap[*value.Boolean](args[0]) + return assert(v, v.Value, false, "Value should be false") + default: + return &value.Boolean{}, errors.NewTestingError( + "Assertion type mismatch, %s type is not BOOLEAN type", + args[0].Type(), + ) + } +} diff --git a/tester/function/assert_false_test.go b/tester/function/assert_false_test.go new file mode 100644 index 00000000..c69fa393 --- /dev/null +++ b/tester/function/assert_false_test.go @@ -0,0 +1,56 @@ +package function + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +func Test_Assert_false(t *testing.T) { + + tests := []struct { + args []value.Value + err error + expect *value.Boolean + }{ + { + args: []value.Value{ + value.Null, + }, + expect: &value.Boolean{Value: false}, + err: &errors.TestingError{}, + }, + { + args: []value.Value{ + &value.Boolean{Value: false}, + }, + expect: &value.Boolean{Value: true}, + }, + { + args: []value.Value{ + &value.Boolean{Value: true}, + }, + err: &errors.AssertionError{}, + expect: &value.Boolean{Value: false}, + }, + } + + for i := range tests { + _, err := Assert_false( + &context.Context{}, + tests[i].args..., + ) + if diff := cmp.Diff( + tests[i].err, + err, + cmpopts.IgnoreFields(errors.AssertionError{}, "Message", "Actual"), + cmpopts.IgnoreFields(errors.TestingError{}, "Message"), + ); diff != "" { + t.Errorf("Assert_false()[%d] error: diff=%s", i, diff) + } + } +} diff --git a/tester/function/assert_match.go b/tester/function/assert_match.go new file mode 100644 index 00000000..a84481b4 --- /dev/null +++ b/tester/function/assert_match.go @@ -0,0 +1,68 @@ +package function + +import ( + "regexp" + + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Assert_match_lookup_Name = "assert" + +var Assert_match_lookup_ArgumentTypes = []value.Type{value.StringType, value.StringType} + +func Assert_match_lookup_Validate(args []value.Value) error { + if len(args) < 2 || len(args) > 3 { + return errors.ArgumentNotInRange(Assert_match_lookup_Name, 2, 3, args) + } + + for i := range Assert_match_lookup_ArgumentTypes { + if args[i].Type() != Assert_match_lookup_ArgumentTypes[i] { + return errors.TypeMismatch(Assert_match_lookup_Name, i+1, Assert_match_lookup_ArgumentTypes[i], args[i].Type()) + } + } + + if len(args) == 3 { + if args[2].Type() != value.StringType { + return errors.TypeMismatch(Assert_match_lookup_Name, 3, value.StringType, args[2].Type()) + } + } + return nil +} + +func Assert_match(ctx *context.Context, args ...value.Value) (value.Value, error) { + if err := Assert_match_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + // Check custom message + var message string + if len(args) == 3 { + message = value.Unwrap[*value.String](args[2]).Value + } + + actual := value.Unwrap[*value.String](args[0]) + expect := value.Unwrap[*value.String](args[1]) + + re, err := regexp.Compile(expect.Value) + if err != nil { + return nil, errors.NewTestingError( + "Invalid regexp string provided: %s", + expect.Value, + ) + } + ret := &value.Boolean{Value: re.MatchString(actual.Value)} + if !ret.Value { + if message != "" { + return ret, errors.NewAssertionError(actual, message) + } + return ret, errors.NewAssertionError( + actual, + `"%s" should match against %s`, + actual.Value, + expect.Value, + ) + } + return ret, nil +} diff --git a/tester/function/assert_match_test.go b/tester/function/assert_match_test.go new file mode 100644 index 00000000..6460a602 --- /dev/null +++ b/tester/function/assert_match_test.go @@ -0,0 +1,78 @@ +package function + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +func Test_Assert_match(t *testing.T) { + + tests := []struct { + args []value.Value + err error + expect *value.Boolean + }{ + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: ".*bar.*"}, + }, + expect: &value.Boolean{Value: true}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: ".*bat.*"}, + }, + expect: &value.Boolean{Value: false}, + err: &errors.AssertionError{}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.Integer{Value: 0}, + }, + expect: nil, + err: &errors.TestingError{}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: ".*bat.*"}, + &value.String{Value: "custom_message"}, + }, + expect: nil, + err: &errors.AssertionError{ + Message: "custom_message", + }, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: "^++a"}, + }, + expect: nil, + err: &errors.TestingError{}, + }, + } + + for i := range tests { + _, err := Assert_match( + &context.Context{}, + tests[i].args..., + ) + if diff := cmp.Diff( + tests[i].err, + err, + cmpopts.IgnoreFields(errors.AssertionError{}, "Message", "Actual"), + cmpopts.IgnoreFields(errors.TestingError{}, "Message"), + ); diff != "" { + t.Errorf("Assert_match()[%d] error: diff=%s", i, diff) + } + } +} diff --git a/tester/function/assert_not_contains.go b/tester/function/assert_not_contains.go new file mode 100644 index 00000000..711eaf0a --- /dev/null +++ b/tester/function/assert_not_contains.go @@ -0,0 +1,61 @@ +package function + +import ( + "strings" + + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Assert_not_contains_lookup_Name = "assert" + +var Assert_not_contains_lookup_ArgumentTypes = []value.Type{value.StringType, value.StringType} + +func Assert_not_contains_lookup_Validate(args []value.Value) error { + if len(args) < 2 || len(args) > 3 { + return errors.ArgumentNotInRange(Assert_not_contains_lookup_Name, 2, 3, args) + } + + for i := range Assert_not_contains_lookup_ArgumentTypes { + if args[i].Type() != Assert_not_contains_lookup_ArgumentTypes[i] { + return errors.TypeMismatch(Assert_not_contains_lookup_Name, i+1, Assert_not_contains_lookup_ArgumentTypes[i], args[i].Type()) + } + } + + if len(args) == 3 { + if args[2].Type() != value.StringType { + return errors.TypeMismatch(Assert_not_contains_lookup_Name, 3, value.StringType, args[2].Type()) + } + } + return nil +} + +func Assert_not_contains(ctx *context.Context, args ...value.Value) (value.Value, error) { + if err := Assert_not_contains_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + // Check custom message + var message string + if len(args) == 3 { + message = value.Unwrap[*value.String](args[2]).Value + } + + actual := value.Unwrap[*value.String](args[0]) + expect := value.Unwrap[*value.String](args[1]) + + ret := &value.Boolean{Value: !strings.Contains(actual.Value, expect.Value)} + if !ret.Value { + if message != "" { + return ret, errors.NewAssertionError(actual, message) + } + return ret, errors.NewAssertionError( + actual, + `"%s" should not be contained in "%s"`, + actual.Value, + expect.Value, + ) + } + return ret, nil +} diff --git a/tester/function/assert_not_contains_test.go b/tester/function/assert_not_contains_test.go new file mode 100644 index 00000000..f0e6b313 --- /dev/null +++ b/tester/function/assert_not_contains_test.go @@ -0,0 +1,70 @@ +package function + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +func Test_Assert_not_contains(t *testing.T) { + + tests := []struct { + args []value.Value + err error + expect *value.Boolean + }{ + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: "bat"}, + }, + expect: &value.Boolean{Value: true}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: "bar"}, + }, + expect: &value.Boolean{Value: false}, + err: &errors.AssertionError{}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.Integer{Value: 0}, + }, + expect: nil, + err: &errors.TestingError{}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: "bar"}, + &value.String{Value: "custom_message"}, + }, + expect: nil, + err: &errors.AssertionError{ + Message: "custom_message", + }, + }, + } + + for i := range tests { + _, err := Assert_not_contains( + &context.Context{}, + tests[i].args..., + ) + if diff := cmp.Diff( + tests[i].err, + err, + cmpopts.IgnoreFields(errors.AssertionError{}, "Message", "Actual"), + cmpopts.IgnoreFields(errors.TestingError{}, "Message"), + ); diff != "" { + t.Errorf("Assert_not_contains()[%d] error: diff=%s", i, diff) + } + } +} diff --git a/tester/function/assert_not_equal.go b/tester/function/assert_not_equal.go new file mode 100644 index 00000000..80440b4b --- /dev/null +++ b/tester/function/assert_not_equal.go @@ -0,0 +1,30 @@ +package function + +import ( + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Assert_not_equal_lookup_Name = "assert" + +func Assert_not_equal_lookup_Validate(args []value.Value) error { + if len(args) < 2 || len(args) > 3 { + return errors.ArgumentNotInRange(Assert_not_equal_lookup_Name, 2, 3, args) + } + if len(args) == 3 { + if args[2].Type() != value.StringType { + return errors.TypeMismatch(Assert_not_equal_lookup_Name, 3, value.StringType, args[2].Type()) + } + } + return nil +} + +func Assert_not_equal(ctx *context.Context, args ...value.Value) (value.Value, error) { + if err := Assert_not_equal_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + // assert.not_equal is alias for assert.not_strict_equal + return assert_not_strict_equal(args...) +} diff --git a/tester/function/assert_not_equal_test.go b/tester/function/assert_not_equal_test.go new file mode 100644 index 00000000..6c072aac --- /dev/null +++ b/tester/function/assert_not_equal_test.go @@ -0,0 +1,388 @@ +package function + +import ( + "net" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ysugimoto/falco/ast" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +func assert_not_test(t *testing.T, v value.Value, suite testSuite, name string) { + ret, err := Assert_not_equal(&context.Context{}, v, suite.compare) + if diff := cmp.Diff( + suite.err, + err, + cmpopts.IgnoreFields(errors.AssertionError{}, "Message", "Actual"), + cmpopts.IgnoreFields(errors.TestingError{}, "Message"), + ); diff != "" { + t.Errorf("Assert_not_equal()[%s] error: diff=%s", name, diff) + } + if diff := cmp.Diff(ret, &value.Boolean{Value: suite.expect}); diff != "" { + t.Errorf("Assert_not_equal()[%s] return value mismatch: diff=%s", name, diff) + } +} + +func Test_Assert_not_equal(t *testing.T) { + + now := time.Now() + assertionError := &errors.AssertionError{} + + t.Run("NULL", func(t *testing.T) { + v := value.Null + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_not_test(t, v, tests[i], "Actual is NULL") + } + }) + + t.Run("INTEGER", func(t *testing.T) { + v := &value.Integer{Value: 1} + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, expect: true}, + {compare: &value.Integer{Value: 1}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_not_test(t, v, tests[i], "Actual is INTEGER") + } + }) + + t.Run("FLOAT", func(t *testing.T) { + v := &value.Float{Value: 1} + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, expect: true}, + {compare: &value.Float{Value: 1}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_not_test(t, v, tests[i], "Actual is FLOAT") + } + }) + + t.Run("STRING", func(t *testing.T) { + v := &value.String{Value: "test"} + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, expect: true}, + {compare: &value.String{Value: "test"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_not_test(t, v, tests[i], "Actual is STRING") + } + }) + + t.Run("BOOLEAN", func(t *testing.T) { + v := &value.Boolean{Value: true} + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, expect: true}, + {compare: &value.Boolean{Value: true}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_not_test(t, v, tests[i], "Actual is BOOLEAN") + } + }) + + t.Run("RTIME", func(t *testing.T) { + v := &value.RTime{Value: time.Second} + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, expect: true}, + {compare: &value.RTime{Value: time.Second}, err: assertionError}, + {compare: &value.Time{Value: now}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_not_test(t, v, tests[i], "Actual is RTIME") + } + }) + + t.Run("TIME", func(t *testing.T) { + v := &value.Time{Value: now} + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now}, err: assertionError}, + {compare: &value.Time{Value: now.Add(time.Second)}, expect: true}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_not_test(t, v, tests[i], "Actual is TIME") + } + }) + + t.Run("IP", func(t *testing.T) { + v := &value.IP{Value: net.ParseIP("192.168.0.1")} + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, expect: true}, + {compare: &value.IP{Value: net.ParseIP("192.168.0.1")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now.Add(time.Second)}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_not_test(t, v, tests[i], "Actual is IP") + } + }) + + t.Run("BACKEND", func(t *testing.T) { + v := &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "test", + }, + }, + } + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now.Add(time.Second)}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, expect: true}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "test", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_not_test(t, v, tests[i], "Actual is BACKEND") + } + }) + + t.Run("ACL", func(t *testing.T) { + v := &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "test", + }, + }, + } + tests := []testSuite{ + {compare: value.Null, err: assertionError}, + {compare: &value.String{Value: "string"}, err: assertionError}, + {compare: &value.IP{Value: net.ParseIP("10.0.0.0")}, err: assertionError}, + {compare: &value.Boolean{}, err: assertionError}, + {compare: &value.Integer{Value: 100}, err: assertionError}, + {compare: &value.Float{Value: 100}, err: assertionError}, + {compare: &value.RTime{Value: 10 * time.Second}, err: assertionError}, + {compare: &value.Time{Value: now.Add(time.Second)}, err: assertionError}, + {compare: &value.Backend{ + Value: &ast.BackendDeclaration{ + Name: &ast.Ident{ + Value: "backend", + }, + }, + }, err: assertionError}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "acl", + }, + }, + }, expect: true}, + {compare: &value.Acl{ + Value: &ast.AclDeclaration{ + Name: &ast.Ident{ + Value: "test", + }, + }, + }, err: assertionError}, + } + + for i := range tests { + assert_not_test(t, v, tests[i], "Actual is ACL") + } + }) +} diff --git a/tester/function/assert_not_match.go b/tester/function/assert_not_match.go new file mode 100644 index 00000000..bb7bfb71 --- /dev/null +++ b/tester/function/assert_not_match.go @@ -0,0 +1,68 @@ +package function + +import ( + "regexp" + + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Assert_not_match_lookup_Name = "assert" + +var Assert_not_match_lookup_ArgumentTypes = []value.Type{value.StringType, value.StringType} + +func Assert_not_match_lookup_Validate(args []value.Value) error { + if len(args) < 2 || len(args) > 3 { + return errors.ArgumentNotInRange(Assert_not_match_lookup_Name, 2, 3, args) + } + + for i := range Assert_not_match_lookup_ArgumentTypes { + if args[i].Type() != Assert_not_match_lookup_ArgumentTypes[i] { + return errors.TypeMismatch(Assert_not_match_lookup_Name, i+1, Assert_not_match_lookup_ArgumentTypes[i], args[i].Type()) + } + } + + if len(args) == 3 { + if args[2].Type() != value.StringType { + return errors.TypeMismatch(Assert_not_match_lookup_Name, 3, value.StringType, args[2].Type()) + } + } + return nil +} + +func Assert_not_match(ctx *context.Context, args ...value.Value) (value.Value, error) { + if err := Assert_not_match_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + // Check custom message + var message string + if len(args) == 3 { + message = value.Unwrap[*value.String](args[2]).Value + } + + actual := value.Unwrap[*value.String](args[0]) + expect := value.Unwrap[*value.String](args[1]) + + re, err := regexp.Compile(expect.Value) + if err != nil { + return nil, errors.NewTestingError( + "Invalid regexp string provided: %s", + expect.Value, + ) + } + ret := &value.Boolean{Value: re.MatchString(actual.Value)} + if ret.Value { + if message != "" { + return ret, errors.NewAssertionError(actual, message) + } + return ret, errors.NewAssertionError( + actual, + `"%s" should not match against %s`, + actual.Value, + expect.Value, + ) + } + return ret, nil +} diff --git a/tester/function/assert_not_match_test.go b/tester/function/assert_not_match_test.go new file mode 100644 index 00000000..294e05e8 --- /dev/null +++ b/tester/function/assert_not_match_test.go @@ -0,0 +1,78 @@ +package function + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +func Test_Assert_not_match(t *testing.T) { + + tests := []struct { + args []value.Value + err error + expect *value.Boolean + }{ + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: ".*bat.*"}, + }, + expect: &value.Boolean{Value: true}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: ".*bar.*"}, + }, + expect: &value.Boolean{Value: false}, + err: &errors.AssertionError{}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.Integer{Value: 0}, + }, + expect: nil, + err: &errors.TestingError{}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: ".*bar.*"}, + &value.String{Value: "custom_message"}, + }, + expect: nil, + err: &errors.AssertionError{ + Message: "custom_message", + }, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: "^++a"}, + }, + expect: nil, + err: &errors.TestingError{}, + }, + } + + for i := range tests { + _, err := Assert_not_match( + &context.Context{}, + tests[i].args..., + ) + if diff := cmp.Diff( + tests[i].err, + err, + cmpopts.IgnoreFields(errors.AssertionError{}, "Message", "Actual"), + cmpopts.IgnoreFields(errors.TestingError{}, "Message"), + ); diff != "" { + t.Errorf("Assert_not_match()[%d] error: diff=%s", i, diff) + } + } +} diff --git a/tester/function/assert_not_strict_equal.go b/tester/function/assert_not_strict_equal.go new file mode 100644 index 00000000..985692ad --- /dev/null +++ b/tester/function/assert_not_strict_equal.go @@ -0,0 +1,52 @@ +package function + +import ( + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Assert_not_strict_equal_lookup_Name = "assert" + +func Assert_not_strict_equal_lookup_Validate(args []value.Value) error { + if len(args) < 2 || len(args) > 3 { + return errors.ArgumentNotInRange(Assert_not_strict_equal_lookup_Name, 2, 3, args) + } + if len(args) == 3 { + if args[2].Type() != value.StringType { + return errors.TypeMismatch(Assert_not_strict_equal_lookup_Name, 3, value.StringType, args[2].Type()) + } + } + return nil +} + +func Assert_not_strict_equal(ctx *context.Context, args ...value.Value) (value.Value, error) { + if err := Assert_not_strict_equal_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + return assert_not_strict_equal(args...) +} + +func assert_not_strict_equal(args ...value.Value) (value.Value, error) { + // Check custom message + var message string + if len(args) == 3 { + message = value.Unwrap[*value.String](args[2]).Value + } + + actual, expect := args[0], args[1] + if expect.Type() != actual.Type() { + if message == "" { + return &value.Boolean{}, errors.NewAssertionError( + actual, + "Type Mismatch: expect=%s but actual=%s", + expect.Type(), + actual.Type(), + ) + } + return &value.Boolean{}, errors.NewAssertionError(actual, message) + } + + return assert_not(actual, actual.String(), expect.String(), message) +} diff --git a/tester/function/assert_null.go b/tester/function/assert_null.go new file mode 100644 index 00000000..38162902 --- /dev/null +++ b/tester/function/assert_null.go @@ -0,0 +1,33 @@ +package function + +import ( + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Assert_null_lookup_Name = "assert" + +func Assert_null_lookup_Validate(args []value.Value) error { + if len(args) != 1 { + return errors.ArgumentNotEnough(Assert_null_lookup_Name, 1, args) + } + return nil +} + +func Assert_null(ctx *context.Context, args ...value.Value) (value.Value, error) { + if err := Assert_null_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + switch args[0].Type() { + case value.StringType: + v := value.Unwrap[*value.String](args[0]) + return assert(v, v.IsNotSet, true, "value is not null") + case value.IpType: + v := value.Unwrap[*value.IP](args[0]) + return assert(v, v.IsNotSet, true, "value is not null") + default: + return assert(args[0], args[0].String(), "NULL", "value is not null") + } +} diff --git a/tester/function/assert_null_test.go b/tester/function/assert_null_test.go new file mode 100644 index 00000000..80ed1992 --- /dev/null +++ b/tester/function/assert_null_test.go @@ -0,0 +1,61 @@ +package function + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +func Test_Assert_null(t *testing.T) { + + tests := []struct { + args []value.Value + err error + expect *value.Boolean + }{ + { + args: []value.Value{ + value.Null, + }, + expect: &value.Boolean{Value: true}, + }, + { + args: []value.Value{ + &value.String{IsNotSet: true}, + }, + expect: &value.Boolean{Value: true}, + }, + { + args: []value.Value{ + &value.IP{IsNotSet: true}, + }, + expect: &value.Boolean{Value: true}, + }, + { + args: []value.Value{ + &value.Integer{}, + }, + expect: &value.Boolean{Value: false}, + err: &errors.AssertionError{}, + }, + } + + for i := range tests { + _, err := Assert_null( + &context.Context{}, + tests[i].args..., + ) + if diff := cmp.Diff( + tests[i].err, + err, + cmpopts.IgnoreFields(errors.AssertionError{}, "Message", "Actual"), + cmpopts.IgnoreFields(errors.TestingError{}, "Message"), + ); diff != "" { + t.Errorf("Assert_null()[%d] error: diff=%s", i, diff) + } + } +} diff --git a/tester/function/assert_starts_with.go b/tester/function/assert_starts_with.go new file mode 100644 index 00000000..2c9e6898 --- /dev/null +++ b/tester/function/assert_starts_with.go @@ -0,0 +1,61 @@ +package function + +import ( + "strings" + + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Assert_starts_with_lookup_Name = "assert" + +var Assert_starts_with_lookup_ArgumentTypes = []value.Type{value.StringType, value.StringType} + +func Assert_starts_with_lookup_Validate(args []value.Value) error { + if len(args) < 2 || len(args) > 3 { + return errors.ArgumentNotInRange(Assert_starts_with_lookup_Name, 2, 3, args) + } + + for i := range Assert_starts_with_lookup_ArgumentTypes { + if args[i].Type() != Assert_starts_with_lookup_ArgumentTypes[i] { + return errors.TypeMismatch(Assert_starts_with_lookup_Name, i+1, Assert_starts_with_lookup_ArgumentTypes[i], args[i].Type()) + } + } + + if len(args) == 3 { + if args[2].Type() != value.StringType { + return errors.TypeMismatch(Assert_starts_with_lookup_Name, 3, value.StringType, args[2].Type()) + } + } + return nil +} + +func Assert_starts_with(ctx *context.Context, args ...value.Value) (value.Value, error) { + if err := Assert_starts_with_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + // Check custom message + var message string + if len(args) == 3 { + message = value.Unwrap[*value.String](args[2]).Value + } + + actual := value.Unwrap[*value.String](args[0]) + expect := value.Unwrap[*value.String](args[1]) + + ret := &value.Boolean{Value: strings.HasPrefix(actual.Value, expect.Value)} + if !ret.Value { + if message != "" { + return ret, errors.NewAssertionError(actual, message) + } + return ret, errors.NewAssertionError( + actual, + `"%s" should start with "%s"`, + expect.Value, + actual.Value, + ) + } + return ret, nil +} diff --git a/tester/function/assert_starts_with_test.go b/tester/function/assert_starts_with_test.go new file mode 100644 index 00000000..ffa732e8 --- /dev/null +++ b/tester/function/assert_starts_with_test.go @@ -0,0 +1,70 @@ +package function + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +func Test_Assert_starts_with(t *testing.T) { + + tests := []struct { + args []value.Value + err error + expect *value.Boolean + }{ + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: "foo"}, + }, + expect: &value.Boolean{Value: true}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: "goo"}, + }, + expect: &value.Boolean{Value: false}, + err: &errors.AssertionError{}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.Integer{Value: 0}, + }, + expect: nil, + err: &errors.TestingError{}, + }, + { + args: []value.Value{ + &value.String{Value: "foobarbaz"}, + &value.String{Value: "bat"}, + &value.String{Value: "custom_message"}, + }, + expect: nil, + err: &errors.AssertionError{ + Message: "custom_message", + }, + }, + } + + for i := range tests { + _, err := Assert_starts_with( + &context.Context{}, + tests[i].args..., + ) + if diff := cmp.Diff( + tests[i].err, + err, + cmpopts.IgnoreFields(errors.AssertionError{}, "Message", "Actual"), + cmpopts.IgnoreFields(errors.TestingError{}, "Message"), + ); diff != "" { + t.Errorf("Assert_starts_with()[%d] error: diff=%s", i, diff) + } + } +} diff --git a/tester/function/assert_strict_equal.go b/tester/function/assert_strict_equal.go new file mode 100644 index 00000000..a50dcc0d --- /dev/null +++ b/tester/function/assert_strict_equal.go @@ -0,0 +1,52 @@ +package function + +import ( + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Assert_strict_equal_lookup_Name = "assert" + +func Assert_strict_equal_lookup_Validate(args []value.Value) error { + if len(args) < 2 || len(args) > 3 { + return errors.ArgumentNotInRange(Assert_strict_equal_lookup_Name, 2, 3, args) + } + if len(args) == 3 { + if args[2].Type() != value.StringType { + return errors.TypeMismatch(Assert_strict_equal_lookup_Name, 3, value.StringType, args[2].Type()) + } + } + return nil +} + +func Assert_strict_equal(ctx *context.Context, args ...value.Value) (value.Value, error) { + if err := Assert_strict_equal_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + return assert_strict_equal(args...) +} + +func assert_strict_equal(args ...value.Value) (value.Value, error) { + // Check custom message + var message string + if len(args) == 3 { + message = value.Unwrap[*value.String](args[2]).Value + } + + actual, expect := args[0], args[1] + if expect.Type() != actual.Type() { + if message == "" { + return &value.Boolean{}, errors.NewAssertionError( + actual, + "Type Mismatch: expect=%s but actual=%s", + expect.Type(), + actual.Type(), + ) + } + return &value.Boolean{}, errors.NewAssertionError(actual, message) + } + + return assert(actual, actual.String(), expect.String(), message) +} diff --git a/tester/function/assert_test.go b/tester/function/assert_test.go new file mode 100644 index 00000000..cb68f54f --- /dev/null +++ b/tester/function/assert_test.go @@ -0,0 +1,57 @@ +package function + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +func Test_Assert(t *testing.T) { + + tests := []struct { + args []value.Value + err error + expect *value.Boolean + }{ + { + args: []value.Value{ + &value.Boolean{}, + }, + err: &errors.AssertionError{}, + expect: &value.Boolean{}, + }, + { + args: []value.Value{ + &value.Boolean{Value: true}, + }, + expect: &value.Boolean{Value: false}, + }, + { + args: []value.Value{ + &value.Boolean{Value: true}, + &value.Boolean{Value: true}, + }, + err: &errors.TestingError{}, + expect: &value.Boolean{Value: false}, + }, + } + + for i := range tests { + _, err := Assert( + &context.Context{}, + tests[i].args..., + ) + if diff := cmp.Diff( + tests[i].err, + err, + cmpopts.IgnoreFields(errors.AssertionError{}, "Message", "Actual"), + cmpopts.IgnoreFields(errors.TestingError{}, "Message"), + ); diff != "" { + t.Errorf("Assert()[%d] error: diff=%s", i, diff) + } + } +} diff --git a/tester/function/assert_true.go b/tester/function/assert_true.go new file mode 100644 index 00000000..82822773 --- /dev/null +++ b/tester/function/assert_true.go @@ -0,0 +1,33 @@ +package function + +import ( + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Assert_true_lookup_Name = "assert" + +func Assert_true_lookup_Validate(args []value.Value) error { + if len(args) != 1 { + return errors.ArgumentNotEnough(Assert_true_lookup_Name, 1, args) + } + return nil +} + +func Assert_true(ctx *context.Context, args ...value.Value) (value.Value, error) { + if err := Assert_true_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + switch args[0].Type() { + case value.BooleanType: + v := value.Unwrap[*value.Boolean](args[0]) + return assert(v, v.Value, true, "Value should be true") + default: + return &value.Boolean{}, errors.NewTestingError( + "Assertion type mismatch, %s type is not BOOLEAN type", + args[0].Type(), + ) + } +} diff --git a/tester/function/assert_true_test.go b/tester/function/assert_true_test.go new file mode 100644 index 00000000..462e5415 --- /dev/null +++ b/tester/function/assert_true_test.go @@ -0,0 +1,56 @@ +package function + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +func Test_Assert_true(t *testing.T) { + + tests := []struct { + args []value.Value + err error + expect *value.Boolean + }{ + { + args: []value.Value{ + value.Null, + }, + expect: &value.Boolean{Value: false}, + err: &errors.TestingError{}, + }, + { + args: []value.Value{ + &value.Boolean{Value: true}, + }, + expect: &value.Boolean{Value: true}, + }, + { + args: []value.Value{ + &value.Boolean{Value: false}, + }, + err: &errors.AssertionError{}, + expect: &value.Boolean{Value: false}, + }, + } + + for i := range tests { + _, err := Assert_true( + &context.Context{}, + tests[i].args..., + ) + if diff := cmp.Diff( + tests[i].err, + err, + cmpopts.IgnoreFields(errors.AssertionError{}, "Message", "Actual"), + cmpopts.IgnoreFields(errors.TestingError{}, "Message"), + ); diff != "" { + t.Errorf("Assert_true()[%d] error: diff=%s", i, diff) + } + } +} diff --git a/tester/function/assertions.go b/tester/function/assertions.go new file mode 100644 index 00000000..81d420db --- /dev/null +++ b/tester/function/assertions.go @@ -0,0 +1,34 @@ +package function + +import ( + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +type assertable interface { + string | int64 | bool | float64 | value.Type +} + +func assert[T assertable](v value.Value, actual, expect T, message string) (*value.Boolean, error) { + ok := &value.Boolean{Value: actual == expect} + if !ok.Value { + if message != "" { + return ok, errors.NewAssertionError(v, message) + } + return ok, errors.NewAssertionError(v, + "Assertion error: expect=%v, actual=%v", expect, actual) + } + return ok, nil +} + +func assert_not[T assertable](v value.Value, actual, expect T, message string) (*value.Boolean, error) { + ok := &value.Boolean{Value: actual != expect} + if !ok.Value { + if message != "" { + return ok, errors.NewAssertionError(v, message) + } + return ok, errors.NewAssertionError(v, + "Assertion error: expect=%v, actual=%v", expect, actual) + } + return ok, nil +} diff --git a/tester/function/testing_call_subroutine.go b/tester/function/testing_call_subroutine.go new file mode 100644 index 00000000..d3b4545f --- /dev/null +++ b/tester/function/testing_call_subroutine.go @@ -0,0 +1,56 @@ +package function + +import ( + "github.com/ysugimoto/falco/interpreter" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Testing_call_subroutine_lookup_Name = "assert" + +var Testing_call_subroutine_lookup_ArgumentTypes = []value.Type{value.StringType} + +func Testing_call_subroutine_lookup_Validate(args []value.Value) error { + if len(args) != 1 { + return errors.ArgumentNotEnough(Testing_call_subroutine_lookup_Name, 1, args) + } + for i := range args { + if args[i].Type() != Testing_call_subroutine_lookup_ArgumentTypes[i] { + return errors.TypeMismatch( + Testing_call_subroutine_lookup_Name, + i+1, + Testing_call_subroutine_lookup_ArgumentTypes[i], + args[i].Type(), + ) + } + } + return nil +} + +func Testing_call_subroutine( + ctx *context.Context, + i *interpreter.Interpreter, + args ...value.Value, +) (value.Value, error) { + + if err := Testing_call_subroutine_lookup_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + var state interpreter.State + var err error + name := value.Unwrap[*value.String](args[0]).Value + + // Functional subroutine + if sub, ok := ctx.SubroutineFunctions[name]; ok { + _, state, err = i.ProcessFunctionSubroutine(sub, interpreter.DebugPass) + // Scoped subroutine + } else if sub, ok := ctx.Subroutines[name]; ok { + state, err = i.ProcessSubroutine(sub, interpreter.DebugPass) + } + if err != nil { + return value.Null, errors.NewTestingError(err.Error()) + } + return &value.String{Value: string(state)}, nil +} diff --git a/tester/function/testing_functions.go b/tester/function/testing_functions.go new file mode 100644 index 00000000..b34510b7 --- /dev/null +++ b/tester/function/testing_functions.go @@ -0,0 +1,336 @@ +package function + +import ( + "github.com/pkg/errors" + "github.com/ysugimoto/falco/interpreter" + "github.com/ysugimoto/falco/interpreter/context" + ifn "github.com/ysugimoto/falco/interpreter/function" + "github.com/ysugimoto/falco/interpreter/value" +) + +const allScope = context.AnyScope + +type Counter interface { + Pass() + Fail(err error) +} + +// nolint: funlen,gocognit +func TestingFunctions(i *interpreter.Interpreter, c Counter) map[string]*ifn.Function { + return map[string]*ifn.Function{ + // Special testing function of "testing.call_subrouting" + // We need to interpret subroutine statement in this function + // so pass *interpreter.Interpreter pointer to the function + "testing.call_subroutine": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + v, err := Testing_call_subroutine(ctx, i, args...) + if err != nil { + return value.Null, err + } + ctx.ReturnState = value.Unwrap[*value.String](v) + return value.Null, nil + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "assert": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + unwrapped, err := unwrapIdentArguments(i, args) + if err != nil { + return value.Null, errors.WithStack(err) + } + v, err := Assert(ctx, unwrapped...) + if err != nil { + c.Fail(err) + } else { + c.Pass() + } + return v, err + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "assert.null": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + unwrapped, err := unwrapIdentArguments(i, args) + if err != nil { + return value.Null, errors.WithStack(err) + } + v, err := Assert_null(ctx, unwrapped...) + if err != nil { + c.Fail(err) + } else { + c.Pass() + } + return v, err + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "assert.true": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + unwrapped, err := unwrapIdentArguments(i, args) + if err != nil { + return value.Null, errors.WithStack(err) + } + v, err := Assert_true(ctx, unwrapped...) + if err != nil { + c.Fail(err) + } else { + c.Pass() + } + return v, err + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "assert.false": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + unwrapped, err := unwrapIdentArguments(i, args) + if err != nil { + return value.Null, errors.WithStack(err) + } + v, err := Assert_false(ctx, unwrapped...) + if err != nil { + c.Fail(err) + } else { + c.Pass() + } + return v, err + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "assert.equal": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + unwrapped, err := unwrapIdentArguments(i, args) + if err != nil { + return value.Null, errors.WithStack(err) + } + v, err := Assert_equal(ctx, unwrapped...) + if err != nil { + c.Fail(err) + } else { + c.Pass() + } + return v, err + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "assert.not_equal": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + unwrapped, err := unwrapIdentArguments(i, args) + if err != nil { + return value.Null, errors.WithStack(err) + } + v, err := Assert_not_equal(ctx, unwrapped...) + if err != nil { + c.Fail(err) + } else { + c.Pass() + } + return v, err + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "assert.strict_equal": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + unwrapped, err := unwrapIdentArguments(i, args) + if err != nil { + return value.Null, errors.WithStack(err) + } + v, err := Assert_strict_equal(ctx, unwrapped...) + if err != nil { + c.Fail(err) + } else { + c.Pass() + } + return v, err + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "assert.not_strict_equal": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + unwrapped, err := unwrapIdentArguments(i, args) + if err != nil { + return value.Null, errors.WithStack(err) + } + v, err := Assert_not_strict_equal(ctx, unwrapped...) + if err != nil { + c.Fail(err) + } else { + c.Pass() + } + return v, err + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "assert.match": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + unwrapped, err := unwrapIdentArguments(i, args) + if err != nil { + return value.Null, errors.WithStack(err) + } + v, err := Assert_match(ctx, unwrapped...) + if err != nil { + c.Fail(err) + } else { + c.Pass() + } + return v, err + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "assert.not_match": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + unwrapped, err := unwrapIdentArguments(i, args) + if err != nil { + return value.Null, errors.WithStack(err) + } + v, err := Assert_not_match(ctx, unwrapped...) + if err != nil { + c.Fail(err) + } else { + c.Pass() + } + return v, err + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "assert.contains": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + unwrapped, err := unwrapIdentArguments(i, args) + if err != nil { + return value.Null, errors.WithStack(err) + } + v, err := Assert_contains(ctx, unwrapped...) + if err != nil { + c.Fail(err) + } else { + c.Pass() + } + return v, err + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "assert.not_contains": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + unwrapped, err := unwrapIdentArguments(i, args) + if err != nil { + return value.Null, errors.WithStack(err) + } + v, err := Assert_not_contains(ctx, unwrapped...) + if err != nil { + c.Fail(err) + } else { + c.Pass() + } + return v, err + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "assert.starts_with": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + unwrapped, err := unwrapIdentArguments(i, args) + if err != nil { + return value.Null, errors.WithStack(err) + } + v, err := Assert_starts_with(ctx, unwrapped...) + if err != nil { + c.Fail(err) + } else { + c.Pass() + } + return v, err + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "assert.ends_with": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + unwrapped, err := unwrapIdentArguments(i, args) + if err != nil { + return value.Null, errors.WithStack(err) + } + v, err := Assert_ends_with(ctx, unwrapped...) + if err != nil { + c.Fail(err) + } else { + c.Pass() + } + return v, err + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + } +} + +func unwrapIdentArguments(ip *interpreter.Interpreter, args []value.Value) ([]value.Value, error) { + for i := range args { + if args[i].Type() != value.IdentType { + continue + } + ident := value.Unwrap[*value.Ident](args[i]) + v, err := ip.IdentValue(ident.Value, false) + if err != nil { + return nil, errors.WithStack(err) + } + args[i] = v + } + + return args, nil +} diff --git a/tester/tester.go b/tester/tester.go new file mode 100644 index 00000000..2923ea42 --- /dev/null +++ b/tester/tester.go @@ -0,0 +1,216 @@ +package tester + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/ysugimoto/falco/ast" + "github.com/ysugimoto/falco/config" + "github.com/ysugimoto/falco/interpreter" + icontext "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function" + "github.com/ysugimoto/falco/interpreter/variable" + "github.com/ysugimoto/falco/lexer" + "github.com/ysugimoto/falco/parser" + "github.com/ysugimoto/falco/resolver" + tf "github.com/ysugimoto/falco/tester/function" + tv "github.com/ysugimoto/falco/tester/variable" +) + +var ( + defaultTimeout = 10 // testing process will be timeouted in 10 minutes + ErrTimeout = errors.New("Timeout") +) + +type Tester struct { + interpreter *interpreter.Interpreter + config *config.TestConfig +} + +func New(c *config.TestConfig, i *interpreter.Interpreter) *Tester { + return &Tester{ + interpreter: i, + config: c, + } +} + +// Find test target VCL files +// Note that: +// - Test files must have ".test.vcl" extension e.g default.test.vcl +// - Tester finds files from all include paths +func (t *Tester) listTestFiles(mainVCL string) ([]string, error) { + // correct include paths + searchDirs := []string{filepath.Dir(mainVCL)} + searchDirs = append(searchDirs, t.config.IncludePaths...) + + var testFiles []string + for i := range searchDirs { + files, err := findTestTargetFiles(searchDirs[i]) + if err != nil { + return nil, errors.WithStack(err) + } + testFiles = append(testFiles, files...) + } + + return testFiles, nil +} + +// Only expose function for running tests +func (t *Tester) Run(main string) ([]*TestResult, *TestCounter, error) { + // Find test target VCL files + targetFiles, err := t.listTestFiles(main) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + counter := NewTestCounter() + // Inject testing variables and functions to be enable to run tests in testing VCL files + variable.Inject(&tv.TestingVariables{}) + if err := function.Inject(tf.TestingFunctions(t.interpreter, counter)); err != nil { + return nil, nil, errors.WithStack(err) + } + + // Run tests + var results []*TestResult + for i := range targetFiles { + result, err := t.run(targetFiles[i]) + if err != nil { + continue + } + results = append(results, result) + } + return results, counter, nil +} + +// Actually run testing method +func (t *Tester) run(testFile string) (*TestResult, error) { + resolvers, err := resolver.NewFileResolvers(testFile, t.config.IncludePaths) + if err != nil { + return nil, errors.WithStack(err) + } + + main, err := resolvers[0].MainVCL() + if err != nil { + return nil, errors.WithStack(err) + } + + l := lexer.NewFromString(main.Data, lexer.WithFile(main.Name)) + vcl, err := parser.New(l).ParseVCL() + if err != nil { + return nil, errors.WithStack(err) + } + + // On testing, incoming HTTP request always mocked + mockRequest := httptest.NewRequest(http.MethodGet, "http://localhost", nil) + ctx := context.Background() + + errChan := make(chan error) + finishChan := make(chan []*TestCase) + + timeout := defaultTimeout + if t.config.Timeout > 0 { + timeout = t.config.Timeout + } + timeoutChan := time.After(time.Duration(timeout) * time.Minute) + + go func(vcl *ast.VCL) { + var cases []*TestCase + for _, stmt := range vcl.Statements { + // We treat subroutine as testing + sub, ok := stmt.(*ast.SubroutineDeclaration) + if !ok { + continue + } + if err := t.interpreter.TestProcessInit(mockRequest.Clone(ctx)); err != nil { + errChan <- errors.WithStack(err) + return + } + suite, scopes := t.findTestSuites(sub) + for _, s := range scopes { + err := t.interpreter.ProcessTestSubroutine(s, sub) + cases = append(cases, &TestCase{ + Name: suite, + Error: errors.Cause(err), + Scope: s.String(), + }) + } + } + finishChan <- cases + }(vcl) + + // Aggregate asynchronous channels + select { + case err := <-errChan: + return nil, err + case <-timeoutChan: + return nil, ErrTimeout + case cases := <-finishChan: + return &TestResult{ + Filename: testFile, + Cases: cases, + Lexer: l, + }, nil + } +} + +func (t *Tester) findTestSuites(sub *ast.SubroutineDeclaration) (string, []icontext.Scope) { + // Find test suite name and scope from annotation + suiteName := sub.Name.Value + + var scopes []icontext.Scope + comments := sub.GetMeta().Leading + for i := range comments { + l := strings.TrimLeft(comments[i].Value, " */#") + if !strings.HasPrefix(l, "@") { + continue + } + // If @suite annotation found, use it as suite name + if strings.HasPrefix(l, "@suite:") { + suiteName = strings.TrimPrefix(l, "@suite:") + continue + } + var an []string + if strings.HasPrefix(l, "@scope:") { + an = strings.Split(strings.TrimPrefix(l, "@scope:"), ",") + } else { + an = strings.Split(strings.TrimPrefix(l, "@"), ",") + } + for _, s := range an { + scopes = append(scopes, icontext.ScopeByString(strings.TrimSpace(s))) + } + } + + if len(scopes) > 0 { + return suiteName, scopes + } + + // If we could not determine scope from annotation, try to find from subroutine name + switch { + case strings.HasSuffix(sub.Name.Value, "_recv"): + scopes = append(scopes, icontext.RecvScope) + case strings.HasSuffix(sub.Name.Value, "_hash"): + scopes = append(scopes, icontext.HashScope) + case strings.HasSuffix(sub.Name.Value, "_miss"): + scopes = append(scopes, icontext.MissScope) + case strings.HasSuffix(sub.Name.Value, "_pass"): + scopes = append(scopes, icontext.PassScope) + case strings.HasSuffix(sub.Name.Value, "_fetch"): + scopes = append(scopes, icontext.FetchScope) + case strings.HasSuffix(sub.Name.Value, "_deliver"): + scopes = append(scopes, icontext.DeliverScope) + case strings.HasSuffix(sub.Name.Value, "_error"): + scopes = append(scopes, icontext.ErrorScope) + case strings.HasSuffix(sub.Name.Value, "_log"): + scopes = append(scopes, icontext.LogScope) + default: + // Set RECV scope as default + scopes = append(scopes, icontext.RecvScope) + } + + return suiteName, scopes +} diff --git a/tester/variable/testing.go b/tester/variable/testing.go new file mode 100644 index 00000000..58eccfe7 --- /dev/null +++ b/tester/variable/testing.go @@ -0,0 +1,38 @@ +package variable + +import ( + "fmt" + + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/value" + iv "github.com/ysugimoto/falco/interpreter/variable" +) + +// Dedicated for testing variables +const ( + TESTING_STATE = "testing.state" +) + +type TestingVariables struct { + iv.InjectVariable +} + +func (v *TestingVariables) Get(ctx *context.Context, scope context.Scope, name string) (value.Value, error) { + switch name { // nolint:gocritic + case TESTING_STATE: + return ctx.ReturnState, nil + } + + return nil, fmt.Errorf("Not Found") +} + +func (v *TestingVariables) Set( + ctx *context.Context, + scope context.Scope, + name string, + operator string, + val value.Value, +) error { + + return fmt.Errorf("Not Found") +}