diff --git a/Makefile b/Makefile index 80ed7e05..1135808e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build build-cli test test-coverage test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps dev-docs dev-docs-tidy hugo +.PHONY: all build build-cli test test-coverage test-browser test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps dev-docs dev-docs-tidy hugo # Load .env if present (simple KEY=VALUE format, no shell quoting) -include .env @@ -147,6 +147,15 @@ test-coverage: $(GOCMD) tool cover -html=coverage.out -o coverage.html @echo "Coverage report generated: coverage.html" +# Browser-level regression tests for the embedded player's static assets +# (see pkg/service/soundtouchweb/browser_compatibility_test.go). Opt-in via +# the "browsertest" build tag, not part of `test`/`check`, since they need a +# Chrome/Chromium binary that chromedp can find on PATH or in a standard +# install location. +test-browser: + @echo "Running browser-level compatibility tests..." + $(GOTEST) -tags browsertest -v ./pkg/service/soundtouchweb/... + check: fmt vet test test-http-client # Archive any existing tests/integration/testdata/ to a timestamped sibling @@ -501,6 +510,7 @@ help: @echo " build-linux-armv7 - Build for Linux ARMv7 (kernel 3.14+ compatible, CGO_ENABLED=0)" @echo " test - Run tests" @echo " test-coverage - Run tests with coverage report" + @echo " test-browser - Run browser-level (chromedp) player compatibility tests" @echo " test-http-client - Run .http integration tests via Docker Compose" @echo " test-http-client-rotate - Archive tests/integration/testdata/ before a fresh run (non-destructive)" @echo " check - Run fmt, vet, and tests" diff --git a/pkg/service/soundtouchweb/browser_compatibility_test.go b/pkg/service/soundtouchweb/browser_compatibility_test.go new file mode 100644 index 00000000..6e336b17 --- /dev/null +++ b/pkg/service/soundtouchweb/browser_compatibility_test.go @@ -0,0 +1,136 @@ +//go:build browsertest + +// Package soundtouchweb browser-level regression tests for #649. These drive +// a real headless Chrome via chromedp (already a project dependency, used +// today for the doc-screenshot tool) instead of only asserting on the raw +// HTML/JS source. They are opt-in (build tag "browsertest", run via `make +// test-browser`) rather than part of the default `go test ./...`/`make +// check` path, since they require a Chrome/Chromium binary to be present -- +// see CONTRIBUTING or the Makefile for how to run them locally or in CI. +package soundtouchweb + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/chromedp/chromedp" + "github.com/go-chi/chi/v5" +) + +// newHeadlessChromeContext returns a context bound to a fresh headless +// Chrome instance, torn down automatically at the end of the test. +func newHeadlessChromeContext(t *testing.T) context.Context { + t.Helper() + + allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), + append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", true), + chromedp.Flag("disable-gpu", true), + // CI runners commonly execute as a user without the namespace + // permissions Chrome's sandbox needs; harmless to also set + // locally. + chromedp.Flag("no-sandbox", true), + )..., + ) + t.Cleanup(cancelAlloc) + + ctx, cancelCtx := chromedp.NewContext(allocCtx) + t.Cleanup(cancelCtx) + + ctx, cancelTimeout := context.WithTimeout(ctx, 30*time.Second) + t.Cleanup(cancelTimeout) + + return ctx +} + +// TestPlayerRendersNatively confirms the shipped page (native import maps, +// es-module-shims left uninjected) still renders in an ordinary modern +// browser -- i.e. that restoring import maps for #649 didn't break the +// common case for the vast majority of users who never need the shim. +func TestPlayerRendersNatively(t *testing.T) { + app := NewWebApp() + r := chi.NewRouter() + app.Mount(r, nil) + + server := httptest.NewServer(r) + t.Cleanup(server.Close) + + ctx := newHeadlessChromeContext(t) + + var shimInjected bool + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/app"), + chromedp.WaitVisible(`.nav-discover-icon`, chromedp.ByQuery), + chromedp.Evaluate(`document.querySelectorAll('script[src*="es-module-shims"]').length > 0`, &shimInjected), + ); err != nil { + t.Fatalf("chromedp run: %v", err) + } + + if shimInjected { + t.Error("es-module-shims should not be injected on a browser with native import map support") + } +} + +// TestPlayerRendersUnderForcedShimMode exercises the actual old-Safari code +// path -- es-module-shims resolving the same import map and vendored files +// the real app uses -- without needing physical iPadOS 15 hardware. It +// serves a variant of index.html that forces es-module-shims into shimMode +// (see the library's README: shimMode is triggered by +// window.esmsInitOptions.shimMode or by using importmap-shim/module-shim +// script types), which routes every browser -- including this ordinary +// headless Chrome -- through the library's own polyfill resolution instead +// of native import map support. +func TestPlayerRendersUnderForcedShimMode(t *testing.T) { + app := NewWebApp() + r := chi.NewRouter() + app.MountWeb(r, nil) // only need /app/static/* and /api/control/* + + const shimModePage = ` + +
+ + + + + + + + + + +` + + r.Get("/test-shim-mode", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(shimModePage)) + }) + + server := httptest.NewServer(r) + t.Cleanup(server.Close) + + ctx := newHeadlessChromeContext(t) + + var rendered bool + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/test-shim-mode"), + chromedp.WaitVisible(`.nav-discover-icon`, chromedp.ByQuery), + chromedp.Evaluate(`document.getElementById('app').children.length > 0`, &rendered), + ); err != nil { + t.Fatalf("chromedp run (forced shim mode): %v", err) + } + + if !rendered { + t.Error("app did not render under forced es-module-shims shim mode") + } +}