From 99ab40979155d97c5c9553a1cb79d4ba9b11870c Mon Sep 17 00:00:00 2001 From: dnitsch Date: Mon, 24 Oct 2022 09:59:54 +0100 Subject: [PATCH] fix: initial commit with basic implementation --- README.md | 17 ++- cmd/main.go | 8 ++ cmd/uistrategy/uistrategy.go | 53 ++++++++ go.mod | 23 ++++ go.sum | 37 ++++++ internal/cmdutil/cmdutil.go | 21 ++++ internal/config/config.go | 5 + internal/util/helper.go | 31 +++++ internal/util/helper_test.go | 1 + test-input.yml | 8 ++ uistrategy.go | 236 +++++++++++++++++++++++++++++++++++ uistrategy_test.go | 94 ++++++++++++++ 12 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 cmd/main.go create mode 100644 cmd/uistrategy/uistrategy.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cmdutil/cmdutil.go create mode 100644 internal/config/config.go create mode 100644 internal/util/helper.go create mode 100644 internal/util/helper_test.go create mode 100644 test-input.yml create mode 100644 uistrategy.go create mode 100644 uistrategy_test.go diff --git a/README.md b/README.md index e957833..4c4e905 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,15 @@ -# uistrategy -Config driven UI driver for data seeding and automated tests +# UI Strategy + +Config driven UI driver for data seeding for cases where there isn't an REST API available and automated UI tests stored in declarative configuration files. + +Part of strategy series :D - see reststrategy :wink: + +Run local tests + +`docker run --name=pb-app --detach -p 8090:8090 dnitsch/reststrategy-sample:latest` + +Then navigate to this [page](http://127.0.0.1:8090/_/?installer#) + +## Underlying Web Driver + +This module and CLI use the [Go-Rod](https://github.com/go-rod/rod) which uses the CPD protocol. diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..4ce63ef --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,8 @@ +package main + +import uistrategy "github.com/dnitsch/uistrategy/cmd/uistrategy" + +func main() { + // init loggerHere or in init function + uistrategy.Execute() +} diff --git a/cmd/uistrategy/uistrategy.go b/cmd/uistrategy/uistrategy.go new file mode 100644 index 0000000..a15bd93 --- /dev/null +++ b/cmd/uistrategy/uistrategy.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "os" + + log "github.com/dnitsch/simplelog" + "github.com/dnitsch/uistrategy" + "github.com/dnitsch/uistrategy/internal/cmdutil" + "github.com/dnitsch/uistrategy/internal/util" + "github.com/spf13/cobra" +) + +var ( + path string + verbose bool + rootCmd = &cobra.Command{ + Use: "uistrategy", + RunE: runActions, + Short: "executes a series of actions against a URL", + Long: ``, + } +) + +func Execute() { + if err := rootCmd.Execute(); err != nil { + util.Exit(err) + } + util.CleanExit() +} + +func init() { + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output") + rootCmd.PersistentFlags().StringVarP(&path, "input", "i", "", "Path to the input file containing the config definition for the UIStrategy") +} + +// runActions parses and executes the provided actions +func runActions(cmd *cobra.Command, args []string) error { + conf := &uistrategy.UiStrategyConf{} + ui := uistrategy.New().WithLogger(logger(verbose)) + + if err := cmdutil.YamlParseInput(conf, path); err != nil { + return err + } + + return cmdutil.RunActions(ui, conf) +} + +func logger(verbose bool) log.Logger { + if verbose { + return log.New(os.Stderr, log.DebugLvl) + } + return log.New(os.Stderr, log.ErrorLvl) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b4112bc --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/dnitsch/uistrategy + +go 1.19 + +require github.com/go-rod/rod v0.112.0 + +require ( + github.com/inconshreveable/mousetrap v1.0.1 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/rs/zerolog v1.28.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 // indirect +) + +require ( + github.com/dnitsch/simplelog v1.5.1 + github.com/spf13/cobra v1.6.0 + github.com/ysmood/goob v0.4.0 // indirect + github.com/ysmood/gson v0.7.1 // indirect + github.com/ysmood/leakless v0.8.0 // indirect + gopkg.in/yaml.v2 v2.4.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ae9371e --- /dev/null +++ b/go.sum @@ -0,0 +1,37 @@ +github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/dnitsch/simplelog v1.5.1 h1:PhIFuZluJIAUiKgJ+xBzTZwqe49PFg20d3ToDucu3Dg= +github.com/dnitsch/simplelog v1.5.1/go.mod h1:scPvWULBlruthxS6seGJkMbc1DM5f44IRZNmaSZCJVU= +github.com/go-rod/rod v0.112.0 h1:U9Yc+quw4hxZ6GrdbWFBeylvaYElEKM9ijFW2LYkGlA= +github.com/go-rod/rod v0.112.0/go.mod h1:GZDtmEs6RpF6kBRYpGCZXxXlKNneKVPiKOjaMbmVVjE= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= +github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= +github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.6.0 h1:42a0n6jwCot1pUmomAp4T7DeMD+20LFv4Q54pxLf2LI= +github.com/spf13/cobra v1.6.0/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/got v0.31.3/go.mod h1:pE1l4LOwOBhQg6A/8IAatkGp7uZjnalzrZolnlhhMgY= +github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM= +github.com/ysmood/gson v0.7.1 h1:zKL2MTGtynxdBdlZjyGsvEOZ7dkxaY5TH6QhAbTgz0Q= +github.com/ysmood/gson v0.7.1/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.8.0 h1:BzLrVoiwxikpgEQR0Lk8NyBN5Cit2b1z+u0mgL4ZJak= +github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmdutil/cmdutil.go b/internal/cmdutil/cmdutil.go new file mode 100644 index 0000000..04441b3 --- /dev/null +++ b/internal/cmdutil/cmdutil.go @@ -0,0 +1,21 @@ +package cmdutil + +import ( + "os" + + "github.com/dnitsch/uistrategy" + "gopkg.in/yaml.v2" +) + +func RunActions(uistrategy *uistrategy.Web, conf *uistrategy.UiStrategyConf) error { + return nil +} + +// YamlParseInput will return a filled pointer with Unmarshalled data +func YamlParseInput[T any](input *T, path string) error { + b, err := os.ReadFile(path) + if err != nil { + return err + } + return yaml.Unmarshal(b, input) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..253195c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,5 @@ +package config + +const ( + SELF_NAME = "uistratetegy" +) diff --git a/internal/util/helper.go b/internal/util/helper.go new file mode 100644 index 0000000..067766f --- /dev/null +++ b/internal/util/helper.go @@ -0,0 +1,31 @@ +package util + +import ( + "log" + "os" +) + +func HomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + log.Fatal("unable to get the user home dir") + } + return home +} + +func WriteDataDir(datadir string) { + os.MkdirAll(datadir, 0755) +} + +// CleanExit signals 0 exit code and should clean up any current process +func CleanExit() { + os.Exit(0) +} + +func Exit(err error) { + log.Fatal(err) +} + +func Str(s string) *string { + return &s +} diff --git a/internal/util/helper_test.go b/internal/util/helper_test.go new file mode 100644 index 0000000..c7d8682 --- /dev/null +++ b/internal/util/helper_test.go @@ -0,0 +1 @@ +package util diff --git a/test-input.yml b/test-input.yml new file mode 100644 index 0000000..cf63a83 --- /dev/null +++ b/test-input.yml @@ -0,0 +1,8 @@ +baseUrl: http://127.0.0.1:8090 +timeout: 30 +reuseBrowserCache: false +auth: + username: ... + password: ddddd + actions: + - name: createCollection-1 diff --git a/uistrategy.go b/uistrategy.go new file mode 100644 index 0000000..1703648 --- /dev/null +++ b/uistrategy.go @@ -0,0 +1,236 @@ +package uistrategy + +import ( + "context" + "fmt" + "path" + + log "github.com/dnitsch/simplelog" + "github.com/dnitsch/uistrategy/internal/config" + "github.com/dnitsch/uistrategy/internal/util" + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" +) + +type Element struct { + CSSSelector *string `yaml:"cssSelector,omitempty" json:"cssSelector,omitempty"` + XPath *string `yaml:"xPath,omitempty" json:"xPath,omitempty"` + Value string `yaml:"value,omitempty" json:"value,omitempty"` + Must bool `yaml:"must" json:"must"` +} + +type Auth struct { + Username Element `yaml:"username" json:"username"` + Password Element `yaml:"password" json:"password"` + ConfirmPassword Element `yaml:"confirmPassword,omitempty json:"confirmPassword,omitempty` + RequireConfirm bool `yaml:"requireConfirm,omitempty" json:"requireConfirm,omitempty"` + Navigate string `yaml:"navigate" json:"navigate"` + Submit Element `yaml:"submit" json:"submit"` +} + +type LoggedInPage struct { + page *rod.Page + browser *rod.Browser + log log.Loggeriface +} + +// func (lp *LoggedInPage) WithLogger(l log.Logger) *LoggedInPage { +// lp.log = l +// return lp +// } + +type UiStrategyConf struct { + BaseUrl string `yaml:"baseUrl" json:"baseUrl"` + Timeout int `yaml:"timeout" json:"timeout"` + WebConfig struct { + Headless bool `yaml:"headless" json:"headless"` + // if enabled it will store session data on disk + // when used in CI, if you also want this enabled + // you should also cache the default location of where the cache is: + // ~/.uistratetegy-data + PersistSessionOnDisk bool `yaml:"persist" json:"persist"` + } `yaml:"webConfig,omitempty" json:"webConfig,omitempty"` + ReuseBrowserCache bool `yaml:"reuseBrowserCache" json:"reuseBrowserCache"` + Auth Auth `yaml:"auth" json:"auth"` + Actions []UIAction `yaml:"actions" json:"actions"` +} + +type UIAction struct { + Name string `yaml:"name" json:"name"` + Action +} + +// Action defines a single action to do +// e.g. look up item, input text, click/swipe +// can include Assertion that action successfully occured +type Action struct { + navigate string `yaml:"-" json:"-"` + Timeout int `yaml:"timeout,omitempty" json:"timeout,omitempty"` + Navigate string `yaml:"navigate" json:"navigate"` + Element Element `yaml:"element" json:"element"` + InputText *string `yaml:"inputText,omitempty" json:"inputText,omitempty"` + ClickSwipe bool `yaml:"clickSwipe" json:"clickSwipe"` + Assert any `yaml:"assert,omitempty" json:"assert,omitempty"` +} + +func (a *Action) WithNavigate(baseUrl string) *Action { + a.navigate = fmt.Sprintf("%s%s", baseUrl, a.Navigate) + return a +} + +type Web struct { + datadir *string + launcher *launcher.Launcher + browser *rod.Browser + log log.Loggeriface +} + +// New returns an initialised instance of Web struct +func New() *Web { + ddir := path.Join(util.HomeDir(), fmt.Sprintf(".%s-data", config.SELF_NAME)) + + l := launcher.New(). + Headless(false). + Devtools(false). + Leakless(true) + + // url := l.UserDataDir(ddir).MustLaunch() + url := l.MustLaunch() + + browser := rod.New(). + ControlURL(url). + MustConnect().NoDefaultDevice() + + return &Web{ + datadir: &ddir, + launcher: l, + browser: browser, + } +} + +func (w *Web) WithLogger(l log.Logger) *Web { + w.log = l + return w +} + +// ActionPerform wraps around a single action +func (web *Web) ActionsPerform(ctx context.Context, auth Auth, action []Action, config UiStrategyConf) []error { + var errs []error + // and re-use same browser for all calls + // defer web.browser.MustClose() + + // doAuth + page, err := web.DoAuth(auth) + defer web.browser.MustClose() + if err != nil { + return []error{err} + } + + // start driving in that session + for _, v := range action { + v = *v.WithNavigate(config.BaseUrl) + if e := page.PerformAction(v); e != nil { + errs = append(errs, err) + } + } + // logOut + return errs +} + +// DoAuth performs the required Authentication +// in the browser and returns a authed Page +func (web *Web) DoAuth(auth Auth) (*LoggedInPage, error) { + + util.WriteDataDir(*web.datadir) + + page := web.browser.MustPage(auth.Navigate) + lp := &LoggedInPage{page, web.browser, web.log} + // determine which selector is available special case for AuthHandler + determinActionElement(lp, auth.Username).MustInput(auth.Username.Value) + determinActionElement(lp, auth.Password).MustInput(auth.Password.Value) + determinActionElement(lp, auth.Submit).MustClick() + wait := lp.page.MustWaitRequestIdle() + wait() + return lp, nil +} + +// DoRegistration performs the required registration +// currently unused but will be a special dispensation +// for when the UI run of actions will require a registration of users +func (web *Web) DoRegistration(auth Auth) (*LoggedInPage, error) { + + util.WriteDataDir(*web.datadir) + + page := web.browser.MustPage(auth.Navigate) + lp := &LoggedInPage{page, web.browser, web.log} + // determine which selector is available special case for AuthHandler + determinActionElement(lp, auth.Username).MustInput(auth.Username.Value) + determinActionElement(lp, auth.Password).MustInput(auth.Password.Value) + if auth.RequireConfirm { + determinActionElement(lp, auth.ConfirmPassword).MustInput(auth.ConfirmPassword.Value) + } + determinActionElement(lp, auth.Submit).MustClick() + wait := lp.page.MustWaitRequestIdle() + wait() + return lp, nil +} + +// PerformAction handles a single action +func (p *LoggedInPage) PerformAction(action Action) error { + if err := p.page.Navigate(action.navigate); err != nil { + return err + } + // if Assert is specified do not perform action only assert on pageObjects + // extend screenshots here + if _, err := p.DetermineActionType(action, p.DetermineActionElement(action)); err != nil { + return err + } + return nil +} + +// DetermineActionType returns the rod.Element with correct action +func (p *LoggedInPage) DetermineActionElement(action Action) *rod.Element { + return determinActionElement(p, action.Element) +} + +func determinActionElement(lp *LoggedInPage, elem Element) *rod.Element { + if elem.Must { + return mustSelector(lp, elem) + } + return maySelector(lp, elem) +} + +func mustSelector(lp *LoggedInPage, elem Element) *rod.Element { + if elem.XPath != nil { + return lp.page.MustElementX(*elem.XPath) + } + return lp.page.MustElement(*elem.CSSSelector) +} + +func maySelector(lp *LoggedInPage, elem Element) *rod.Element { + if elem.XPath != nil { + if el, err := lp.page.ElementX(*elem.XPath); el != nil { + fmt.Printf("error %+v", err) + return el + } + } + if el, err := lp.page.Element(*elem.CSSSelector); el != nil { + fmt.Printf("error %+v", err) + return el + } + return nil +} + +// DetermineActionType returns the rod.Element with correct action +// either Click/Swipe or Input +// when Input is selected - ensure you have specified the input HTML element +// as the enclosing elements may not always allow for input... +func (p *LoggedInPage) DetermineActionType(action Action, elem *rod.Element) (*rod.Element, error) { + if action.ClickSwipe { + return elem.MustClick(), nil + } + if action.InputText != nil { + return elem.MustInput(*action.InputText), nil + } + return nil, fmt.Errorf("must specify either click/swipe action or input text must not be ") +} diff --git a/uistrategy_test.go b/uistrategy_test.go new file mode 100644 index 0000000..06c7215 --- /dev/null +++ b/uistrategy_test.go @@ -0,0 +1,94 @@ +package uistrategy + +import ( + "context" + "fmt" + "testing" + + "github.com/dnitsch/uistrategy/internal/util" +) + +var ( + testAuth = Auth{ + Username: Element{ + Must: true, + Value: "test@example.com", + XPath: util.Str(`//*[@class="app-body"]/div[1]/main/div/form/div[2]/input`), + }, + RequireConfirm: true, + Password: Element{ + Must: true, + Value: "P4s$w0rd123!", + XPath: util.Str(`//*[@class="app-body"]/div[1]/main/div/form/div[3]/input`), + }, + ConfirmPassword: Element{ + Must: true, + Value: "P4s$w0rd123!", + XPath: util.Str(`//*[@class="app-body"]/div[1]/main/div/form/div[4]/input`), + }, + Navigate: `/_/?installer#`, + Submit: Element{ + Must: true, + Value: "", + CSSSelector: util.Str(`#app > div > div > div.page-wrapper.full-page.center-content > main > div > form > button`), + }, + } + testActions = []Action{ + { + Navigate: `/_/?#/collections?collectionId=&filter=&sort=-created`, + Element: Element{ + Must: false, + CSSSelector: util.Str(`#app > div > div > div.page-wrapper.center-content > main > div > button`), + Value: "", + }, + ClickSwipe: true, + // #app > div > div > div.page-wrapper.center-content > main > div > button + }, + } +) + +func Test_DoAuth(t *testing.T) { + tests := []struct { + name string + auth Auth + }{ + { + name: "happy path", + auth: testAuth, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ui := New() + p, e := ui.DoAuth(tt.auth) + if e != nil { + t.Errorf("wanted %v to be ", e) + } + fmt.Println(p) + }) + } +} + +func Test_ActionsPerform(t *testing.T) { + tests := []struct { + name string + auth Auth + actions []Action + web *Web + }{ + { + name: "happy path", + auth: testAuth, + web: New(), + actions: testActions, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.web.ActionsPerform(context.TODO(), tt.auth, tt.actions) + if len(err) > 0 { + t.Errorf("expected errors to be nil, got %v", err) + } + }) + } +}