Files
kjol/bundler/hmr_browser_test.go

202 lines
6.5 KiB
Go

//go:build dev
package bundler
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
// chromePath returns a headless-capable Chrome binary, or "" if none is found.
func chromePath() string {
candidates := []string{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
}
for _, name := range []string{"google-chrome", "chromium", "chromium-browser", "google-chrome-stable"} {
if p, err := exec.LookPath(name); err == nil {
candidates = append(candidates, p)
}
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
return ""
}
// TestBrowserNativeESMRenders proves the whole native-ESM path works in a real
// browser: the import map resolves solid-js to one vendored instance, the module
// server transforms + serves the entry and a .tsx component (solid-refresh
// compiled), and Solid mounts it reactively. The page reports its rendered text
// back to the test via a beacon (robust against headless Chrome's one-shot exit
// quirks). It does NOT assert the interactive hot-swap — that needs a persistent
// CDP session and is the final manual check — but removes the largest
// browser-integration risk.
func TestBrowserNativeESMRenders(t *testing.T) {
chrome := chromePath()
if chrome == "" {
t.Skip("no headless Chrome/Chromium available")
}
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
t.Skip("browser smoke test only wired for macOS/Linux")
}
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
t.Chdir(root)
frontendAbs, _ := filepath.Abs(frontendDir)
// Fixtures in a temp src root so the real source tree (and Tailwind scan /
// route gen) is untouched. The component uses JSX (→ solid-refresh) and a
// signal (→ real reactivity); the entry imports it via a `.js` specifier
// (→ .tsx resolution), then beacons back the rendered text.
tmpSrc := evalSymlinks(t.TempDir())
writeFile(t, filepath.Join(tmpSrc, "Widget.tsx"),
`import { createSignal } from "solid-js";
export default function Widget() {
const [msg] = createSignal("hello-hmr-rendered");
return <div id="w">{msg()}</div>;
}
`)
writeFile(t, filepath.Join(tmpSrc, "entry.tsx"),
`import { render } from "solid-js/web";
import Widget from "./Widget.js";
render(() => <Widget/>, document.getElementById("app"));
setTimeout(() => {
const el = document.getElementById("w");
fetch("/__result?text=" + encodeURIComponent(el ? el.textContent : "EMPTY"));
}, 0);
`)
eps, err := loadVendorManifest(filepath.Join(frontendAbs, "vendor"))
if err != nil {
t.Fatalf("vendor manifest: %v", err)
}
d := &devServer{
hub: newHub(),
frontend: frontendAbs,
srcRoot: tmpSrc,
vendor: filepath.Join(frontendAbs, "vendor"),
graph: newModuleGraph(),
vendorCache: map[string][]byte{},
cssTrigger: make(chan struct{}, 1),
vendorEntrypoints: eps,
}
d.importMapJSON = d.buildImportMap()
rendered := make(chan string, 1)
var diagMu sync.Mutex
var diag []string
mux := http.NewServeMux()
d.register(mux)
mux.HandleFunc("/__result", func(w http.ResponseWriter, r *http.Request) {
select {
case rendered <- r.URL.Query().Get("text"):
default:
}
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/__diag", func(w http.ResponseWriter, r *http.Request) {
diagMu.Lock()
diag = append(diag, r.URL.Query().Get("text"))
diagMu.Unlock()
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html><html><head>
<script>
function beacon(p,t){fetch(p+'?text='+encodeURIComponent(t));}
window.addEventListener('error', function(e){beacon('/__diag','ERR:'+((e.error&&e.error.stack)||e.message));});
window.addEventListener('unhandledrejection', function(e){beacon('/__diag','REJ:'+((e.reason&&e.reason.stack)||String(e.reason)));});
window.addEventListener('DOMContentLoaded', function(){beacon('/__diag','DOMCONTENTLOADED');});
</script>
<script type="importmap">%s</script>
</head><body><div id="app"></div>
<script type="module" src="/@src/entry.tsx"></script>
</body></html>`, d.importMapJSON)
})
srv := httptest.NewServer(mux)
defer srv.Close()
// Sanity-check that every module the page needs serves (200) before the browser.
for _, m := range []string{"/@src/entry.tsx", "/@src/Widget.tsx", "/@hmr/client",
vendorURLPrefix + "solid-js.js", vendorURLPrefix + "solid-js/web.js"} {
resp, err := http.Get(srv.URL + m)
if err != nil {
t.Fatalf("GET %s: %v", m, err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("GET %s -> %d", m, resp.StatusCode)
}
}
// Launch headless Chrome to load the page; wait for the render beacon rather
// than for Chrome to exit (new headless doesn't reliably one-shot-exit here).
// Use a best-effort temp profile dir (not t.TempDir): Chrome's detached helper
// processes may still be writing to it at cleanup time, and t.TempDir's strict
// RemoveAll would then fail the test.
userDir, _ := os.MkdirTemp("", "hmr-chrome-*")
defer os.RemoveAll(userDir)
devNull, _ := os.Open(os.DevNull)
defer devNull.Close()
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, chrome,
"--headless=new", "--disable-gpu", "--no-sandbox", "--no-first-run",
"--user-data-dir="+userDir,
"--disable-background-networking", "--disable-component-update",
"--disable-default-apps", "--disable-sync", "--no-default-browser-check",
srv.URL,
)
cmd.Stdout = devNull
cmd.Stderr = devNull
if err := cmd.Start(); err != nil {
cancel()
t.Fatalf("start chrome: %v", err)
}
defer func() { cancel(); cmd.Wait() }()
select {
case text := <-rendered:
if text != "hello-hmr-rendered" {
t.Fatalf("browser rendered %q, want %q", text, "hello-hmr-rendered")
}
t.Log("native-ESM app rendered in headless Chrome (import map + single solid-js + solid-refresh component)")
case <-time.After(25 * time.Second):
diagMu.Lock()
msgs := strings.Join(diag, "\n ")
diagMu.Unlock()
t.Fatalf("timed out waiting for the browser render beacon.\nbrowser diagnostics:\n %s", msgs)
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}