Repository: google/pprof
Pull request #831 (2024-02-23 22:06:07)

Use html/template to produce source listing.
We previously used an ad-hoc combination of calls to printf and HTML escaping routines to produce HTML for source code listing. We now use html/template to do the formatting. This should give us HTML generation that is less susceptible to escaping issues.

The change keeps the output very similar to the old output (mostly a small number of spacing changes that do not affect what a user can see).

Also added a test that does a quick check of the source view displayed in a browser.


Patch:
diff --git a/internal/driver/driver.go b/internal/driver/driver.go
index 74ce8cb..06a47c6 100644
--- a/internal/driver/driver.go
+++ b/internal/driver/driver.go
@@ -20,6 +20,7 @@ package driver
 import (
 	"bytes"
 	"fmt"
+	"io"
 	"os"
 	"path/filepath"
 	"regexp"
@@ -118,7 +119,14 @@ func generateReport(p *profile.Profile, cmd []string, cfg config, o *plugin.Opti
 
 	// Generate the report.
 	dst := new(bytes.Buffer)
-	if err := report.Generate(dst, rpt, o.Obj); err != nil {
+	switch rpt.OutputFormat() {
+	case report.WebList:
+		// We need template expansion, so generate here instead of in report.
+		err = weblist(dst, rpt, o.Obj)
+	default:
+		err = report.Generate(dst, rpt, o.Obj)
+	}
+	if err != nil {
 		return err
 	}
 	src := dst
@@ -155,6 +163,18 @@ func generateReport(p *profile.Profile, cmd []string, cfg config, o *plugin.Opti
 	return out.Close()
 }
 
+func weblist(dst io.Writer, rpt *report.Report, obj plugin.ObjTool) error {
+	listing, err := report.MakeWebList(rpt, obj, -1)
+	if err != nil {
+		return err
+	}
+	legend := report.ProfileLabels(rpt)
+	return renderHTML(dst, "sourcelisting", rpt, nil, legend, webArgs{
+		Standalone: true,
+		Listing:    listing,
+	})
+}
+
 func applyCommandOverrides(cmd string, outputFormat int, cfg config) config {
 	// Some report types override the trim flag to false below. This is to make
 	// sure the default heuristics of excluding insignificant nodes and edges
diff --git a/internal/driver/html/source.html b/internal/driver/html/source.html
index 3212bee..b676ce2 100644
--- a/internal/driver/html/source.html
+++ b/internal/driver/html/source.html
@@ -3,16 +3,70 @@
 <head>
   <meta charset="utf-8">
   <title>{{.Title}}</title>
-  {{template "css" .}}
+  {{if not .Standalone}}{{template "css" .}}{{end}}
   {{template "weblistcss" .}}
   {{template "weblistjs" .}}
 </head>
-<body>
-  {{template "header" .}}
-  <div id="content" class="source">
-    {{.HTMLBody}}
-  </div>
-  {{template "script" .}}
-  <script>viewer(new URL(window.location.href), null);</script>
+<body>{{"\n" -}}
+  {{/* emit different header in standalone mode */ -}}
+  {{if .Standalone}}{{"\n" -}}
+    <div class="legend">{{"" -}}
+      {{range $i, $e := .Legend -}}
+        {{if $i}}<br>{{"\n"}}{{end}}{{. -}}
+      {{end}}<br>Total: {{.Listing.Total -}}
+    </div>{{"" -}}
+  {{else -}}
+    {{template "header" .}}
+    <div id="content" class="source">{{"" -}}
+  {{end -}}
+
+  {{range .Listing.Files -}}
+    {{range .Funcs -}}
+      <h2>{{.Name}}</h2>{{"" -}}
+      <p class="filename">{{.File}}</p>{{"\n" -}}
+      <pre onClick="pprof_toggle_asm(event)">{{"\n" -}}
+        {{printf "  Total:  %10s %10s (flat, cum) %s" .Flat .Cumulative .Percent -}}
+        {{range .Lines -}}{{"\n" -}}
+          {{/* source line */ -}}
+          <span class=line>{{printf " %6d" .Line}}</span>{{" " -}}
+          <span class={{.HTMLClass}}>
+            {{- printf "  %10s %10s %8s  %s " .Flat .Cumulative "" .SrcLine -}}
+          </span>{{"" -}}
+
+          {{if .Instructions -}}
+            {{/* instructions for this source line */ -}}
+            <span class=asm>{{"" -}}
+            {{range .Instructions -}}
+              {{/* separate when we hit a new basic block */ -}}
+              {{if .NewBlock -}}{{printf " %8s %28s\n" "" "⋮"}}{{end -}}
+
+              {{/* inlined calls leading to this instruction */ -}}
+              {{range .InlinedCalls -}}
+                {{printf " %8s %10s %10s %8s  " "" "" "" "" -}}
+                <span class=inlinesrc>{{.SrcLine}}</span>{{" " -}}
+                <span class=unimportant>{{.FileBase}}:{{.Line}}</span>{{"\n" -}}
+              {{end -}}
+
+              {{if not .Synthetic -}}
+                {{/* disassembled instruction */ -}}
+                {{printf " %8s %10s %10s %8x: %s " "" .Flat .Cumulative .Address .Disasm -}}
+                <span class=unimportant>{{.FileLine}}</span>{{"\n" -}}
+              {{end -}}
+            {{end -}}
+            </span>{{"" -}}
+          {{end -}}
+          {{/* end of line */ -}}
+        {{end}}{{"\n" -}}
+      </pre>{{"\n" -}}
+      {{/* end of function */ -}}
+    {{end -}}
+    {{/* end of file */ -}}
+  {{end -}}
+
+  {{if not .Standalone}}{{"\n  " -}}
+    </div>{{"\n" -}}
+    {{template "script" .}}{{"\n" -}}
+    <script>viewer(new URL(window.location.href), null);</script>{{"" -}}
+  {{end}}
 </body>
 </html>
diff --git a/internal/driver/webhtml.go b/internal/driver/webhtml.go
index 984936a..436c408 100644
--- a/internal/driver/webhtml.go
+++ b/internal/driver/webhtml.go
@@ -19,8 +19,27 @@ import (
 	"fmt"
 	"html/template"
 	"os"
+	"sync"
+
+	"github.com/google/pprof/internal/report"
+)
+
+var (
+	htmlTemplates    *template.Template // Lazily loaded templates
+	htmlTemplateInit sync.Once
 )
 
+// getHTMLTemplates returns the set of HTML templates used by pprof,
+// initializing them if necessary.
+func getHTMLTemplates() *template.Template {
+	htmlTemplateInit.Do(func() {
+		htmlTemplates = template.New("templategroup")
+		addTemplates(htmlTemplates)
+		report.AddSourceTemplates(htmlTemplates)
+	})
+	return htmlTemplates
+}
+
 //go:embed html
 var embeddedFiles embed.FS
 
diff --git a/internal/driver/webui.go b/internal/driver/webui.go
index 476e1d2..89a912a 100644
--- a/internal/driver/webui.go
+++ b/internal/driver/webui.go
@@ -18,6 +18,7 @@ import (
 	"bytes"
 	"fmt"
 	"html/template"
+	"io"
 	"net"
 	"net/http"
 	gourl "net/url"
@@ -39,7 +40,6 @@ type webInterface struct {
 	copier       profileCopier
 	options      *plugin.Options
 	help         map[string]string
-	templates    *template.Template
 	settingsFile string
 }
 
@@ -48,15 +48,11 @@ func makeWebInterface(p *profile.Profile, copier profileCopier, opt *plugin.Opti
 	if err != nil {
 		return nil, err
 	}
-	templates := template.New("templategroup")
-	addTemplates(templates)
-	report.AddSourceTemplates(templates)
 	return &webInterface{
 		prof:         p,
 		copier:       copier,
 		options:      opt,
 		help:         make(map[string]string),
-		templates:    templates,
 		settingsFile: settingsFile,
 	}, nil
 }
@@ -82,11 +78,13 @@ type webArgs struct {
 	Total       int64
 	SampleTypes []string
 	Legend      []string
+	Standalone  bool // True for command-line generation of HTML
 	Help        map[string]string
 	Nodes       []string
 	HTMLBody    template.HTML
 	TextBody    string
 	Top         []report.TextItem
+	Listing     report.WebListData
 	FlameGraph  template.JS
 	Stacks      template.JS
 	Configs     []configMenuEntry
@@ -283,21 +281,25 @@ func (ui *webInterface) makeReport(w http.ResponseWriter, req *http.Request,
 	return rpt, catcher.errors
 }
 
-// render generates html using the named template based on the contents of data.
-func (ui *webInterface) render(w http.ResponseWriter, req *http.Request, tmpl string,
-	rpt *report.Report, errList, legend []string, data webArgs) {
+// renderHTML generates html using the named template based on the contents of data.
+func renderHTML(dst io.Writer, tmpl string, rpt *report.Report, errList, legend []string, data webArgs) error {
 	file := getFromLegend(legend, "File: ", "unknown")
 	profile := getFromLegend(legend, "Type: ", "unknown")
 	data.Title = file + " " + profile
 	data.Errors = errList
 	data.Total = rpt.Total()
-	data.SampleTypes = sampleTypes(ui.prof)
 	data.Legend = legend
+	return getHTMLTemplates().ExecuteTemplate(dst, tmpl, data)
+}
+
+// render responds with html generated by passing data to the named template.
+func (ui *webInterface) render(w http.ResponseWriter, req *http.Request, tmpl string,
+	rpt *report.Report, errList, legend []string, data webArgs) {
+	data.SampleTypes = sampleTypes(ui.prof)
 	data.Help = ui.help
 	data.Configs = configMenu(ui.settingsFile, *req.URL)
-
 	html := &bytes.Buffer{}
-	if err := ui.templates.ExecuteTemplate(html, tmpl, data); err != nil {
+	if err := renderHTML(html, tmpl, rpt, errList, legend, data); err != nil {
 		http.Error(w, "internal template error", http.StatusInternalServerError)
 		ui.options.UI.PrintErr(err)
 		return
@@ -410,8 +412,8 @@ func (ui *webInterface) source(w http.ResponseWriter, req *http.Request) {
 	}
 
 	// Generate source listing.
-	var body bytes.Buffer
-	if err := report.PrintWebList(&body, rpt, ui.options.Obj, maxEntries); err != nil {
+	listing, err := report.MakeWebList(rpt, ui.options.Obj, maxEntries)
+	if err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		ui.options.UI.PrintErr(err)
 		return
@@ -419,7 +421,7 @@ func (ui *webInterface) source(w http.ResponseWriter, req *http.Request) {
 
 	legend := report.ProfileLabels(rpt)
 	ui.render(w, req, "sourcelisting", rpt, errList, legend, webArgs{
-		HTMLBody: template.HTML(body.String()),
+		Listing: listing,
 	})
 }
 
diff --git a/internal/report/report.go b/internal/report/report.go
index 96b8003..c7f5bed 100644
--- a/internal/report/report.go
+++ b/internal/report/report.go
@@ -111,11 +111,10 @@ func Generate(w io.Writer, rpt *Report, obj plugin.ObjTool) error {
 		return printAssembly(w, rpt, obj)
 	case List:
 		return printSource(w, rpt)
-	case WebList:
-		return printWebSource(w, rpt, obj)
 	case Callgrind:
 		return printCallgrind(w, rpt)
 	}
+	// Note: WebList handling is in driver package.
 	return fmt.Errorf("unexpected output format")
 }
 
@@ -1327,6 +1326,9 @@ type Report struct {
 // Total returns the total number of samples in a report.
 func (rpt *Report) Total() int64 { return rpt.total }
 
+// OutputFormat returns the output format for the report.
+func (rpt *Report) OutputFormat() int { return rpt.options.OutputFormat }
+
 func abs64(i int64) int64 {
 	if i < 0 {
 		return -i
diff --git a/internal/report/source.go b/internal/report/source.go
index d8b4395..4a2f4e4 100644
--- a/internal/report/source.go
+++ b/internal/report/source.go
@@ -122,17 +122,6 @@ func printSource(w io.Writer, rpt *Report) error {
 	return nil
 }
 
-// printWebSource prints an annotated source listing, include all
-// functions with samples that match the regexp rpt.options.symbol.
-func printWebSource(w io.Writer, rpt *Report, obj plugin.ObjTool) error {
-	printHeader(w, rpt)
-	if err := PrintWebList(w, rpt, obj, -1); err != nil {
-		return err
-	}
-	printPageClosing(w)
-	return nil
-}
-
 // sourcePrinter holds state needed for generating source+asm HTML listing.
 type sourcePrinter struct {
 	reader     *sourceReader
@@ -198,24 +187,73 @@ type addressRange struct {
 	score      int64 // Used to order ranges for processing
 }
 
-// PrintWebList prints annotated source listing of rpt to w.
+// WebListData holds the data needed to generate HTML source code listing.
+type WebListData struct {
+	Total string
+	Files []WebListFile
+}
+
+// WebListFile holds the per-file information for HTML source code listing.
+type WebListFile struct {
+	Funcs []WebListFunc
+}
+
+// WebListFunc holds the per-function information for HTML source code listing.
+type WebListFunc struct {
+	Name       string
+	File       string
+	Flat       string
+	Cumulative string
+	Percent    string
+	Lines      []WebListLine
+}
+
+// WebListLine holds the per-source-line information for HTML source code listing.
+type WebListLine struct {
+	SrcLine      string
+	HTMLClass    string
+	Line         int
+	Flat         string
+	Cumulative   string
+	Instructions []WebListInstruction
+}
+
+// WebListInstruction holds the per-instruction information for HTML source code listing.
+type WebListInstruction struct {
+	NewBlock     bool // Insert marker that indicates separation from previous block
+	Flat         string
+	Cumulative   string
+	Synthetic    bool
+	Address      uint64
+	Disasm       string
+	FileLine     string
+	InlinedCalls []WebListCall
+}
+
+// WebListCall holds the per-inlined-call information for HTML source code listing.
+type WebListCall struct {
+	SrcLine  string
+	FileBase string
+	Line     int
+}
+
+// MakeWebList returns an annotated source listing of rpt.
 // rpt.prof should contain inlined call info.
-func PrintWebList(w io.Writer, rpt *Report, obj plugin.ObjTool, maxFiles int) error {
+func MakeWebList(rpt *Report, obj plugin.ObjTool, maxFiles int) (WebListData, error) {
 	sourcePath := rpt.options.SourcePath
 	if sourcePath == "" {
 		wd, err := os.Getwd()
 		if err != nil {
-			return fmt.Errorf("could not stat current dir: %v", err)
+			return WebListData{}, fmt.Errorf("could not stat current dir: %v", err)
 		}
 		sourcePath = wd
 	}
 	sp := newSourcePrinter(rpt, obj, sourcePath)
 	if len(sp.interest) == 0 {
-		return fmt.Errorf("no matches found for regexp: %s", rpt.options.Symbol)
+		return WebListData{}, fmt.Errorf("no matches found for regexp: %s", rpt.options.Symbol)
 	}
-	sp.print(w, maxFiles, rpt)
-	sp.close()
-	return nil
+	defer sp.close()
+	return sp.generate(maxFiles, rpt), nil
 }
 
 func newSourcePrinter(rpt *Report, obj plugin.ObjTool, sourcePath string) *sourcePrinter {
@@ -566,7 +604,7 @@ func (sp *sourcePrinter) initSamples(flat, cum map[uint64]int64) {
 	}
 }
 
-func (sp *sourcePrinter) print(w io.Writer, maxFiles int, rpt *Report) {
+func (sp *sourcePrinter) generate(maxFiles int, rpt *Report) WebListData {
 	// Finalize per-file counts.
 	for _, file := range sp.files {
 		seen := map[uint64]bool{}
@@ -598,19 +636,31 @@ func (sp *sourcePrinter) print(w io.Writer, maxFiles int, rpt *Report) {
 		maxFiles = len(files)
 	}
 	sort.Slice(files, order)
+	result := WebListData{
+		Total: rpt.formatValue(rpt.total),
+	}
 	for i, f := range files {
 		if i < maxFiles {
-			sp.printFile(w, f, rpt)
+			result.Files = append(result.Files, sp.generateFile(f, rpt))
 		}
 	}
+	return result
 }
 
-func (sp *sourcePrinter) printFile(w io.Writer, f *sourceFile, rpt *Report) {
+func (sp *sourcePrinter) generateFile(f *sourceFile, rpt *Report) WebListFile {
+	var result WebListFile
 	for _, fn := range sp.functions(f) {
 		if fn.cum == 0 {
 			continue
 		}
-		printFunctionHeader(w, fn.name, f.fname, fn.flat, fn.cum, rpt)
+
+		listfn := WebListFunc{
+			Name:       fn.name,
+			File:       f.fname,
+			Flat:       rpt.formatValue(fn.flat),
+			Cumulative: rpt.formatValue(fn.cum),
+			Percent:    measurement.Percentage(fn.cum, rpt.total),
+		}
 		var asm []assemblyInstruction
 		for l := fn.begin; l < fn.end; l++ {
 			lineContents, ok := sp.reader.line(f.fname, l)
@@ -654,10 +704,12 @@ func (sp *sourcePrinter) printFile(w io.Writer, f *sourceFile, rpt *Report) {
 				})
 			}
 
-			printFunctionSourceLine(w, l, flatSum, cumSum, lineContents, asm, sp.reader, rpt)
+			listfn.Lines = append(listfn.Lines, webListLine(l, flatSum, cumSum, lineContents, asm, sp.reader, rpt))
 		}
-		printFunctionClosing(w)
+
+		result.Funcs = append(result.Funcs, listfn)
 	}
+	return result
 }
 
 // functions splits apart the lines to show in a file into a list of per-function ranges.
@@ -752,89 +804,58 @@ func (sp *sourcePrinter) objectFile(m *profile.Mapping) plugin.ObjFile {
 	return object
 }
 
-// printHeader prints the page header for a weblist report.
-func printHeader(w io.Writer, rpt *Report) {
-	fmt.Fprintln(w, `
-<!DOCTYPE html>
-<html>
-<head>
-<meta charset="UTF-8">
-<title>Pprof listing</title>`)
-	fmt.Fprintln(w, weblistPageCSS)
-	fmt.Fprintln(w, weblistPageScript)
-	fmt.Fprint(w, "</head>\n<body>\n\n")
-
-	var labels []string
-	for _, l := range ProfileLabels(rpt) {
-		labels = append(labels, template.HTMLEscapeString(l))
+// webListLine returns the contents of a single line in a web listing. This includes
+// the source line and the corresponding assembly.
+func webListLine(lineNo int, flat, cum int64, lineContents string,
+	assembly []assemblyInstruction, reader *sourceReader, rpt *Report) WebListLine {
+	line := WebListLine{
+		SrcLine:    lineContents,
+		Line:       lineNo,
+		Flat:       valueOrDot(flat, rpt),
+		Cumulative: valueOrDot(cum, rpt),
 	}
 
-	fmt.Fprintf(w, `<div class="legend">%s<br>Total: %s</div>`,
-		strings.Join(labels, "<br>\n"),
-		rpt.formatValue(rpt.total),
-	)
-}
-
-// printFunctionHeader prints a function header for a weblist report.
-func printFunctionHeader(w io.Writer, name, path string, flatSum, cumSum int64, rpt *Report) {
-	fmt.Fprintf(w, `<h2>%s</h2><p class="filename">%s</p>
-<pre onClick="pprof_toggle_asm(event)">
-  Total:  %10s %10s (flat, cum) %s
-`,
-		template.HTMLEscapeString(name), template.HTMLEscapeString(path),
-		rpt.formatValue(flatSum), rpt.formatValue(cumSum),
-		measurement.Percentage(cumSum, rpt.total))
-}
-
-// printFunctionSourceLine prints a source line and the corresponding assembly.
-func printFunctionSourceLine(w io.Writer, lineNo int, flat, cum int64, lineContents string,
-	assembly []assemblyInstruction, reader *sourceReader, rpt *Report) {
 	if len(assembly) == 0 {
-		fmt.Fprintf(w,
-			"<span class=line> %6d</span> <span class=nop>  %10s %10s %8s  %s </span>\n",
-			lineNo,
-			valueOrDot(flat, rpt), valueOrDot(cum, rpt),
-			"", template.HTMLEscapeString(lineContents))
-		return
+		line.HTMLClass = "nop"
+		return line
 	}
 
 	nestedInfo := false
-	cl := "deadsrc"
+	line.HTMLClass = "deadsrc"
 	for _, an := range assembly {
 		if len(an.inlineCalls) > 0 || an.instruction != synthAsm {
 			nestedInfo = true
-			cl = "livesrc"
+			line.HTMLClass = "livesrc"
 		}
 	}
 
-	fmt.Fprintf(w,
-		"<span class=line> %6d</span> <span class=%s>  %10s %10s %8s  %s </span>",
-		lineNo, cl,
-		valueOrDot(flat, rpt), valueOrDot(cum, rpt),
-		"", template.HTMLEscapeString(lineContents))
 	if nestedInfo {
 		srcIndent := indentation(lineContents)
-		printNested(w, srcIndent, assembly, reader, rpt)
+		line.Instructions = webListInstructions(srcIndent, assembly, reader, rpt)
 	}
-	fmt.Fprintln(w)
+	return line
 }
 
-func printNested(w io.Writer, srcIndent int, assembly []assemblyInstruction, reader *sourceReader, rpt *Report) {
-	fmt.Fprint(w, "<span class=asm>")
+func webListInstructions(srcIndent int, assembly []assemblyInstruction, reader *sourceReader, rpt *Report) []WebListInstruction {
+	var result []WebListInstruction
 	var curCalls []callID
 	for i, an := range assembly {
-		if an.startsBlock && i != 0 {
-			// Insert a separator between discontiguous blocks.
-			fmt.Fprintf(w, " %8s %28s\n", "", "⋮")
-		}
-
 		var fileline string
 		if an.file != "" {
 			fileline = fmt.Sprintf("%s:%d", template.HTMLEscapeString(filepath.Base(an.file)), an.line)
 		}
-		flat, cum := an.flat, an.cum
+		text := strings.Repeat(" ", srcIndent+4+4*len(an.inlineCalls)) + an.instruction
+		inst := WebListInstruction{
+			NewBlock:   (an.startsBlock && i != 0),
+			Flat:       valueOrDot(an.flat, rpt),
+			Cumulative: valueOrDot(an.cum, rpt),
+			Synthetic:  (an.instruction == synthAsm),
+			Address:    an.address,
+			Disasm:     rightPad(text, 80),
+			FileLine:   fileline,
+		}
 
-		// Print inlined call context.
+		// Add inlined call context.
 		for j, c := range an.inlineCalls {
 			if j < len(curCalls) && curCalls[j] == c {
 				// Skip if same as previous instruction.
@@ -845,36 +866,18 @@ func printNested(w io.Writer, srcIndent int, assembly []assemblyInstruction, rea
 			if !ok {
 				fline = ""
 			}
-			text := strings.Repeat(" ", srcIndent+4+4*j) + strings.TrimSpace(fline)
-			fmt.Fprintf(w, " %8s %10s %10s %8s  <span class=inlinesrc>%s</span> <span class=unimportant>%s:%d</span>\n",
-				"", "", "", "",
-				template.HTMLEscapeString(rightPad(text, 80)),
-				template.HTMLEscapeString(filepath.Base(c.file)), c.line)
+			srcCode := strings.Repeat(" ", srcIndent+4+4*j) + strings.TrimSpace(fline)
+			inst.InlinedCalls = append(inst.InlinedCalls, WebListCall{
+				SrcLine:  rightPad(srcCode, 80),
+				FileBase: filepath.Base(c.file),
+				Line:     c.line,
+			})
 		}
 		curCalls = an.inlineCalls
-		if an.instruction == synthAsm {
-			continue
-		}
-		text := strings.Repeat(" ", srcIndent+4+4*len(curCalls)) + an.instruction
-		fmt.Fprintf(w, " %8s %10s %10s %8x: %s <span class=unimportant>%s</span>\n",
-			"", valueOrDot(flat, rpt), valueOrDot(cum, rpt), an.address,
-			template.HTMLEscapeString(rightPad(text, 80)),
-			// fileline should not be escaped since it was formed by appending
-			// line number (just digits) to an escaped file name. Escaping here
-			// would cause double-escaping of file name.
-			fileline)
-	}
-	fmt.Fprint(w, "</span>")
-}
-
-// printFunctionClosing prints the end of a function in a weblist report.
-func printFunctionClosing(w io.Writer) {
-	fmt.Fprintln(w, "</pre>")
-}
 
-// printPageClosing prints the end of the page in a weblist report.
-func printPageClosing(w io.Writer) {
-	fmt.Fprintln(w, weblistPageClosing)
+		result = append(result, inst)
+	}
+	return result
 }
 
 // getSourceFromFile collects the sources of a function from a source
diff --git a/internal/report/source_html.go b/internal/report/source_html.go
index 851693f..614a5ee 100644
--- a/internal/report/source_html.go
+++ b/internal/report/source_html.go
@@ -69,7 +69,3 @@ function pprof_toggle_asm(e) {
   }
 }
 </script>`
-
-const weblistPageClosing = `
-</body>
-</html>`


Test patch:
diff --git a/internal/driver/browser_test.go b/internal/driver/browser_test.go
index b4c0678..aebef1e 100644
--- a/internal/driver/browser_test.go
+++ b/internal/driver/browser_test.go
@@ -101,6 +101,28 @@ func TestFlameGraph(t *testing.T) {
 //go:embed testdata/testflame.js
 var jsCheckFlame string
 
+func TestSource(t *testing.T) {
+	maybeSkipBrowserTest(t)
+
+	prof := makeFakeProfile()
+	server := makeTestServer(t, prof)
+	ctx, cancel := context.WithTimeout(context.Background(), browserDeadline)
+	defer cancel()
+	ctx, cancel = chromedp.NewContext(ctx)
+	defer cancel()
+
+	err := chromedp.Run(ctx,
+		chromedp.Navigate(server.URL+"/source?f=F3"),
+		chromedp.WaitVisible(`#content`, chromedp.ByID),
+		matchRegexp(t, "#content", `F3`),            // Header
+		matchRegexp(t, "#content", `Total:.*100ms`), // Total for function
+		matchRegexp(t, "#content", `\b22\b.*100ms`), // Line 22
+	)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
 // matchRegexp is a chromedp.Action that fetches the text of the first
 // node that matched query and checks that the text matches regexp re.
 func matchRegexp(t *testing.T, query, re string) chromedp.ActionFunc {
diff --git a/internal/driver/testdata/pprof.cpu.flat.addresses.weblist b/internal/driver/testdata/pprof.cpu.flat.addresses.weblist
index d8446c8..b480dd9 100644
--- a/internal/driver/testdata/pprof.cpu.flat.addresses.weblist
+++ b/internal/driver/testdata/pprof.cpu.flat.addresses.weblist
@@ -1,10 +1,10 @@
-
 <!DOCTYPE html>
 <html>
 <head>
-<meta charset="UTF-8">
-<title>Pprof listing</title>
-<style type="text/css">
+  <meta charset="utf-8">
+  <title>testbinary cpu</title>
+  
+  <style type="text/css">
 body #content{
 font-family: sans-serif;
 }
@@ -31,7 +31,7 @@ color: #008800;
 display: none;
 }
 </style>
-<script type="text/javascript">
+  <script type="text/javascript">
 function pprof_toggle_asm(e) {
   var target;
   if (!e) e = window.event;
diff --git a/internal/report/report_test.go b/internal/report/report_test.go
index bc0b5dc..e21189b 100644
--- a/internal/report/report_test.go
+++ b/internal/report/report_test.go
@@ -112,10 +112,6 @@ func TestFilter(t *testing.T) {
 			name:   "list",
 			format: List,
 		},
-		{
-			name:   "weblist",
-			format: WebList,
-		},
 		{
 			name:   "disasm",
 			format: Dis,
diff --git a/internal/report/source_test.go b/internal/report/source_test.go
index 2febfd1..afd166b 100644
--- a/internal/report/source_test.go
+++ b/internal/report/source_test.go
@@ -15,7 +15,6 @@
 package report
 
 import (
-	"bytes"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -40,11 +39,11 @@ func TestWebList(t *testing.T) {
 		SampleValue:  func(v []int64) int64 { return v[1] },
 		SampleUnit:   cpu.SampleType[1].Unit,
 	})
-	var buf bytes.Buffer
-	if err := Generate(&buf, rpt, &binutils.Binutils{}); err != nil {
+	result, err := MakeWebList(rpt, &binutils.Binutils{}, -1)
+	if err != nil {
 		t.Fatalf("could not generate weblist: %v", err)
 	}
-	output := buf.String()
+	output := fmt.Sprint(result)
 
 	for _, expect := range []string{"func busyLoop", "call.*mapassign"} {
 		if match, _ := regexp.MatchString(expect, output); !match {
@@ -106,12 +105,12 @@ func testSourceMapping(t *testing.T, zeroAddress bool) {
 		formatValue: func(v int64) string { return fmt.Sprint(v) },
 	}
 
-	var out bytes.Buffer
-	err := PrintWebList(&out, rpt, nil, -1)
+	result, err := MakeWebList(rpt, nil, -1)
 	if err != nil {
-		t.Fatalf("PrintWebList returned unexpected error: %v", err)
+		t.Fatalf("MakeWebList returned unexpected error: %v", err)
 	}
-	got := out.String()
+	got := fmt.Sprint(result)
+
 	expect := regexp.MustCompile(
 		`(?s)` + // Allow "." to match newline
 			`bar\.go.* 50\b.* 17 +26 .*` +