Files
kjol/go/jsbundler/hmr_e2e_test.go
2026-07-15 11:28:24 -04:00

189 lines
6.3 KiB
Go

//go:build dev
package jsbundler
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// A `?url` import must resolve to a shim module whose default export is the raw
// /@fs/ file URL — not the file itself (which would fail with "no default export",
// as the pdfjs worker did and cascaded into a stuck loading spinner).
func TestDevServerAssetURL(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
t.Chdir(root)
frontendAbs, _ := filepath.Abs(frontendDir)
tmpSrc := evalSymlinks(t.TempDir())
os.WriteFile(filepath.Join(tmpSrc, "uses-worker.tsx"),
[]byte(`import u from "pdfjs-dist/build/pdf.worker.min.mjs?url";
export const url = u;`), 0o644)
eps, _ := loadVendorManifest(filepath.Join(frontendAbs, "vendor"))
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}
out, err := d.transformModule(filepath.Join(tmpSrc, "uses-worker.tsx"))
if err != nil {
t.Fatalf("transform: %v", err)
}
const shimURL = "/@url/vendor/pdfjs-dist/build/pdf.worker.min.mjs"
if !strings.Contains(string(out), shimURL) {
t.Fatalf("?url import not rewritten to the shim path %q:\n%s", shimURL, out)
}
req := httptest.NewRequest("GET", shimURL, nil)
w := httptest.NewRecorder()
d.serveAssetURL(w, req)
body := w.Body.String()
const want = `export default "/@fs/vendor/pdfjs-dist/build/pdf.worker.min.mjs"`
if w.Code != 200 || !strings.Contains(body, want) {
t.Fatalf("shim module = %q (status %d), want default export of the /@fs/ URL", body, w.Code)
}
}
// resolveSource must mirror esbuild's resolution: a `.js` specifier for a sibling
// .ts/.tsx, extensionless specifiers, and directory index files.
func TestResolveSourceExtensions(t *testing.T) {
dir := t.TempDir()
write := func(rel string) {
p := filepath.Join(dir, rel)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte("export default 1;"), 0o644)
}
write("Foo.tsx")
write("Bar.ts")
write("baz/index.ts")
write("Real.js")
cases := []struct{ spec, want string }{
{"./Foo.js", "Foo.tsx"}, // .js specifier -> sibling .tsx
{"./Foo.tsx", "Foo.tsx"}, // exact
{"./Bar", "Bar.ts"}, // extensionless
{"./baz", "baz/index.ts"}, // directory index
{"./Real.js", "Real.js"}, // real .js wins
{"./missing.js", ""}, // dangling
}
for _, c := range cases {
got := resolveSource(dir, c.spec)
want := ""
if c.want != "" {
want = filepath.Join(dir, filepath.FromSlash(c.want))
}
if got != want {
t.Errorf("resolveSource(%q) = %q, want %q", c.spec, got, want)
}
}
}
// End-to-end over the dev server's HTTP surface: the SPA entry, a .tsx component,
// a vendor bundle, the client runtime, the import map, and graph-driven update vs.
// full-reload decisions — all without a database or the full server.
func TestDevServerEndToEnd(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
t.Chdir(root)
d, err := newDevServer()
if err != nil {
t.Fatalf("newDevServer: %v", err)
}
get := func(path string) (int, string) {
req := httptest.NewRequest("GET", path, nil)
w := httptest.NewRecorder()
switch {
case strings.HasPrefix(path, srcURLPrefix):
d.serveModule(w, req)
case strings.HasPrefix(path, vendorURLPrefix):
d.serveVendor(w, req)
case path == "/@hmr/client":
d.serveClient(w, req)
}
return w.Code, w.Body.String()
}
// --- SPA entry: hot bootstrap + bare imports kept + relative imports rewritten
code, app := get(srcURLPrefix + "app.tsx")
if code != 200 {
t.Fatalf("app.tsx status %d:\n%s", code, app)
}
for _, want := range []string{
`__createHotContext("/@src/app.tsx")`, // hot bootstrap
`/@hmr/client`, // client import injected
`"solid-js/web"`, // bare specifier preserved for import map
`"@solidjs/router"`, // bare specifier preserved
`/@src/routes/app-routes.ts`, // relative import rewritten
`/@src/layouts/AppLayout.tsx`, // relative import rewritten
} {
if !strings.Contains(app, want) {
t.Errorf("app.tsx missing %q", want)
}
}
// --- a .tsx component: Solid + solid-refresh instrumentation
code, alerts := get(srcURLPrefix + "ui/Alerts.tsx")
if code != 200 {
t.Fatalf("Alerts.tsx status %d:\n%s", code, alerts)
}
for _, want := range []string{
`__createHotContext("/@src/ui/Alerts.tsx")`,
`solid-refresh`, // runtime import
"import.meta.hot", // esm HMR accept
} {
if !strings.Contains(alerts, want) {
t.Errorf("Alerts.tsx missing %q", want)
}
}
// --- vendor: solid-js/web must import "solid-js" externally (single instance)
code, web := get(vendorURLPrefix + "solid-js/web.js")
if code != 200 {
t.Fatalf("vendor solid-js/web status %d", code)
}
if !strings.Contains(web, `"solid-js"`) {
t.Errorf("solid-js/web should keep a bare `solid-js` import (shared instance)")
}
// --- client runtime is an ES module exporting createHotContext
code, client := get("/@hmr/client")
if code != 200 || !strings.Contains(client, "export function createHotContext") {
t.Errorf("client runtime missing createHotContext (status %d)", code)
}
// --- import map maps the key vendored specifiers to /@vendor/ URLs
var im struct {
Imports map[string]string `json:"imports"`
}
if err := json.Unmarshal([]byte(d.importMapJSON), &im); err != nil {
t.Fatalf("import map JSON: %v", err)
}
for _, spec := range []string{"solid-js", "solid-js/web", "@solidjs/router", "solid-refresh"} {
if got := im.Imports[spec]; got != vendorURLPrefix+spec+".js" {
t.Errorf("import map[%q] = %q, want %q", spec, got, vendorURLPrefix+spec+".js")
}
}
// --- graph: editing a routes table (non-boundary, reachable from the entry)
// forces a full reload; editing a .tsx component is a hot update.
routes := filepath.Join(d.srcRoot, "routes", "app-routes.ts")
if _, _, reload := d.graph.invalidate(routes); !reload {
t.Errorf("changing app-routes.ts should force a full reload")
}
alertsPath := filepath.Join(d.srcRoot, "ui", "Alerts.tsx")
if boundaries, _, reload := d.graph.invalidate(alertsPath); reload || len(boundaries) == 0 {
t.Errorf("changing Alerts.tsx should be a hot update, got reload=%v boundaries=%v", reload, boundaries)
}
}