gui: new command to launch the https://github.com/rclone/rclone-web/ GUI
This adds a new gui command which runs an embedded copy of the GUI at https://github.com/rclone/rclone-web/ The GUI release is fetched as part of the CI build.
This commit is contained in:
@@ -231,6 +231,9 @@ jobs:
|
|||||||
check-latest: true
|
check-latest: true
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
|
- name: Fetch GUI dist
|
||||||
|
run: make fetch-gui
|
||||||
|
|
||||||
- name: Cache
|
- name: Cache
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -20,3 +20,5 @@ __pycache__
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
resource_windows_*.syso
|
resource_windows_*.syso
|
||||||
.devcontainer
|
.devcontainer
|
||||||
|
# GUI dist is fetched at build time via `make fetch-gui`
|
||||||
|
cmd/gui/dist/
|
||||||
@@ -45,9 +45,9 @@ LINTTAGS=--build-tags "$(GOTAGS)"
|
|||||||
endif
|
endif
|
||||||
LDFLAGS=--ldflags "-s -X github.com/rclone/rclone/fs.Version=$(TAG)"
|
LDFLAGS=--ldflags "-s -X github.com/rclone/rclone/fs.Version=$(TAG)"
|
||||||
|
|
||||||
.PHONY: rclone test_all vars version
|
.PHONY: rclone test_all vars version fetch-gui
|
||||||
|
|
||||||
rclone:
|
rclone: fetch-gui
|
||||||
ifeq ($(GO_OS),windows)
|
ifeq ($(GO_OS),windows)
|
||||||
go run bin/resource_windows.go -version $(TAG) -syso resource_windows_`go env GOARCH`.syso
|
go run bin/resource_windows.go -version $(TAG) -syso resource_windows_`go env GOARCH`.syso
|
||||||
endif
|
endif
|
||||||
@@ -59,6 +59,9 @@ endif
|
|||||||
cp -av rclone`go env GOEXE` `go env GOPATH`/bin/rclone`go env GOEXE`.new
|
cp -av rclone`go env GOEXE` `go env GOPATH`/bin/rclone`go env GOEXE`.new
|
||||||
mv -v `go env GOPATH`/bin/rclone`go env GOEXE`.new `go env GOPATH`/bin/rclone`go env GOEXE`
|
mv -v `go env GOPATH`/bin/rclone`go env GOEXE`.new `go env GOPATH`/bin/rclone`go env GOEXE`
|
||||||
|
|
||||||
|
fetch-gui:
|
||||||
|
$(SHELL) ./bin/fetch-gui-dist.sh
|
||||||
|
|
||||||
test_all:
|
test_all:
|
||||||
go install $(LDFLAGS) $(BUILDTAGS) $(BUILD_ARGS) github.com/rclone/rclone/fstest/test_all
|
go install $(LDFLAGS) $(BUILDTAGS) $(BUILD_ARGS) github.com/rclone/rclone/fstest/test_all
|
||||||
|
|
||||||
|
|||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Fetch the latest GUI dist from rclone/rclone-web GitHub releases.
|
||||||
|
#
|
||||||
|
# Downloads dist.zip from the latest release and extracts it to
|
||||||
|
# cmd/gui/dist/. Skips the download if the local tag matches.
|
||||||
|
#
|
||||||
|
# Requires: curl, unzip
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="rclone/rclone-web"
|
||||||
|
DEST="cmd/gui/dist"
|
||||||
|
TAG_FILE="${DEST}/.tag"
|
||||||
|
|
||||||
|
CURL_OPTS=(-fSs --retry 5 --retry-delay 2 --retry-all-errors)
|
||||||
|
|
||||||
|
# Get the latest release info
|
||||||
|
echo "Checking latest release of ${REPO}..."
|
||||||
|
RELEASE_JSON=$(curl "${CURL_OPTS[@]}" \
|
||||||
|
"https://api.github.com/repos/${REPO}/releases/latest") || {
|
||||||
|
echo "Error: failed to fetch release info from GitHub API" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
TAG=$(echo "${RELEASE_JSON}" | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])")
|
||||||
|
ASSET_URL=$(echo "${RELEASE_JSON}" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
r = json.load(sys.stdin)
|
||||||
|
for a in r['assets']:
|
||||||
|
if a['name'] == 'dist.zip':
|
||||||
|
print(a['browser_download_url'])
|
||||||
|
sys.exit(0)
|
||||||
|
print('', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
") || {
|
||||||
|
echo "Error: dist.zip asset not found in release ${TAG}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Latest release: ${TAG}"
|
||||||
|
|
||||||
|
# Check if we already have this version
|
||||||
|
if [ -f "${TAG_FILE}" ] && [ "$(cat "${TAG_FILE}")" = "${TAG}" ]; then
|
||||||
|
echo "Already up to date (${TAG})"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download dist.zip
|
||||||
|
TMPFILE=$(mktemp /tmp/rclone-gui-dist.XXXXXX.zip)
|
||||||
|
trap 'rm -f "${TMPFILE}"' EXIT
|
||||||
|
|
||||||
|
echo "Downloading dist.zip from ${TAG}..."
|
||||||
|
curl -L "${CURL_OPTS[@]}" -o "${TMPFILE}" "${ASSET_URL}" || {
|
||||||
|
echo "Error: failed to download dist.zip" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract
|
||||||
|
echo "Extracting to ${DEST}/..."
|
||||||
|
rm -rf "${DEST}"
|
||||||
|
mkdir -p "${DEST}"
|
||||||
|
unzip -q "${TMPFILE}" -d "${DEST}"
|
||||||
|
|
||||||
|
# Write tag for cache comparison
|
||||||
|
echo -n "${TAG}" > "${TAG_FILE}"
|
||||||
|
|
||||||
|
echo "Done. GUI dist updated to ${TAG}"
|
||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
_ "github.com/rclone/rclone/cmd/genautocomplete"
|
_ "github.com/rclone/rclone/cmd/genautocomplete"
|
||||||
_ "github.com/rclone/rclone/cmd/gendocs"
|
_ "github.com/rclone/rclone/cmd/gendocs"
|
||||||
_ "github.com/rclone/rclone/cmd/gitannex"
|
_ "github.com/rclone/rclone/cmd/gitannex"
|
||||||
|
_ "github.com/rclone/rclone/cmd/gui"
|
||||||
_ "github.com/rclone/rclone/cmd/hashsum"
|
_ "github.com/rclone/rclone/cmd/hashsum"
|
||||||
_ "github.com/rclone/rclone/cmd/link"
|
_ "github.com/rclone/rclone/cmd/link"
|
||||||
_ "github.com/rclone/rclone/cmd/listremotes"
|
_ "github.com/rclone/rclone/cmd/listremotes"
|
||||||
|
|||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
// Package gui implements the "rclone gui" command.
|
||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
iofs "io/fs"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rclone/rclone/cmd"
|
||||||
|
"github.com/rclone/rclone/fs"
|
||||||
|
"github.com/rclone/rclone/fs/rc"
|
||||||
|
"github.com/rclone/rclone/fs/rc/rcserver"
|
||||||
|
libhttp "github.com/rclone/rclone/lib/http"
|
||||||
|
"github.com/rclone/rclone/lib/random"
|
||||||
|
"github.com/rclone/rclone/lib/systemd"
|
||||||
|
"github.com/skratchdot/open-golang/open"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed dist
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
var (
|
||||||
|
guiAddr []string
|
||||||
|
apiAddr []string
|
||||||
|
user string
|
||||||
|
pass string
|
||||||
|
noAuth bool
|
||||||
|
noOpenBrowser bool
|
||||||
|
enableMetrics bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cmd.Root.AddCommand(commandDefinition)
|
||||||
|
f := commandDefinition.Flags()
|
||||||
|
f.StringArrayVar(&guiAddr, "addr", nil, "IPaddress:Port for the GUI server (default auto-chosen localhost port)")
|
||||||
|
f.StringArrayVar(&apiAddr, "api-addr", nil, "IPaddress:Port for the RC API server (default auto-chosen localhost port)")
|
||||||
|
f.StringVar(&user, "user", "", "User name for RC authentication")
|
||||||
|
f.StringVar(&pass, "pass", "", "Password for RC authentication")
|
||||||
|
f.BoolVar(&noAuth, "no-auth", false, "Don't require auth for the RC API")
|
||||||
|
f.BoolVar(&noOpenBrowser, "no-open-browser", false, "Skip opening the browser automatically")
|
||||||
|
f.BoolVar(&enableMetrics, "enable-metrics", false, "Enable OpenMetrics/Prometheus compatible endpoint at /metrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
var commandDefinition = &cobra.Command{
|
||||||
|
Use: "gui",
|
||||||
|
Short: `Open the web based GUI.`,
|
||||||
|
Long: `This command starts an embedded web GUI for rclone and opens it in
|
||||||
|
your default browser.
|
||||||
|
|
||||||
|
It starts an RC API server and a GUI server on separate localhost
|
||||||
|
ports, generates login credentials automatically unless --no-auth
|
||||||
|
is specified, and opens the browser already authenticated.
|
||||||
|
|
||||||
|
rclone gui
|
||||||
|
|
||||||
|
Use --no-open-browser to skip opening the browser automatically:
|
||||||
|
|
||||||
|
rclone gui --no-open-browser
|
||||||
|
|
||||||
|
Use --addr to bind the GUI to a specific address:
|
||||||
|
|
||||||
|
rclone gui --addr localhost:5580
|
||||||
|
|
||||||
|
Use --user and --pass to set specific credentials:
|
||||||
|
|
||||||
|
rclone gui --user admin --pass secret
|
||||||
|
|
||||||
|
Use --no-auth to disable authentication entirely:
|
||||||
|
|
||||||
|
rclone gui --no-auth
|
||||||
|
`,
|
||||||
|
Annotations: map[string]string{
|
||||||
|
"versionIntroduced": "v1.74",
|
||||||
|
"groups": "RC",
|
||||||
|
},
|
||||||
|
Run: func(command *cobra.Command, args []string) {
|
||||||
|
cmd.CheckArgs(0, 0, command, args)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// --- 1. Create the GUI server (binds port eagerly, before Serve) ---
|
||||||
|
guiCfg := libhttp.DefaultCfg()
|
||||||
|
if command.Flags().Changed("addr") {
|
||||||
|
guiCfg.ListenAddr = guiAddr
|
||||||
|
} else {
|
||||||
|
guiCfg.ListenAddr = []string{"localhost:0"}
|
||||||
|
}
|
||||||
|
guiServer, err := libhttp.NewServer(ctx, libhttp.WithConfig(guiCfg))
|
||||||
|
if err != nil {
|
||||||
|
fs.Fatalf(nil, "Failed to create GUI server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the GUI origin from the bound address (available before Serve).
|
||||||
|
guiOrigin := originFromURL(guiServer.URLs()[0])
|
||||||
|
|
||||||
|
// --- 2. Configure the RC API server ---
|
||||||
|
opt := rc.Opt // copy global defaults
|
||||||
|
opt.Enabled = true
|
||||||
|
opt.WebUI = false
|
||||||
|
opt.Serve = false
|
||||||
|
|
||||||
|
if command.Flags().Changed("api-addr") {
|
||||||
|
opt.HTTP.ListenAddr = apiAddr
|
||||||
|
} else {
|
||||||
|
port, err := freePort()
|
||||||
|
if err != nil {
|
||||||
|
fs.Fatalf(nil, "Failed to find a free port for RC: %v", err)
|
||||||
|
}
|
||||||
|
opt.HTTP.ListenAddr = []string{fmt.Sprintf("localhost:%d", port)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS: allow the GUI origin to make cross-port API requests.
|
||||||
|
opt.HTTP.AllowOrigin = guiOrigin
|
||||||
|
|
||||||
|
// Forward metrics flag to the RC server.
|
||||||
|
if command.Flags().Changed("enable-metrics") {
|
||||||
|
opt.EnableMetrics = enableMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Generate credentials if needed ---
|
||||||
|
if command.Flags().Changed("user") {
|
||||||
|
opt.Auth.BasicUser = user
|
||||||
|
}
|
||||||
|
if command.Flags().Changed("pass") {
|
||||||
|
opt.Auth.BasicPass = pass
|
||||||
|
}
|
||||||
|
if command.Flags().Changed("no-auth") {
|
||||||
|
opt.NoAuth = noAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opt.NoAuth {
|
||||||
|
if opt.Auth.BasicUser == "" {
|
||||||
|
opt.Auth.BasicUser = "gui"
|
||||||
|
fs.Infof(nil, "No username specified. Using default username: %s", opt.Auth.BasicUser)
|
||||||
|
}
|
||||||
|
if opt.Auth.BasicPass == "" {
|
||||||
|
randomPass, err := random.Password(128)
|
||||||
|
if err != nil {
|
||||||
|
fs.Fatalf(nil, "Failed to make password: %v", err)
|
||||||
|
}
|
||||||
|
opt.Auth.BasicPass = randomPass
|
||||||
|
fs.Infof(nil, "No password specified. Using random password: %s", randomPass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Start the RC server (unchanged rcserver.Start) ---
|
||||||
|
rcServer, err := rcserver.Start(ctx, &opt)
|
||||||
|
if err != nil {
|
||||||
|
fs.Fatalf(nil, "Failed to start RC server: %v", err)
|
||||||
|
}
|
||||||
|
if rcServer == nil {
|
||||||
|
fs.Fatal(nil, "RC server not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the RC URL from the address we configured (rcserver.Server
|
||||||
|
// does not expose URLs, and we know the address we passed in).
|
||||||
|
rcURL := "http://" + opt.HTTP.ListenAddr[0] + "/"
|
||||||
|
|
||||||
|
// --- 5. Mount the embedded GUI handler and start serving ---
|
||||||
|
spaHandler := guiHandler()
|
||||||
|
guiServer.Router().Get("/*", spaHandler.ServeHTTP)
|
||||||
|
guiServer.Router().Head("/*", spaHandler.ServeHTTP)
|
||||||
|
guiServer.Serve()
|
||||||
|
|
||||||
|
guiURL := guiServer.URLs()[0]
|
||||||
|
fs.Logf(nil, "Serving GUI on %s", guiURL)
|
||||||
|
|
||||||
|
// --- 6. Open browser ---
|
||||||
|
loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth)
|
||||||
|
|
||||||
|
fs.Logf(nil, "GUI available at %s", loginURL)
|
||||||
|
if flag.Lookup("test.v") == nil && !noOpenBrowser {
|
||||||
|
if err := open.Start(loginURL); err != nil {
|
||||||
|
fs.Errorf(nil, "Failed to open GUI in browser: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 7. Wait for either server to exit, then shut both down ---
|
||||||
|
defer systemd.Notify()()
|
||||||
|
done := make(chan struct{}, 2)
|
||||||
|
go func() { rcServer.Wait(); done <- struct{}{} }()
|
||||||
|
go func() { guiServer.Wait(); done <- struct{}{} }()
|
||||||
|
<-done
|
||||||
|
_ = rcServer.Shutdown()
|
||||||
|
_ = guiServer.Shutdown()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// freePort asks the OS for a free TCP port on localhost.
|
||||||
|
func freePort() (int, error) {
|
||||||
|
l, err := net.Listen("tcp", "localhost:0")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = l.Close() }()
|
||||||
|
return l.Addr().(*net.TCPAddr).Port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// originFromURL extracts the origin (scheme://host) from a URL string,
|
||||||
|
// stripping any path or trailing slash.
|
||||||
|
func originFromURL(rawURL string) string {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return strings.TrimRight(rawURL, "/")
|
||||||
|
}
|
||||||
|
return u.Scheme + "://" + u.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
// guiHandler returns an http.Handler that serves the embedded GUI bundle
|
||||||
|
// with SPA fallback: paths that don't match a real file return index.html.
|
||||||
|
func guiHandler() http.Handler {
|
||||||
|
sub, err := iofs.Sub(assets, "dist")
|
||||||
|
if err != nil {
|
||||||
|
panic("gui: embedded dist missing: " + err.Error())
|
||||||
|
}
|
||||||
|
fileServer := http.FileServer(http.FS(sub))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
if path == "" {
|
||||||
|
path = "index.html"
|
||||||
|
}
|
||||||
|
if _, err := iofs.Stat(sub, path); err == nil {
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SPA fallback: serve index.html for unknown paths so that
|
||||||
|
// client-side routing (e.g. /login) works.
|
||||||
|
r.URL.Path = "/"
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// guiBaseURL is the GUI server's URL. rcURL is the RC API server's URL.
|
||||||
|
// When auth is enabled it appends url, user, and pass as query
|
||||||
|
// parameters so the React app can discover the API endpoint and
|
||||||
|
// log in automatically.
|
||||||
|
func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string {
|
||||||
|
u, err := url.Parse(guiBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return guiBaseURL
|
||||||
|
}
|
||||||
|
if noAuth {
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
u.Path = "/login"
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("url", rcURL)
|
||||||
|
q.Set("user", user)
|
||||||
|
q.Set("pass", pass)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildLoginURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
guiURL string
|
||||||
|
rcURL string
|
||||||
|
user string
|
||||||
|
pass string
|
||||||
|
noAuth bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with credentials",
|
||||||
|
guiURL: "http://localhost:5580/",
|
||||||
|
rcURL: "http://localhost:5572/",
|
||||||
|
user: "gui",
|
||||||
|
pass: "secret",
|
||||||
|
noAuth: false,
|
||||||
|
want: "http://localhost:5580/login?pass=secret&url=http%3A%2F%2Flocalhost%3A5572%2F&user=gui",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no auth",
|
||||||
|
guiURL: "http://localhost:5580/",
|
||||||
|
rcURL: "http://localhost:5572/",
|
||||||
|
user: "",
|
||||||
|
pass: "",
|
||||||
|
noAuth: true,
|
||||||
|
want: "http://localhost:5580/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no auth ignores credentials",
|
||||||
|
guiURL: "http://localhost:5580/",
|
||||||
|
rcURL: "http://localhost:5572/",
|
||||||
|
user: "gui",
|
||||||
|
pass: "secret",
|
||||||
|
noAuth: true,
|
||||||
|
want: "http://localhost:5580/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := buildLoginURL(tt.guiURL, tt.rcURL, tt.user, tt.pass, tt.noAuth)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOriginFromURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with trailing slash",
|
||||||
|
url: "http://localhost:5580/",
|
||||||
|
want: "http://localhost:5580",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with path",
|
||||||
|
url: "http://localhost:5580/some/path",
|
||||||
|
want: "http://localhost:5580",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no trailing slash",
|
||||||
|
url: "http://localhost:5580",
|
||||||
|
want: "http://localhost:5580",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := originFromURL(tt.url)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreePort(t *testing.T) {
|
||||||
|
port, err := freePort()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Greater(t, port, 0)
|
||||||
|
assert.Less(t, port, 65536)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerServesIndexHTML(t *testing.T) {
|
||||||
|
h := guiHandler()
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Contains(t, string(body), "<div id=\"root\"></div>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerServesStaticAssets(t *testing.T) {
|
||||||
|
h := guiHandler()
|
||||||
|
req := httptest.NewRequest("GET", "/icon.svg", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, strings.Contains(string(body), "<svg"), "expected SVG content")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerSPAFallback(t *testing.T) {
|
||||||
|
h := guiHandler()
|
||||||
|
|
||||||
|
// /login is not a real file — it should fall back to index.html
|
||||||
|
req := httptest.NewRequest("GET", "/login", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Contains(t, string(body), "<div id=\"root\"></div>",
|
||||||
|
"SPA fallback should serve index.html for unknown routes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerSPAFallbackDeepPath(t *testing.T) {
|
||||||
|
h := guiHandler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/some/deep/route", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Contains(t, string(body), "<div id=\"root\"></div>")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user