test(player): add browser-level compatibility tests via chromedp

Adds two opt-in tests (build tag "browsertest", run via `make
test-browser`) that drive a real headless Chrome instead of only
asserting on the raw HTML/JS source:

- TestPlayerRendersNatively confirms the shipped page still renders
  normally and never injects es-module-shims on a browser with native
  import map support.
- TestPlayerRendersUnderForcedShimMode forces es-module-shims into its
  own shimMode (importmap-shim/module-shim, per the library's docs),
  routing the real app.js and vendored dependencies through the
  library's actual polyfill resolution. This exercises the old-Safari
  code path directly in CI/local headless Chrome, without needing
  physical iPadOS 15 hardware.

Not wired into `test`/`check`/CI yet, since chromedp has not
previously been exercised as part of this repo's test suite (only in
the standalone doc-screenshot tool) and needs a Chrome/Chromium binary
available to the runner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-08-29 17:09:36 +02:00
co-authored by Claude Sonnet 5
parent 966d9962a5
commit efcb96998f
2 changed files with 147 additions and 1 deletions
+11 -1
View File
@@ -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"
@@ -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 = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script>window.esmsInitOptions = { shimMode: true };</script>
<script src="/app/static/lib/es-module-shims.js"></script>
<script type="importmap-shim">
{
"imports": {
"preact": "/app/static/lib/preact.module.js",
"preact/hooks": "/app/static/lib/preact-hooks.module.js",
"htm": "/app/static/lib/htm.module.js"
}
}
</script>
<link rel="stylesheet" href="/app/static/css/app.css" />
</head>
<body>
<div id="app"></div>
<script type="module-shim" src="/app/static/js/app.js"></script>
</body>
</html>`
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")
}
}