//go:build dev package bundler 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.ts") if code != 200 { t.Fatalf("app.ts status %d:\n%s", code, app) } for _, want := range []string{ `__createHotContext("/@src/app.ts")`, // 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.ts`, // relative import rewritten } { if !strings.Contains(app, want) { t.Errorf("app.ts 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) } }