From aff4790d22728d89e7e2dac8af262c92087b5b39 Mon Sep 17 00:00:00 2001 From: Julien Dessaux Date: Sat, 11 Sep 2021 12:27:59 +0200 Subject: Piece evry last week changes together in a big rewrite of the webui --- README.md | 17 +++---- internal/webui/html/root.html | 17 ++----- internal/webui/html/specificStop.html | 16 ++++++ internal/webui/html/stop.html | 11 ++++ internal/webui/root.go | 21 +++----- internal/webui/root_test.go | 13 ++--- internal/webui/specificStop.go | 68 +++++++++++++++++++++++++ internal/webui/specificStop_test.go | 62 ++++++++++++++++++++++ internal/webui/stop.go | 50 ++++++++++++++++++ internal/webui/stop_test.go | 62 ++++++++++++++++++++++ internal/webui/utils.go | 8 +++ internal/webui/webui.go | 2 + pkg/config/config.go | 7 --- pkg/config/config_test.go | 22 ++++---- pkg/config/error.go | 15 ------ pkg/config/error_test.go | 2 - pkg/config/test_data/complete.yaml | 1 - pkg/config/test_data/invalid_trainStop.yaml | 4 -- pkg/config/test_data/minimal.yaml | 1 - pkg/config/test_data/minimal_with_hostname.yaml | 1 - 20 files changed, 308 insertions(+), 92 deletions(-) create mode 100644 internal/webui/html/specificStop.html create mode 100644 internal/webui/html/stop.html create mode 100644 internal/webui/specificStop.go create mode 100644 internal/webui/specificStop_test.go create mode 100644 internal/webui/stop.go create mode 100644 internal/webui/stop_test.go delete mode 100644 pkg/config/test_data/invalid_trainStop.yaml diff --git a/README.md b/README.md index 1ea91cb..ce4ba8d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Trains -Trains is a simple web app to display train timetables for specific lines at stations on France SNCF's network. It queries the SNCF official api by default but will work with any compatible Navitia api implementation and present the results in a minimal web page that loads fast (unlike the official sites with all their images and ads). +Trains is a simple web app to display train timetables for stations on France SNCF's network. It queries the SNCF official api by default but will work with any compatible Navitia api implementation and present the results in a minimal web page that loads fast (unlike the official sites with all their images and ads). -Api queries are cached for 60 seconds so that someone refreshing your instance cannot simply DOS the api and exhaust your request quota. +StopAreas' Api queries are cached for 60 seconds so that someone refreshing your instance cannot simply DOS the api and exhaust your request quota, they would need to fetch different stations each time. A personal instance runs at https://trains.adyxax.org/. @@ -18,7 +18,7 @@ A personal instance runs at https://trains.adyxax.org/. ## Dependencies -go is required. Only go version >= 1.16 on linux amd64 (Gentoo and Ubuntu 20.04) and on OpenBSD amd64 has been tested. +go is required. Only go version >= 1.17 on linux amd64 (Gentoo and Ubuntu 20.04) and on OpenBSD amd64 is being regularly tested. ## Quick Install @@ -34,18 +34,12 @@ The default configuration file location is `$HOME/.config/trains/config.yaml`. I address: 127.0.0.1 port: 8082 token: 12345678-9abc-def0-1234-56789abcdef0 -trainStop: stop_area:SNCF:87723502 ``` `address` can be any ipv4 or ipv6 address or a hostname that resolves to such address and defaults to `127.0.0.1`. `port` can be any valid tcp port number or service name and defaults to `8080`. You can get a free token from the [official SNCF's website](https://www.digital.sncf.com/startup/api/token-developpeur) for up to 5000 requests per day. -For now you can get the trainStop codes by manually parsing queries like the following. It is quite horrible but with a bit of perseverance you will find the stop code you want : -``` -for i in `seq 4`; do curl 'https://TOKEN@api.sncf.com/v1/coverage/sncf/stop_areas?count=1000&start_page='$i |jq > $i.json; done -``` - ## Usage Launching the webui server is as simple as : @@ -81,8 +75,9 @@ GOOS=openbsd GOARCH=amd64 go build -ldflags="-s -w" ./cmd/trains-webui/ ## Design Choices -- Being a small webapp, the only database supported for now is sqlite3 -- Being a small webapp with no expectation of traffic and for simplicity, the user sessions are currently stored in the database +Being a small webapp, the following choices have been made : +- the only database supported for now is sqlite3 +- Having no expectation of heavy traffic and for simplicity, the user sessions are currently stored in the database ## References diff --git a/internal/webui/html/root.html b/internal/webui/html/root.html index 97441c8..6b7bd6e 100644 --- a/internal/webui/html/root.html +++ b/internal/webui/html/root.html @@ -1,16 +1,9 @@ -{{ define "title"}}Horaires des prochains trains à Crépieux la Pape{{ end }} +{{ define "title"}}Trains!{{ end }} {{ template "base" . }} {{ define "main" }} -

Horaires des prochains trains à Crépieux la Pape

- - - - - - {{ range .Departures }} - - {{ end }} - -
Arrivée en gareDirection
{{ .Arrival }}{{ .DisplayName }}
+

Menu

+ {{ end }} diff --git a/internal/webui/html/specificStop.html b/internal/webui/html/specificStop.html new file mode 100644 index 0000000..5916927 --- /dev/null +++ b/internal/webui/html/specificStop.html @@ -0,0 +1,16 @@ +{{ define "title"}}Horaires des prochains trains à {{ .Stop }}{{ end }} +{{ template "base" . }} + +{{ define "main" }} +

Horaires des prochains trains à {{ .Stop }}

+ + + + + + {{ range $i, $elt := .Departures }} + + {{ end }} + +
Arrivée en gareDirection
{{ .Arrival }}{{ .Direction }}
+{{ end }} diff --git a/internal/webui/html/stop.html b/internal/webui/html/stop.html new file mode 100644 index 0000000..33a94c6 --- /dev/null +++ b/internal/webui/html/stop.html @@ -0,0 +1,11 @@ +{{ define "title"}}Choisir une gare{{ end }} +{{ template "base" . }} + +{{ define "main" }} +

Choisir une gare

+ +{{ end }} diff --git a/internal/webui/root.go b/internal/webui/root.go index 94f2169..423ba93 100644 --- a/internal/webui/root.go +++ b/internal/webui/root.go @@ -3,18 +3,16 @@ package webui import ( "fmt" "html/template" - "log" "net/http" "git.adyxax.org/adyxax/trains/pkg/model" ) -var rootTemplate = template.Must(template.ParseFS(templatesFS, "html/base.html", "html/root.html")) +var rootTemplate = template.Must(template.New("root").Funcs(funcMap).ParseFS(templatesFS, "html/base.html", "html/root.html")) // The page template variable -type Page struct { - User *model.User - Departures []model.Departure +type RootPage struct { + User *model.User } // The root handler of the webui @@ -25,16 +23,9 @@ func rootHandler(e *env, w http.ResponseWriter, r *http.Request) error { http.Redirect(w, r, "/login", http.StatusFound) return nil } - var departures []model.Departure - if departures, err := e.navitia.GetDepartures(e.conf.TrainStop); err != nil { - log.Printf("%s; data returned: %+v\n", err, departures) - return newStatusError(http.StatusInternalServerError, fmt.Errorf("Could not get departures")) - } else { - w.Header().Set("Cache-Control", "no-store, no-cache") - } - p := Page{ - User: user, - Departures: departures, + w.Header().Set("Cache-Control", "no-store, no-cache") + p := RootPage{ + User: user, } err = rootTemplate.ExecuteTemplate(w, "root.html", p) if err != nil { diff --git a/internal/webui/root_test.go b/internal/webui/root_test.go index 7e4ec71..e96f1c8 100644 --- a/internal/webui/root_test.go +++ b/internal/webui/root_test.go @@ -24,15 +24,8 @@ func TestRootHandler(t *testing.T) { require.Nil(t, err) e := env{ dbEnv: dbEnv, - conf: &config.Config{TrainStop: "test"}, + conf: &config.Config{}, } - departures1 := []model.Departure{ - model.Departure{ - Direction: "test direction", - Arrival: "20210503T150405", - }, - } - e.navitia = &NavitiaMockClient{departures: departures1, err: nil} // test GET requests runHttpTest(t, &e, rootHandler, &httpTestCase{ name: "a simple get when not logged in should redirect to the login page", @@ -46,7 +39,7 @@ func TestRootHandler(t *testing.T) { }, }) runHttpTest(t, &e, rootHandler, &httpTestCase{ - name: "a simple get when logged in should display the departure times", + name: "a simple get when logged in should display the menu", input: httpTestInput{ method: http.MethodGet, path: "/", @@ -54,7 +47,7 @@ func TestRootHandler(t *testing.T) { }, expect: httpTestExpect{ code: http.StatusOK, - bodyString: "Horaires des prochains trains", + bodyString: "Menu", }, }) } diff --git a/internal/webui/specificStop.go b/internal/webui/specificStop.go new file mode 100644 index 0000000..64dd04a --- /dev/null +++ b/internal/webui/specificStop.go @@ -0,0 +1,68 @@ +package webui + +import ( + "fmt" + "html/template" + "log" + "net/http" + "path" + "regexp" + + "git.adyxax.org/adyxax/trains/pkg/model" +) + +var validStopId = regexp.MustCompile(`^stop_area:[a-zA-Z]+:\d+$`) + +var specificStopTemplate = template.Must(template.New("specificStop").Funcs(funcMap).ParseFS(templatesFS, "html/base.html", "html/specificStop.html")) + +// The page template variable +type SpecificStopPage struct { + User *model.User + Stop string + Departures []model.Departure +} + +// The stop handler of the webui +func specificStopHandler(e *env, w http.ResponseWriter, r *http.Request) error { + if path.Dir(r.URL.Path) == "/stop" { + user, err := tryAndResumeSession(e, r) + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return nil + } + switch r.Method { + case http.MethodGet: + id := path.Base(r.URL.Path) + if id == "" { + return newStatusError(http.StatusBadRequest, fmt.Errorf("No id in query string")) // TODO should we redirect to root page to chose a stop id? + } + if ok := validStopId.MatchString(id); !ok { + return newStatusError(http.StatusBadRequest, fmt.Errorf("Invalid stop id")) + } + stop, err := e.dbEnv.GetStop(id) + if err != nil { + return newStatusError(http.StatusBadRequest, fmt.Errorf("Stop id not found in database")) // TODO do better + } + if departures, err := e.navitia.GetDepartures(stop.Id); err != nil { + log.Printf("%s; data returned: %+v\n", err, departures) + return newStatusError(http.StatusInternalServerError, fmt.Errorf("Could not get departures")) + } else { + w.Header().Set("Cache-Control", "no-store, no-cache") + p := SpecificStopPage{ + User: user, + Stop: stop.Name, + Departures: departures, + } + err = specificStopTemplate.ExecuteTemplate(w, "specificStop.html", p) + if err != nil { + return newStatusError(http.StatusInternalServerError, err) + } + return nil + } + default: + return newStatusError(http.StatusMethodNotAllowed, fmt.Errorf(http.StatusText(http.StatusMethodNotAllowed))) + } + } else { + return newStatusError(http.StatusNotFound, fmt.Errorf("Invalid path in specificStopHandler")) + } +} diff --git a/internal/webui/specificStop_test.go b/internal/webui/specificStop_test.go new file mode 100644 index 0000000..45b2c2f --- /dev/null +++ b/internal/webui/specificStop_test.go @@ -0,0 +1,62 @@ +package webui + +import ( + "net/http" + "testing" + + "git.adyxax.org/adyxax/trains/pkg/config" + "git.adyxax.org/adyxax/trains/pkg/database" + "git.adyxax.org/adyxax/trains/pkg/model" + "github.com/stretchr/testify/require" +) + +func TestSpecificStopHandler(t *testing.T) { + // test environment setup + dbEnv, err := database.InitDB("sqlite3", "file::memory:?_foreign_keys=on") + require.Nil(t, err) + err = dbEnv.Migrate() + require.Nil(t, err) + user1, err := dbEnv.CreateUser(&model.UserRegistration{Username: "user1", Password: "password1", Email: "julien@adyxax.org"}) + require.Nil(t, err) + _, err = dbEnv.Login(&model.UserLogin{Username: "user1", Password: "password1"}) + require.Nil(t, err) + token1, err := dbEnv.CreateSession(user1) + require.Nil(t, err) + err = dbEnv.ReplaceAndImportStops([]model.Stop{model.Stop{Id: "stop_area:test:01", Name: "test"}}) + require.Nil(t, err) + e := env{ + dbEnv: dbEnv, + conf: &config.Config{}, + } + departures1 := []model.Departure{ + model.Departure{ + Direction: "test direction", + Arrival: "20210503T150405", + }, + } + e.navitia = &NavitiaMockClient{departures: departures1, err: nil} + // test GET requests + runHttpTest(t, &e, specificStopHandler, &httpTestCase{ + name: "a simple get when not logged in should redirect to the login page", + input: httpTestInput{ + method: http.MethodGet, + path: "/stop/stop_area:test:01", + }, + expect: httpTestExpect{ + code: http.StatusFound, + location: "/login", + }, + }) + runHttpTest(t, &e, specificStopHandler, &httpTestCase{ + name: "a get of a subpath when logged in should display the departure times", + input: httpTestInput{ + method: http.MethodGet, + path: "/stop/stop_area:test:01", + cookie: &http.Cookie{Name: sessionCookieName, Value: *token1}, + }, + expect: httpTestExpect{ + code: http.StatusOK, + bodyString: "Horaires des prochains trains à test", + }, + }) +} diff --git a/internal/webui/stop.go b/internal/webui/stop.go new file mode 100644 index 0000000..68d592e --- /dev/null +++ b/internal/webui/stop.go @@ -0,0 +1,50 @@ +package webui + +import ( + "fmt" + "html/template" + "net/http" + + "git.adyxax.org/adyxax/trains/pkg/model" +) + +var stopTemplate = template.Must(template.New("stop").Funcs(funcMap).ParseFS(templatesFS, "html/base.html", "html/stop.html")) + +// The page template variable +type StopPage struct { + User *model.User + Stops []model.Stop +} + +// The stop handler of the webui +func stopHandler(e *env, w http.ResponseWriter, r *http.Request) error { + if r.URL.Path == "/stop" { + user, err := tryAndResumeSession(e, r) + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return nil + } + switch r.Method { + case http.MethodGet: + stops, err := e.dbEnv.GetStops() + if err != nil { + return newStatusError(http.StatusInternalServerError, fmt.Errorf("Could not get train stops")) + } else { + w.Header().Set("Cache-Control", "no-store, no-cache") + } + p := StopPage{ + User: user, + Stops: stops, + } + err = stopTemplate.ExecuteTemplate(w, "stop.html", p) + if err != nil { + return newStatusError(http.StatusInternalServerError, err) + } + return nil + default: + return newStatusError(http.StatusMethodNotAllowed, fmt.Errorf(http.StatusText(http.StatusMethodNotAllowed))) + } + } else { + return newStatusError(http.StatusNotFound, fmt.Errorf("Invalid path in stopHandler")) + } +} diff --git a/internal/webui/stop_test.go b/internal/webui/stop_test.go new file mode 100644 index 0000000..30e7593 --- /dev/null +++ b/internal/webui/stop_test.go @@ -0,0 +1,62 @@ +package webui + +import ( + "net/http" + "testing" + + "git.adyxax.org/adyxax/trains/pkg/config" + "git.adyxax.org/adyxax/trains/pkg/database" + "git.adyxax.org/adyxax/trains/pkg/model" + "github.com/stretchr/testify/require" +) + +func TestStopHandler(t *testing.T) { + // test environment setup + dbEnv, err := database.InitDB("sqlite3", "file::memory:?_foreign_keys=on") + require.Nil(t, err) + err = dbEnv.Migrate() + require.Nil(t, err) + user1, err := dbEnv.CreateUser(&model.UserRegistration{Username: "user1", Password: "password1", Email: "julien@adyxax.org"}) + require.Nil(t, err) + _, err = dbEnv.Login(&model.UserLogin{Username: "user1", Password: "password1"}) + require.Nil(t, err) + token1, err := dbEnv.CreateSession(user1) + require.Nil(t, err) + err = dbEnv.ReplaceAndImportStops([]model.Stop{model.Stop{Id: "stop_area:test:01", Name: "test"}}) + require.Nil(t, err) + e := env{ + dbEnv: dbEnv, + conf: &config.Config{}, + } + departures1 := []model.Departure{ + model.Departure{ + Direction: "test direction", + Arrival: "20210503T150405", + }, + } + e.navitia = &NavitiaMockClient{departures: departures1, err: nil} + // test GET requests + runHttpTest(t, &e, stopHandler, &httpTestCase{ + name: "a simple get when not logged in should redirect to the login page", + input: httpTestInput{ + method: http.MethodGet, + path: "/stop", + }, + expect: httpTestExpect{ + code: http.StatusFound, + location: "/login", + }, + }) + runHttpTest(t, &e, stopHandler, &httpTestCase{ + name: "a simple get when logged in should display the stops list", + input: httpTestInput{ + method: http.MethodGet, + path: "/stop", + cookie: &http.Cookie{Name: sessionCookieName, Value: *token1}, + }, + expect: httpTestExpect{ + code: http.StatusOK, + bodyString: "stop_area:test:01", + }, + }) +} diff --git a/internal/webui/utils.go b/internal/webui/utils.go index be8baf3..28a7add 100644 --- a/internal/webui/utils.go +++ b/internal/webui/utils.go @@ -2,6 +2,7 @@ package webui import ( "embed" + "html/template" "log" "net/http" @@ -16,6 +17,13 @@ var templatesFS embed.FS //go:embed static/* var staticFS embed.FS +// Template functions +var funcMap = template.FuncMap{ + "odd": func(i int) bool { + return i%2 == 1 + }, +} + // the environment that will be passed to our handlers type env struct { conf *config.Config diff --git a/internal/webui/webui.go b/internal/webui/webui.go index b18bc5c..63173c6 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -18,6 +18,8 @@ func Run(c *config.Config, dbEnv *database.DBEnv) { http.Handle("/", handler{&e, rootHandler}) http.Handle("/login", handler{&e, loginHandler}) http.Handle("/static/", http.FileServer(http.FS(staticFS))) + http.Handle("/stop", handler{&e, stopHandler}) + http.Handle("/stop/", handler{&e, specificStopHandler}) if i, err := dbEnv.CountStops(); err == nil && i == 0 { log.Printf("No trains stops data found, updating...") diff --git a/pkg/config/config.go b/pkg/config/config.go index 795ccd0..442695c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,7 +9,6 @@ import ( ) var validToken = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) -var validTrainStop = regexp.MustCompile(`^[a-zA-Z0-9:_]+$`) type Config struct { // Address is the hostname or ip the web server will listen to @@ -18,8 +17,6 @@ type Config struct { Port string `yaml:"port"` // Token is the sncf api token Token string `yaml:"token"` - // TrainStop is the navitia code of the train stop the webapp will monitor - TrainStop string `yaml:"trainStop"` } func (c *Config) validate() error { @@ -43,10 +40,6 @@ func (c *Config) validate() error { if ok := validToken.MatchString(c.Token); !ok { return newInvalidTokenError(c.Token) } - // TrainStop - if ok := validTrainStop.MatchString(c.TrainStop); !ok { - return newInvalidTrainStopError(c.TrainStop) - } return nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 02f2840..43d4a48 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -11,26 +11,23 @@ import ( func TestLoadFile(t *testing.T) { // Minimal yaml file minimalConfig := Config{ - Address: "127.0.0.1", - Port: "8080", - Token: "12345678-9abc-def0-1234-56789abcdef0", - TrainStop: "ABCD:test:01", + Address: "127.0.0.1", + Port: "8080", + Token: "12345678-9abc-def0-1234-56789abcdef0", } // Minimal yaml file with hostname resolving minimalConfigWithResolving := Config{ - Address: "localhost", - Port: "www", - Token: "12345678-9abc-def0-1234-56789abcdef0", - TrainStop: "VWXY_Z:test:90", + Address: "localhost", + Port: "www", + Token: "12345678-9abc-def0-1234-56789abcdef0", } // Complete yaml file completeConfig := Config{ - Address: "127.0.0.2", - Port: "8082", - Token: "12345678-9abc-def0-1234-56789abcdef0", - TrainStop: "ABCD:test:01", + Address: "127.0.0.2", + Port: "8082", + Token: "12345678-9abc-def0-1234-56789abcdef0", } // Test cases @@ -46,7 +43,6 @@ func TestLoadFile(t *testing.T) { {"Unresolvable address should fail to load", "test_data/invalid_address_unresolvable.yaml", nil, &InvalidAddressError{}}, {"Invalid port should fail to load", "test_data/invalid_port.yaml", nil, &InvalidPortError{}}, {"Invalid token should fail to load", "test_data/invalid_token.yaml", nil, &InvalidTokenError{}}, - {"Invalid trainStop should fail to load", "test_data/invalid_trainStop.yaml", nil, &InvalidTrainStopError{}}, {"Minimal config", "test_data/minimal.yaml", &minimalConfig, nil}, {"Minimal config with resolving", "test_data/minimal_with_hostname.yaml", &minimalConfigWithResolving, nil}, {"Complete config", "test_data/complete.yaml", &completeConfig, nil}, diff --git a/pkg/config/error.go b/pkg/config/error.go index 41a323c..c49b6a9 100644 --- a/pkg/config/error.go +++ b/pkg/config/error.go @@ -90,18 +90,3 @@ func newInvalidTokenError(token string) error { token: token, } } - -// Invalid trainStop field error -type InvalidTrainStopError struct { - trainStop string -} - -func (e *InvalidTrainStopError) Error() string { - return fmt.Sprintf("Invalid trainStop %s : it must be a string that lookslike \"stop_area:SNCF:87723502\" (make sure to quote the string because of the colon characters)", e.trainStop) -} - -func newInvalidTrainStopError(trainStop string) error { - return &InvalidTrainStopError{ - trainStop: trainStop, - } -} diff --git a/pkg/config/error_test.go b/pkg/config/error_test.go index 4a80b05..f9807c1 100644 --- a/pkg/config/error_test.go +++ b/pkg/config/error_test.go @@ -17,6 +17,4 @@ func TestErrorsCoverage(t *testing.T) { _ = invalidPortErr.Unwrap() invalidTokenErr := InvalidTokenError{} _ = invalidTokenErr.Error() - invalidTrainStopErr := InvalidTrainStopError{} - _ = invalidTrainStopErr.Error() } diff --git a/pkg/config/test_data/complete.yaml b/pkg/config/test_data/complete.yaml index e34db15..2944645 100644 --- a/pkg/config/test_data/complete.yaml +++ b/pkg/config/test_data/complete.yaml @@ -1,4 +1,3 @@ address: 127.0.0.2 port: 8082 token: 12345678-9abc-def0-1234-56789abcdef0 -trainStop: "ABCD:test:01" diff --git a/pkg/config/test_data/invalid_trainStop.yaml b/pkg/config/test_data/invalid_trainStop.yaml deleted file mode 100644 index 159950d..0000000 --- a/pkg/config/test_data/invalid_trainStop.yaml +++ /dev/null @@ -1,4 +0,0 @@ -address: 127.0.0.2 -port: 8082 -token: 12345678-9abc-def0-1234-56789abcdef0 -trainStop: = diff --git a/pkg/config/test_data/minimal.yaml b/pkg/config/test_data/minimal.yaml index 5092997..932fca3 100644 --- a/pkg/config/test_data/minimal.yaml +++ b/pkg/config/test_data/minimal.yaml @@ -1,2 +1 @@ token: 12345678-9abc-def0-1234-56789abcdef0 -trainStop: "ABCD:test:01" diff --git a/pkg/config/test_data/minimal_with_hostname.yaml b/pkg/config/test_data/minimal_with_hostname.yaml index 116688a..dbede14 100644 --- a/pkg/config/test_data/minimal_with_hostname.yaml +++ b/pkg/config/test_data/minimal_with_hostname.yaml @@ -1,4 +1,3 @@ address: localhost port: www token: 12345678-9abc-def0-1234-56789abcdef0 -trainStop: "VWXY_Z:test:90" -- cgit v1.2.3