202 lines
6.5 KiB
Go
202 lines
6.5 KiB
Go
//go:build dev
|
|
|
|
package jsbundler
|
|
|
|
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([]string{filepath.Join(frontendAbs, "vendor")})
|
|
if err != nil {
|
|
t.Fatalf("vendor manifest: %v", err)
|
|
}
|
|
d := &devServer{
|
|
hub: newHub(),
|
|
frontend: frontendAbs,
|
|
srcRoot: tmpSrc,
|
|
vendorDirs: []string{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)
|
|
}
|
|
}
|