diff --git a/AGENTS.md b/AGENTS.md index 1d2b08b..a36e0a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,3 +79,4 @@ func TestFunction(t *testing.T) { - `tests/feature/vite_plugin_test.go` runs the published `goravel-vite-plugin` against a real `vite build` and `npm run dev`. It runs `npm ci` itself when `node_modules/goravel-vite-plugin` is not installed, so the first run needs the network. - The suite builds into `public/build` and owns `public/hot` while it runs; it removes the build and puts back the hot file a dev server had written. +- `vite.build_directory` and `vite.hot_file` in `config/vite.go` are paths relative to `public` (`build`, `hot`); a fixture hot file is a path under `public` too, never absolute. diff --git a/config/inertia.go b/config/inertia.go index a7c7ad6..54b8260 100644 --- a/config/inertia.go +++ b/config/inertia.go @@ -13,11 +13,5 @@ func init() { "ssr_url": config.Env("INERTIA_SSR_URL", "http://127.0.0.1:13714/render"), "ssr_timeout": config.Env("INERTIA_SSR_TIMEOUT", 5), "flash_keys": []string{"success", "error", "warning", "info", "message"}, - "vite": map[string]any{ - "public_path": "public", - "build_dir": "build", - "hot_file": "public/hot", - "dev_url": config.Env("VITE_DEV_URL", ""), - }, }) } diff --git a/config/vite.go b/config/vite.go index 1b9bd43..8718deb 100644 --- a/config/vite.go +++ b/config/vite.go @@ -14,13 +14,22 @@ func init() { // Hot File // - // The file the dev server announces its URL in, relative to the project - // root. While it exists, {{ vite }} points every tag at the dev server. - "hot_file": "public/hot", + // The file the dev server announces its URL in, inside the public + // directory. While it exists, {{ vite }} points every tag at the dev server. + "hot_file": "hot", // Manifest // // The manifest `vite build` writes, relative to the build directory. "manifest": ".vite/manifest.json", + + // Inputs and Assets + // + // Entries and assets `vite build` must include even when no view names + // them; vite:inputs merges these with the view calls, once each and + // sorted. resources/views/vite.tmpl already names both, so the build is + // unchanged. + "inputs": []string{"resources/inertia/app.ts"}, + "assets": []string{"resources/images/logo.svg"}, }) } diff --git a/go.mod b/go.mod index 2a8d6d4..c8c746c 100644 --- a/go.mod +++ b/go.mod @@ -8,10 +8,10 @@ require ( github.com/goravel/cos v1.18.0 github.com/goravel/example-proto v0.0.1 github.com/goravel/fiber v1.18.1-0.20261030085228-66c98dc71f1d - github.com/goravel/framework v1.18.1-0.20261109081827-a1fd1ea59809 + github.com/goravel/framework v1.18.1-0.20261110120158-82c6a43353b4 github.com/goravel/gemini v1.18.0 github.com/goravel/gin v1.18.1-0.20261030085342-4d9429b10cb3 - github.com/goravel/inertia v1.18.1-0.20260824090129-ac3d472d028c + github.com/goravel/inertia v1.18.1-0.20261110120219-2a36f31baeaa github.com/goravel/minio v1.18.0 github.com/goravel/mysql v1.18.0 github.com/goravel/openai v1.18.0 @@ -34,7 +34,7 @@ require ( go.opentelemetry.io/otel v1.47.0 go.opentelemetry.io/otel/metric v1.47.0 go.opentelemetry.io/otel/trace v1.47.0 - google.golang.org/grpc v1.86.0-dev.0.20261029074820-bc90736a6fad + google.golang.org/grpc v1.86.0-dev.0.20261104142343-cbf30c413099 google.golang.org/protobuf v1.36.12 ) @@ -242,7 +242,7 @@ require ( go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/arch v0.29.0 // indirect golang.org/x/crypto v0.58.0 // indirect - golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect + golang.org/x/exp v0.0.0-20261106192419-47ce03d25ef0 // indirect golang.org/x/mod v0.42.0 // indirect golang.org/x/net v0.61.0 // indirect golang.org/x/sync v0.24.0 // indirect @@ -250,7 +250,7 @@ require ( golang.org/x/term v0.47.0 // indirect golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.52.0 // indirect + golang.org/x/tools v0.52.1-0.20261106210606-4f7264d11672 // indirect google.golang.org/api v0.293.0 // indirect google.golang.org/genai v1.58.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect @@ -264,6 +264,4 @@ require ( gorm.io/plugin/dbresolver v1.6.2 // indirect ) -// require and replace carry the same pin; replace stops the older framework -// versions gin and fiber ask for from downgrading the code compiled here. -replace github.com/goravel/framework => github.com/goravel/framework v1.18.1-0.20261109081827-a1fd1ea59809 +replace github.com/goravel/framework => github.com/goravel/framework v1.18.1-0.20261110120158-82c6a43353b4 diff --git a/go.sum b/go.sum index d0809e5..71d3c0a 100644 --- a/go.sum +++ b/go.sum @@ -304,14 +304,14 @@ github.com/goravel/example-proto v0.0.1 h1:ZxETeKREQWjuJ49bX/Hqj1NLR5Vyj489Ks6dR github.com/goravel/example-proto v0.0.1/go.mod h1:I8IPsHr4Ndf7KxmdsRpBR2LQ0Geo48+pjv9IIWf3mZg= github.com/goravel/fiber v1.18.1-0.20261030085228-66c98dc71f1d h1:KmI8hhOJ5ZNOsoglzZN1PCYg4czQ4wQlBz5P6aPS+yE= github.com/goravel/fiber v1.18.1-0.20261030085228-66c98dc71f1d/go.mod h1:F8p0N9fzqenUyvmsDxusXB+9Nsnf0b8IbrwOcB3jM9w= -github.com/goravel/framework v1.18.1-0.20261109081827-a1fd1ea59809 h1:jirPRsHUjIXdPUc3/x1qmXK8cFoks/HpezPw5SmaGn0= -github.com/goravel/framework v1.18.1-0.20261109081827-a1fd1ea59809/go.mod h1:7E2bcYn69ziWfb806H1wWaOksFEhE13uFEDaPwdlVkE= +github.com/goravel/framework v1.18.1-0.20261110120158-82c6a43353b4 h1:7Kfs09Igjz/SDNMUmth5zeRIGLRNPPiKg8j+BOj+VTw= +github.com/goravel/framework v1.18.1-0.20261110120158-82c6a43353b4/go.mod h1:YhcnpFwkr/7mIsFnRX9E0xicGVVIPHuzrSrxgWCuVJc= github.com/goravel/gemini v1.18.0 h1:Tv6IJ3KvBvH5QD7kAM3XFQR0g8IUfkqMbRGi7dtlWUo= github.com/goravel/gemini v1.18.0/go.mod h1:b4FueB0O0Qkz71eVWLiSaKghp+Eb/1hNNICkk7vMgrQ= github.com/goravel/gin v1.18.1-0.20261030085342-4d9429b10cb3 h1:SUF7aInB7u3uAyM2RlI5/XD+HN6mNZzethRyOu/P3Ms= github.com/goravel/gin v1.18.1-0.20261030085342-4d9429b10cb3/go.mod h1:w8slkJMP2vl2zikL05Sj/OsPGYZ8RQ1mU5AzjwCXfTQ= -github.com/goravel/inertia v1.18.1-0.20260824090129-ac3d472d028c h1:/tQABlAVdsM2q9Ts/B2lsfEzQ7YQvsMqDR+sDNi7OjU= -github.com/goravel/inertia v1.18.1-0.20260824090129-ac3d472d028c/go.mod h1:JZ+9yNuVsLiNBdOeaby/hdaRd3svsZ9GeX3llrosNGI= +github.com/goravel/inertia v1.18.1-0.20261110120219-2a36f31baeaa h1:XPkcf9qE88f/nVlFEJjwYorNfH6IQgVkYbgxPebMWS0= +github.com/goravel/inertia v1.18.1-0.20261110120219-2a36f31baeaa/go.mod h1:nYYyprmJHW92S1tbiUhPs/oqlPxUrHuNAyp+JSHVgwk= github.com/goravel/minio v1.18.0 h1:+FSTAZWVFCngLKF+apo5Rglbo8Jt499LJmJiYC1QlFI= github.com/goravel/minio v1.18.0/go.mod h1:rOkeHL0fbL9jn9+Qy5OcX5y9VbQ+BykpfOsd5kJbOns= github.com/goravel/mysql v1.18.0 h1:258TNNloYhJo74wSv11n7o3xmBl+Z3NaUXIs4jPEaHs= @@ -674,8 +674,8 @@ golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= -golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= +golang.org/x/exp v0.0.0-20261106192419-47ce03d25ef0 h1:1GO4fJiyGakz0Liu2Ve1qPAwQYPkKlG1Q8iO2CYjtFA= +golang.org/x/exp v0.0.0-20261106192419-47ce03d25ef0/go.mod h1:J3U+E8RzytcGvwz+uJuAP4zULJ2TkAQBnmPZMexpdXs= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -785,8 +785,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= -golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= +golang.org/x/tools v0.52.1-0.20261106210606-4f7264d11672 h1:VfrTmiWA9+TdDrL9VQr7aeGonnRLaEBwN3/D7jzL25Y= +golang.org/x/tools v0.52.1-0.20261106210606-4f7264d11672/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -806,8 +806,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1: google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= -google.golang.org/grpc v1.86.0-dev.0.20261029074820-bc90736a6fad h1:UYg8dzXN2/IA54SnxVYwWjd5VQm3PFP3xn4aBz+RDJ8= -google.golang.org/grpc v1.86.0-dev.0.20261029074820-bc90736a6fad/go.mod h1:Zr6uoNHvZlKOMmDEULT/cdZrN8qcSkXRXPkw4ybjoxI= +google.golang.org/grpc v1.86.0-dev.0.20261104142343-cbf30c413099 h1:GMGAZI0ozNRMHMV+etj3fihG3abcnkIkZl12Ut8vjH4= +google.golang.org/grpc v1.86.0-dev.0.20261104142343-cbf30c413099/go.mod h1:Zr6uoNHvZlKOMmDEULT/cdZrN8qcSkXRXPkw4ybjoxI= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/tests/feature/vite_plugin_test.go b/tests/feature/vite_plugin_test.go index a6cc811..4878a80 100644 --- a/tests/feature/vite_plugin_test.go +++ b/tests/feature/vite_plugin_test.go @@ -1,10 +1,13 @@ package feature import ( + "crypto/md5" + "encoding/hex" "encoding/json" "net" "os" "os/exec" + "path/filepath" "runtime" "strconv" "strings" @@ -109,6 +112,17 @@ func (s *VitePluginTestSuite) readManifest() map[string]viteManifestChunk { return manifest } +// manifestSum is the md5 of the manifest a build wrote: the version an app pins +// on the files it serves itself, so a new build invalidates them together. +func (s *VitePluginTestSuite) manifestSum() string { + content, err := os.ReadFile(path.Public(facades.Vite().BuildDirectory(), facades.Vite().ManifestFilename())) + s.Require().NoError(err) + + sum := md5.Sum(content) + + return hex.EncodeToString(sum[:]) +} + // TestBuild_ProducesTheManifestTheFrameworkReads runs the build an author runs // and then resolves the same entry and asset through the framework: the entries // came from the views, the files are where config/vite.go points, and the file a @@ -153,6 +167,9 @@ func (s *VitePluginTestSuite) TestBuild_ProducesTheManifestTheFrameworkReads() { s.Require().NoError(err) s.Contains(string(tags), ``) + // The version the app serves matches the manifest this build wrote. + s.Equal(s.manifestSum(), facades.Vite().ManifestHash()) + url, err := facades.Vite().Asset("resources/images/logo.svg") s.Require().NoError(err) s.Equal("/"+buildDirectory+"/"+logo.File, url) @@ -214,6 +231,13 @@ func (s *VitePluginTestSuite) TestDevServer_AnnouncesTheHotFile() { s.Regexp(`^http://(localhost|127\.0\.0\.1):`+port+`$`, url) s.True(facades.Vite().IsRunningHot()) + // The dev server serves unhashed files, so a manifest the last build left + // gives no version while it runs. TearDownTest removes it with the build. + manifestPath := path.Public(facades.Vite().BuildDirectory(), facades.Vite().ManifestFilename()) + s.Require().NoError(os.MkdirAll(filepath.Dir(manifestPath), 0o755)) + s.Require().NoError(os.WriteFile(manifestPath, []byte("{}"), 0o644)) + s.Empty(facades.Vite().ManifestHash()) + tags, err := facades.Vite().Tags("resources/inertia/app.ts") s.Require().NoError(err) got := string(tags) diff --git a/tests/feature/vite_test.go b/tests/feature/vite_test.go index fd9baeb..7c25e75 100644 --- a/tests/feature/vite_test.go +++ b/tests/feature/vite_test.go @@ -1,12 +1,15 @@ package feature import ( + "crypto/md5" + "encoding/hex" "encoding/json" "html/template" "os" "path/filepath" "strings" "testing" + "time" frameworkerrors "github.com/goravel/framework/errors" "github.com/goravel/framework/support/path" @@ -21,6 +24,11 @@ import ( // gitignored whole, so they never reach the checked-in tree. const fixtureBuildDirectory = "build/feature-test" +// fixtureHotFile keeps the hot file inside that same directory: it is under +// public, so the framework resolves it, and TearDownTest removes it with the +// rest of the fixture. +const fixtureHotFile = fixtureBuildDirectory + "/hot" + // viteTestManifest mirrors what `vite build` writes for this app: the Inertia // entry with its own CSS, a static CSS entry, a file only a view names through // viteAsset, and a vendor chunk the entry imports which itself imports a shared @@ -38,7 +46,8 @@ const viteTestManifest = `{ type ViteTestSuite struct { suite.Suite tests.TestCase - // What the singleton held before any case pointed it at fixtures. + // The build directory the singleton booted with, and the hot file config + // declares, both leaves relative to public. originalBuildDirectory string originalHotFile string } @@ -50,11 +59,12 @@ func TestViteTestSuite(t *testing.T) { // SetupSuite snapshots the boot-time config before any case can change or // restore it, so a broken restore fails here instead of hiding until later. func (s *ViteTestSuite) SetupSuite() { + // config/vite.go holds leaves; the instance resolves both inside public. s.originalBuildDirectory = facades.Vite().BuildDirectory() - s.originalHotFile = facades.Vite().HotFile() + s.originalHotFile = facades.Config().GetString("vite.hot_file", goravelvite.DefaultHotFile()) s.Equal("build", s.originalBuildDirectory) - s.Equal(path.Base("public", "hot"), s.originalHotFile) + s.Equal(path.Public(s.originalHotFile), facades.Vite().HotFile()) } func (s *ViteTestSuite) SetupTest() { @@ -76,15 +86,20 @@ func (s *ViteTestSuite) fixturePath(parts ...string) string { } // useFixtureVite points the shared Vite at the fixture build directory and at a -// hot file in a per-test temporary directory. The Use* setters are not on the -// contract on purpose (the instance is configured from config/vite.go), so the -// concrete type is asserted first. +// hot file inside it. Both are leaves relative to public, as config/vite.go +// declares them. The Use* setters are not on the contract on purpose (the +// instance is configured from config/vite.go), so the concrete type is asserted +// first. func (s *ViteTestSuite) useFixtureVite() *goravelvite.Vite { vite, ok := facades.Vite().(*goravelvite.Vite) s.Require().True(ok, "the container should bind the framework *vite.Vite") - hotFile := filepath.Join(s.T().TempDir(), "hot") - vite.UseBuildDirectory(fixtureBuildDirectory).UseHotFile(hotFile) + vite.UseBuildDirectory(fixtureBuildDirectory).UseHotFile(fixtureHotFile) + + // Every case starts from an empty build, so a manifest or a hot file one case + // wrote cannot decide what the next one sees. + s.Require().NoError(os.RemoveAll(s.fixturePath(".vite"))) + s.Require().NoError(os.RemoveAll(vite.HotFile())) return vite } @@ -180,14 +195,58 @@ func (s *ViteTestSuite) TestInputsCommand() { s.Require().NoError(err) // Field by field, so a vite call added to another view does not read as a - // broken command. + // broken command. The config seeds the same two paths, so the dedup is what + // keeps each list at one entry. s.Contains(output, `"inputs":["resources/inertia/app.ts"]`) s.Contains(output, `"assets":["resources/images/logo.svg"]`) s.Contains(output, `"build_directory":"build"`) + s.Contains(output, `"dynamic":[]`) +} + +// TestInputsCommand_SeededEntries pins the entries config declares: vite.inputs +// and vite.assets are merged with the view calls, then deduplicated and sorted, +// so an entry named in both places is built once. +func (s *ViteTestSuite) TestInputsCommand_SeededEntries() { + scope, err := tests.OverrideConfig(map[string]any{ + "vite.inputs": []string{"resources/js/admin.ts", "resources/inertia/app.ts"}, + "vite.assets": []string{"resources/images/logo.svg", "resources/images/favicon.svg"}, + }) + s.Require().NoError(err) + defer func() { s.Require().NoError(scope.Restore()) }() + + output, err := s.CaptureArtisanOutput("vite:inputs") + s.Require().NoError(err) + + // Merged with the view calls (app.ts, logo.svg), once each, in sorted order. + s.Contains(output, `"inputs":["resources/inertia/app.ts","resources/js/admin.ts"]`) + s.Contains(output, `"assets":["resources/images/favicon.svg","resources/images/logo.svg"]`) + + // The command sorts its own copy; the value config holds keeps its order. + s.Equal( + []string{"resources/js/admin.ts", "resources/inertia/app.ts"}, + facades.Config().GetStringSlice("vite.inputs", []string{}), + ) +} + +// TestInputsCommand_ViewCallsOnly clears the config seeds, so the entries the +// command reports can only come from the {{ vite }} and {{ viteAsset }} calls in +// the views. +func (s *ViteTestSuite) TestInputsCommand_ViewCallsOnly() { + scope, err := tests.OverrideConfig(map[string]any{"vite.inputs": []string{}, "vite.assets": []string{}}) + s.Require().NoError(err) + defer func() { s.Require().NoError(scope.Restore()) }() + + output, err := s.CaptureArtisanOutput("vite:inputs") + s.Require().NoError(err) + + s.Contains(output, `"inputs":["resources/inertia/app.ts"]`) + s.Contains(output, `"assets":["resources/images/logo.svg"]`) } // TestInputs_Paths pins the handshake goravel-vite-plugin reads: it builds into -// the paths reported here, so they are the literals config/vite.go sets. +// the paths reported here. The build directory is the leaf config/vite.go sets; +// the hot file is reported relative to the project root, so "hot" inside public +// still reads "public/hot". func (s *ViteTestSuite) TestInputs_Paths() { output, err := s.CaptureArtisanOutput("vite:inputs") s.Require().NoError(err) @@ -210,12 +269,99 @@ func (s *ViteTestSuite) TestInputs_Paths() { s.Equal("public", inputs.Paths.PublicDirectory) s.Equal("build", inputs.Paths.BuildDirectory) s.Equal(".vite/manifest.json", inputs.Paths.Manifest) + // config/vite.go says "hot", inside public; the command reports the hot file + // relative to the project root, so the output is machine-independent. s.Equal("public/hot", inputs.Paths.HotFile) } +// TestManifestHash pins the version an app puts on the files it serves itself: +// the md5 of the build manifest, and "" whenever there is nothing trustworthy to +// hash — no manifest, a broken one, or a dev server that serves unhashed files. +func (s *ViteTestSuite) TestManifestHash() { + sum := md5.Sum([]byte(viteTestManifest)) + + tests := []struct { + name string + setup func(vite *goravelvite.Vite) + expected string + }{ + { + name: "should hash the manifest a build wrote", + setup: func(_ *goravelvite.Vite) { + s.writeManifest(viteTestManifest) + }, + expected: hex.EncodeToString(sum[:]), + }, + { + name: "should be empty before the first build", + setup: func(_ *goravelvite.Vite) {}, + expected: "", + }, + { + name: "should be empty while the dev server runs", + setup: func(vite *goravelvite.Vite) { + s.writeManifest(viteTestManifest) + s.writeFile(vite.HotFile(), "http://localhost:5173\n") + }, + expected: "", + }, + { + name: "should be empty for a manifest that does not parse", + setup: func(_ *goravelvite.Vite) { + s.writeManifest("{not json") + }, + expected: "", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + vite := s.useFixtureVite() + tt.setup(vite) + + s.Equal(tt.expected, vite.ManifestHash()) + }) + } +} + +// TestManifestHash_MovesWithTags pins that the version and the tags come from +// one cache: a rebuild that keeps the file size still moves both, because the +// mtime is what invalidates it. +func (s *ViteTestSuite) TestManifestHash_MovesWithTags() { + vite := s.useFixtureVite() + s.writeManifest(viteTestManifest) + + first := vite.ManifestHash() + s.NotEmpty(first) + + _, err := vite.Tags("resources/inertia/app.ts") + s.Require().NoError(err) + + // The render reads the manifest through the same cache, so the hash still + // covers the bytes on disk. + written, err := os.ReadFile(s.fixturePath(".vite", "manifest.json")) + s.Require().NoError(err) + hashed := md5.Sum(written) + s.Equal(hex.EncodeToString(hashed[:]), vite.ManifestHash(), "a render must not change the hash") + + // Same length, new file name: only the mtime tells the two builds apart. + rebuilt := strings.Replace(viteTestManifest, "app-4ed993c7.js", "app-44ecf5d8.js", 1) + s.writeManifest(rebuilt) + future := time.Now().Add(time.Second) + s.Require().NoError(os.Chtimes(s.fixturePath(".vite", "manifest.json"), future, future)) + + sum := md5.Sum([]byte(rebuilt)) + second := vite.ManifestHash() + s.Equal(hex.EncodeToString(sum[:]), second) + s.NotEqual(first, second) + + tags, err := vite.Tags("resources/inertia/app.ts") + s.Require().NoError(err) + s.Contains(string(tags), "app-44ecf5d8.js", "the tags must come from the bytes the hash covers") +} + // TestErrors covers the three failures in the order a build goes wrong: no // manifest yet, a manifest that does not parse, and an entry the manifest lacks. -// Each case builds on the file the previous one wrote. func (s *ViteTestSuite) TestErrors() { tests := []struct { name string