diff --git a/cmd/hiveview/assets/index.html b/cmd/hiveview/assets/index.html index 3092f4f7bf..5fb444d9e7 100644 --- a/cmd/hiveview/assets/index.html +++ b/cmd/hiveview/assets/index.html @@ -9,7 +9,7 @@ - +
diff --git a/cmd/hiveview/assets/lib/app-index.js b/cmd/hiveview/assets/lib/app-index.js index e89297bd43..797cc02588 100644 --- a/cmd/hiveview/assets/lib/app-index.js +++ b/cmd/hiveview/assets/lib/app-index.js @@ -1,11 +1,20 @@ +import 'datatables.net' +import 'datatables.net-bs5' +import 'datatables.net-responsive' +import 'datatables.net-responsive-bs5' import { $ } from 'jquery' -import { html, format, nav } from './utils.js' + +import { html, format } from './utils.js' import * as routes from './routes.js' +import * as common from './common.js' + +$(document).ready(function () { + common.updateHeader(); -export default function navigate() { $('#loading').show(); console.log("Loading file list..."); $.ajax("listing.jsonl", { + cache: false, success: function(data) { $('#page-text').show(); showFileListing(data); @@ -17,18 +26,7 @@ export default function navigate() { $('#loading').hide(); }, }); -} - -function resultStats(fails, success, total) { - f = parseInt(fails), s = parseInt(success); - t = parseInt(total); - f = isNaN(f) ? "?" : f; - s = isNaN(s) ? "?" : s; - t = isNaN(t) ? "?" : t; - return '' + f + - ' : ' + s + - '  / ' + t + ''; -} +}) function linkToSuite(suiteID, suiteName, linkText) { let url = routes.suite(suiteID, suiteName); diff --git a/cmd/hiveview/assets/lib/app-suite.js b/cmd/hiveview/assets/lib/app-suite.js index 5ad9eee613..47fa769e30 100644 --- a/cmd/hiveview/assets/lib/app-suite.js +++ b/cmd/hiveview/assets/lib/app-suite.js @@ -1,9 +1,16 @@ import 'datatables.net' +import 'datatables.net-bs5' +import 'datatables.net-responsive' +import 'datatables.net-responsive-bs5' import { $ } from 'jquery' + import { html, nav, format, loader } from './utils.js' import * as routes from './routes.js' +import * as common from './common.js' + +$(document).ready(function () { + common.updateHeader(); -export default function navigate() { let name = nav.load("suitename"); if (name) { showSuiteName(name); @@ -34,7 +41,7 @@ export default function navigate() { showError("error fetching " + filename + " : " + error); }, }); -} +}) // showSuiteName displays the suite title. function showSuiteName(name) { diff --git a/cmd/hiveview/assets/lib/app-viewer.js b/cmd/hiveview/assets/lib/app-viewer.js index 9b762b22bb..5b04c624ed 100644 --- a/cmd/hiveview/assets/lib/app-viewer.js +++ b/cmd/hiveview/assets/lib/app-viewer.js @@ -1,8 +1,11 @@ import { $ } from 'jquery' import { html, nav, format, loader } from './utils.js' import * as routes from './routes.js' +import * as common from './common.js' + +$(document).ready(function () { + common.updateHeader(); -export default function navigate() { // Check for line number in hash. var line = null; if (window.location.hash.substr(1, 1) == "L") { @@ -39,7 +42,7 @@ export default function navigate() { // Show default text because nothing was loaded. showText(document.getElementById("exampletext").innerHTML); -} +}) // setHL sets the highlight on a line number. function setHL(num, scroll) { diff --git a/cmd/hiveview/assets/lib/app.css b/cmd/hiveview/assets/lib/app.css index bac8489285..7b33906d8e 100644 --- a/cmd/hiveview/assets/lib/app.css +++ b/cmd/hiveview/assets/lib/app.css @@ -45,7 +45,7 @@ main { } td.test-name-column { - background: url('/images/details_open.svg') no-repeat left 4px; + background: url('../images/details_open.svg') no-repeat left 4px; background-size: 32px; cursor: pointer; padding-left: 32px !important; @@ -61,15 +61,15 @@ td.test-name-column { } tr.failed td.test-name-column { - background-image: url('/images/details_open_err.svg'); + background-image: url('../images/details_open_err.svg'); } tr.shown td.test-name-column { - background-image: url('/images/details_close.svg'); + background-image: url('../images/details_close.svg'); } tr.shown.failed td.test-name-column { - background-image: url('/images/details_close_err.svg'); + background-image: url('../images/details_close_err.svg'); } td.ellipsis { diff --git a/cmd/hiveview/assets/lib/app.js b/cmd/hiveview/assets/lib/common.js similarity index 66% rename from cmd/hiveview/assets/lib/app.js rename to cmd/hiveview/assets/lib/common.js index 5e3b941558..df63d3de67 100644 --- a/cmd/hiveview/assets/lib/app.js +++ b/cmd/hiveview/assets/lib/common.js @@ -1,23 +1,9 @@ -// Pull in dependencies. -import 'datatables.net' -import 'datatables.net-bs5' -import 'datatables.net-responsive' -import 'datatables.net-responsive-bs5' import 'bootstrap' import { $ } from 'jquery' -// Pull in app files. import * as routes from './routes.js' -import { default as index } from './app-index.js' -import { default as suite } from './app-suite.js' -import { default as viewer } from './app-viewer.js' - -$(document).ready(function() { - // Kick off the page main function. - let pages = { index, suite, viewer }; - let name = $('script[type=module]').attr('data-main'); - pages[name](); +export function updateHeader() { // Update the header with version info from hive.json. $.ajax({ type: 'GET', @@ -31,7 +17,7 @@ $(document).ready(function() { console.log("error fetching hive.json:", error); }, }); -}) +} function hiveInfoHTML(data) { var txt = ""; diff --git a/cmd/hiveview/assets/suite.html b/cmd/hiveview/assets/suite.html index 91f91ee29b..0887023d20 100644 --- a/cmd/hiveview/assets/suite.html +++ b/cmd/hiveview/assets/suite.html @@ -8,7 +8,7 @@ - +
diff --git a/cmd/hiveview/assets/viewer.html b/cmd/hiveview/assets/viewer.html index 3c91f2b4d9..a06fd79f39 100644 --- a/cmd/hiveview/assets/viewer.html +++ b/cmd/hiveview/assets/viewer.html @@ -17,7 +17,7 @@ - +
diff --git a/cmd/hiveview/deploy.go b/cmd/hiveview/deploy.go index a947b307f1..67c51daff8 100644 --- a/cmd/hiveview/deploy.go +++ b/cmd/hiveview/deploy.go @@ -3,7 +3,6 @@ package main import ( "bytes" "encoding/json" - "errors" "io" "io/fs" "sort" @@ -15,10 +14,14 @@ import ( // hiveviewBundler creates the esbuild bundler and registers JS/CSS targets. func hiveviewBundler(fsys fs.FS) *bundler { - b := newBundler(fsys) - b.add("lib/app.js") - b.add("lib/app.css") - b.add("lib/viewer.css") + entrypoints := []string{ + "lib/app-index.js", + "lib/app-suite.js", + "lib/app-viewer.js", + "lib/app.css", + "lib/viewer.css", + } + b := newBundler(fsys, entrypoints, moduleAliases) return b } @@ -48,17 +51,16 @@ func importMapScript() string { // against the bundler, and URLs in the HTML will be replaced by references to // bundle files. type deployFS struct { - assets fs.FS - bundler *bundler - useBundle bool + assets fs.FS + bundler *bundler } func newDeployFS(assets fs.FS, useBundle bool) *deployFS { - return &deployFS{ - assets: assets, - bundler: hiveviewBundler(assets), - useBundle: useBundle, + dfs := &deployFS{assets: assets} + if useBundle { + dfs.bundler = hiveviewBundler(assets) } + return dfs } func isBundlePath(name string) bool { @@ -75,8 +77,9 @@ func (dfs *deployFS) Open(name string) (f fs.File, err error) { switch { case !strings.Contains(name, "/") && strings.HasSuffix(name, ".html"): return dfs.openHTML(name) - case isBundlePath(name): - return dfs.bundler.fs().Open(name) + case dfs.bundler != nil && isBundlePath(name): + _, memfs, _ := dfs.bundler.rebuild() + return memfs.Open(name) default: return dfs.assets.Open(name) } @@ -92,8 +95,9 @@ func (dfs *deployFS) ReadDir(name string) ([]fs.DirEntry, error) { switch { case name == ".": return dfs.readDirRoot() - case isBundlePath(name): - return fs.ReadDir(dfs.bundler.fs(), name) + case dfs.bundler != nil && isBundlePath(name): + _, memfs, _ := dfs.bundler.rebuild() + return fs.ReadDir(memfs, name) default: return fs.ReadDir(dfs.assets, name) } @@ -104,8 +108,12 @@ func (dfs *deployFS) readDirRoot() ([]fs.DirEntry, error) { if err != nil { return nil, err } - bundleEntries, _ := fs.ReadDir(dfs.bundler.fs(), ".") - entries = append(entries, bundleEntries...) + if dfs.bundler != nil { + _, memfs, _ := dfs.bundler.rebuild() + bundleEntries, _ := fs.ReadDir(memfs, ".") + entries = append(entries, bundleEntries...) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) @@ -128,35 +136,35 @@ func (dfs *deployFS) openHTML(name string) (fs.File, error) { output := new(bytes.Buffer) modTime := inputInfo.ModTime() - if !dfs.useBundle { + if dfs.bundler == nil { // JS bundle is disabled. To make ES module loading work without the bundle, // the document needs an importmap. insertAfterTag(inputFile, output, "head", importMapScript()) modTime = time.Now() } else { // Replace script/style references with bundle paths, if possible. + buildmsg, _, _ := dfs.bundler.rebuild() var errorShown bool modifyHTML(inputFile, output, func(token *html.Token, errlog io.Writer) { + if len(buildmsg) > 0 && !errorShown { + io.WriteString(errlog, "** ESBUILD ERRORS **\n\n") + renderBuildMsg(buildmsg, errlog) + modTime = time.Now() + errorShown = true + } + ref := scriptOrStyleReference(token) if ref == nil { return // not script } - bundle, buildmsg, err := dfs.bundler.build(ref.Val) - if errors.Is(err, fs.ErrNotExist) { - return - } else if err != nil { - if !errorShown { - io.WriteString(errlog, "** ESBUILD ERRORS **\n\n") - errorShown = true - } - renderBuildMsg(buildmsg, errlog) - modTime = time.Now() - return + bundle := dfs.bundler.bundle(ref.Val) + if bundle == nil || bundle.outputFile == "" { + return // not a bundle target } if bundle.buildTime.After(modTime) { modTime = bundle.buildTime } - ref.Val = "/bundle/" + bundle.name() + ref.Val = "/bundle/" + bundle.outputFile }) } diff --git a/cmd/hiveview/deploy_test.go b/cmd/hiveview/deploy_test.go index 1fb7e5ed6c..b5b0d29e8f 100644 --- a/cmd/hiveview/deploy_test.go +++ b/cmd/hiveview/deploy_test.go @@ -11,9 +11,29 @@ func TestBuildAllBundles(t *testing.T) { b := hiveviewBundler(assets) var output strings.Builder - msg, err := b.buildAll() + msg, _, err := b.rebuild() if err != nil { renderBuildMsg(msg, &output) t.Fatal("esbuild errors:\n\n", output.String()) } } + +func TestDeployWithBundle(t *testing.T) { + assets, _ := fs.Sub(embeddedAssets, "assets") + + temp := t.TempDir() + dfs := newDeployFS(assets, true) + if err := copyFS(temp, dfs); err != nil { + t.Fatal("copy error:", err) + } +} + +func TestDeployWithoutBundle(t *testing.T) { + assets, _ := fs.Sub(embeddedAssets, "assets") + + temp := t.TempDir() + dfs := newDeployFS(assets, false) + if err := copyFS(temp, dfs); err != nil { + t.Fatal("copy error:", err) + } +} diff --git a/cmd/hiveview/esbuild.go b/cmd/hiveview/esbuild.go index c11f9c720d..2e34b0d3bb 100644 --- a/cmd/hiveview/esbuild.go +++ b/cmd/hiveview/esbuild.go @@ -1,13 +1,14 @@ package main import ( - "crypto/sha256" + "encoding/json" "errors" "fmt" "io" "io/fs" "log" "path" + "path/filepath" "strings" "sync" "time" @@ -18,205 +19,181 @@ import ( // bundler creates JS/CSS bundles and caches them in memory. type bundler struct { - fsys fs.FS - options *esbuild.BuildOptions - mu sync.Mutex - files map[string]*bundleFile - mem *memoryfs.FS + fsys fs.FS + + mu sync.Mutex + files map[string]*bundleFile + buildContext esbuild.BuildContext + mem *memoryfs.FS + lastBuild time.Time + lastBuildFailed bool } type bundleFile struct { - inputPath string - hash [32]byte - inputFiles []string + outputFile string buildTime time.Time - buildMsg []esbuild.Message } -func newBundler(fsys fs.FS) *bundler { - mem := memoryfs.New() - mem.MkdirAll("bundle", 0755) +func newBundler(fsys fs.FS, entrypoints []string, aliases map[string]string) *bundler { + options := makeBuildOptions(fsys) + options.Alias = aliases + options.EntryPoints = entrypoints + ctx, err := esbuild.Context(options) + if err != nil { + panic(err) + } + return &bundler{ - mem: mem, - fsys: fsys, - files: make(map[string]*bundleFile), + mem: memoryfs.New(), + fsys: fsys, + files: make(map[string]*bundleFile), + buildContext: ctx, } } -// add adds a file target. -func (b *bundler) add(name string) { - b.mu.Lock() - defer b.mu.Unlock() - - name = path.Clean(strings.TrimPrefix(name, "/")) - if _, ok := b.files[name]; !ok { - b.files[name] = &bundleFile{inputPath: name} +func makeBuildOptions(fsys fs.FS) esbuild.BuildOptions { + loader := fsLoaderPlugin(fsys) + return esbuild.BuildOptions{ + Bundle: true, + Outdir: "/", + AbsWorkingDir: "/", + PublicPath: "/bundle", + EntryNames: "[dir]/[name].[hash]", + AssetNames: "assets/[dir]/[name].[hash]", + ChunkNames: "chunks/chunk-[hash]", + Splitting: true, + Format: esbuild.FormatESModule, + LogLevel: esbuild.LogLevelWarning, + Plugins: []esbuild.Plugin{loader}, + Platform: esbuild.PlatformBrowser, + Target: esbuild.ES2020, + Metafile: true, + MinifyIdentifiers: true, + MinifyWhitespace: true, + MinifySyntax: true, } } -// fs returns a virtual filesystem containing built bundle files. -// Calling this also ensures all bundles are up-to-date! -func (b *bundler) fs() fs.FS { +// bundle looks up a bundle output file. +func (b *bundler) bundle(name string) *bundleFile { b.mu.Lock() defer b.mu.Unlock() - b.buildAll() - return b.mem -} - -func (b *bundler) buildAll() ([]esbuild.Message, error) { - var allmsg []esbuild.Message - var firsterr error - for _, bf := range b.files { - msg, err := bf.rebuild(b) - if err != nil && firsterr == nil { - firsterr = err - } - allmsg = append(allmsg, msg...) - } - return allmsg, firsterr + name = path.Clean(strings.TrimPrefix(name, "/")) + return b.files[name] } -// build ensures the given bundle file is built, and returns the bundle. -func (b *bundler) build(name string) (bf *bundleFile, buildmsg []esbuild.Message, err error) { +// rebuild builds all input files. +func (b *bundler) rebuild() ([]esbuild.Message, fs.FS, error) { b.mu.Lock() defer b.mu.Unlock() - name = path.Clean(strings.TrimPrefix(name, "/")) - bf, ok := b.files[name] - if !ok { - return nil, nil, fs.ErrNotExist + // Skip build if last build was < 1s ago. + if !b.lastBuild.IsZero() && time.Since(b.lastBuild) < 1*time.Second && !b.lastBuildFailed { + return nil, b.mem, nil } - buildmsg, err = bf.rebuild(b) - if err != nil { - return nil, buildmsg, err - } - cpy := *bf - return &cpy, nil, nil -} + b.lastBuild = time.Now() -// rebuild builds the bundle if necessary. -func (bf *bundleFile) rebuild(b *bundler) ([]esbuild.Message, error) { - if !bf.needsBuild(b) { - return nil, nil + start := time.Now() + var msg []esbuild.Message + var err error + res := b.buildContext.Rebuild() + msg = append(msg, res.Errors...) + msg = append(msg, res.Warnings...) + b.lastBuildFailed = len(res.Errors) > 0 + if b.lastBuildFailed { + err = errors.New("build failed") + } else { + b.handleBuildResult(&res) } - return bf.build(b) -} -// name returns the output file name (including the hash). -func (bf *bundleFile) name() string { - base := path.Base(bf.inputPath) - baseNoExt := strings.TrimSuffix(base, path.Ext(base)) - return fmt.Sprintf("%s.%x%s", baseNoExt, bf.hash[:], path.Ext(base)) + fmt.Println("build done:", time.Since(start)) + return msg, b.mem, err } -// needsBuild reports whether the bundle needs to be rebuilt. -func (bf *bundleFile) needsBuild(b *bundler) bool { - if bf.buildTime.IsZero() { - return true - } - for _, f := range bf.inputFiles { - stat, err := fs.Stat(b.fsys, f) - if err != nil || stat.ModTime().After(bf.buildTime) { - return true - } - } - return false +type metafile struct { + Outputs map[string]*metafileOutput } -// build creates/updates a bundle. -func (bf *bundleFile) build(b *bundler) ([]esbuild.Message, error) { - log.Printf("esbuild: %s", bf.inputPath) +type metafileOutput struct { + EntryPoint string +} - prevName := bf.name() - startTime := time.Now() +func (b *bundler) handleBuildResult(res *esbuild.BuildResult) { + var meta metafile + err := json.Unmarshal([]byte(res.Metafile), &meta) + if err != nil { + panic("invalid metafile! " + err.Error()) + } + + // Write output files to a new memfs. + memfs := memoryfs.New() + now := time.Now() + for _, f := range res.OutputFiles { + outputName := strings.TrimPrefix(filepath.ToSlash(f.Path), "/") + outputPath := "bundle/" + outputName + m := meta.Outputs[outputName] + if m == nil { + panic("unknown output file: " + f.Path) + } - var loadedFiles []string - loader := fsLoaderPlugin(b.fsys, &loadedFiles) - options := esbuild.BuildOptions{ - Bundle: true, - LogLevel: esbuild.LogLevelInfo, - EntryPoints: []string{bf.inputPath}, - Plugins: []esbuild.Plugin{loader}, - Platform: esbuild.PlatformBrowser, - Target: esbuild.ES2020, - MinifyIdentifiers: true, - MinifyWhitespace: true, - MinifySyntax: true, - Alias: moduleAliases, - } - res := esbuild.Build(options) - msg := append(res.Errors, res.Warnings...) - if len(res.Errors) > 0 { - return msg, fmt.Errorf("error in %s", bf.inputPath) - } - content := res.OutputFiles[0].Contents - if len(content) == 0 { - panic("empty build output") - } + // Carry over modification time from the previous memfs. + modTime := now + info, err := b.mem.Stat(outputPath) + if err == nil { + modTime = info.ModTime() + } - // Update the result. - bf.hash = sha256.Sum256(content) - bf.buildTime = startTime - bf.inputFiles = loadedFiles + // For output files that correspond to an entry point, + // assign the bundle file name. + if m.EntryPoint != "" { + ep := strings.TrimPrefix(m.EntryPoint, "fsLoader:") + prev := b.files[ep] + bf := &bundleFile{outputFile: outputName, buildTime: modTime} + if prev == nil || prev.outputFile != outputName { + // File has changed, log it. + log.Println("esbuild", ep, "=>", outputName) + } + b.files[ep] = bf + } - // Write output to memfs. - // fmt.Println("store:", path.Join("bundle", bf.name())) - err := b.mem.WriteFile(path.Join("bundle", bf.name()), content, 0644) - if err != nil { - panic("can't write to memfs: " + err.Error()) - } - // Ensure the previous output file is gone. - if prevName != bf.name() { - b.mem.Remove(path.Join("bundle", prevName)) + // fmt.Println("store:", outputPath) + memfs.MkdirAll(path.Dir(outputPath), 0755) + if err := memfs.WriteFile(outputPath, f.Contents, 0644); err != nil { + panic("can't write to memfs: " + err.Error()) + } + memfs.SetModified(outputPath, modTime) } - return msg, nil + // Flip over to the new memfs. + b.mem = memfs } // fsLoaderPlugin constructs an esbuild loader plugin that wraps a filesystem. -// The plugin does two things: -// -// - All file loads are done through the given filesystem. -// - Loaded paths are appended to the 'loadedFiles' list. Note that it is -// not safe to access loadedFiles until esbuild.Build has returned. -func fsLoaderPlugin(fsys fs.FS, loadedFiles *[]string) esbuild.Plugin { - var addedFile = make(map[string]bool) - var fileListMutex sync.Mutex - addToLoadedFiles := func(path string) { - fileListMutex.Lock() - defer fileListMutex.Unlock() - if !addedFile[path] { - *loadedFiles = append(*loadedFiles, path) - addedFile[path] = true - } - } - +func fsLoaderPlugin(fsys fs.FS) esbuild.Plugin { return esbuild.Plugin{ Name: "fsLoader", Setup: func(build esbuild.PluginBuild) { resOpt := esbuild.OnResolveOptions{Filter: ".*"} build.OnResolve(resOpt, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) { - var p string - switch args.Kind { - case esbuild.ResolveCSSURLToken: - // url(...) in CSS is always considered an external resource. + // Ignore data: URLs. + if strings.HasPrefix(args.Path, "data:") { return esbuild.OnResolveResult{Path: args.Path, External: true}, nil + } - case esbuild.ResolveEntryPoint: + var p string + if args.Kind == esbuild.ResolveEntryPoint { // For the initial entry point in the bundle, args.Importer is set // to the absolute working directory, which can't be used, so just treat // it as a raw path into the FS. p = strings.TrimPrefix(args.Path, "/") - - default: - // All other import paths are resolved relative to the importing - // file's location, unless the name is defined as an alias. - alias, ok := build.InitialOptions.Alias[args.Path] - if ok { + } else { + alias, isAlias := build.InitialOptions.Alias[args.Path] + if isAlias { p = path.Clean(alias) - // fmt.Println("resolved alias:", args.Path, "=>", p) } else { + // Relative import paths are resolved relative to the + // importing file's location. p = path.Join(path.Dir(args.Importer), args.Path) } } @@ -231,7 +208,6 @@ func fsLoaderPlugin(fsys fs.FS, loadedFiles *[]string) esbuild.Plugin { loadOpt := esbuild.OnLoadOptions{Filter: ".*", Namespace: "fsLoader"} build.OnLoad(loadOpt, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) { - addToLoadedFiles(args.Path) text, err := fs.ReadFile(fsys, args.Path) if err != nil { return esbuild.OnLoadResult{}, err @@ -258,12 +234,16 @@ func loaderFromExt(name string) esbuild.Loader { case ".json": return esbuild.LoaderJSON default: - return esbuild.LoaderNone + return esbuild.LoaderFile } } func renderBuildMsg(msgs []esbuild.Message, w io.Writer) { for _, msg := range msgs { + if msg.Location == nil { + fmt.Fprintln(w, msg.Text) + continue + } file := strings.Replace(msg.Location.File, "fsLoader:", "assets/", 1) fmt.Fprintf(w, "%s:%d %s\n", file, msg.Location.Line, msg.Text) fmt.Fprintln(w, " |")