diff --git a/pkg/service/browser.go b/pkg/service/browser.go index 7e22b187..4b50fbd5 100644 --- a/pkg/service/browser.go +++ b/pkg/service/browser.go @@ -326,6 +326,8 @@ func (s *BrowserService) Render(ctx context.Context, url string, optionFuncs ... scrollForElements(opts.timeBetweenScrolls), waitForDuration(time.Second), waitForReady(browserCtx, opts.timeout), + resizeViewportForFullHeight(opts), // Resize after all content is loaded and ready + waitForReady(browserCtx, opts.timeout), // Wait for readiness again after viewport resize opts.printer.action(fileChan, opts), } span.AddEvent("actions created") @@ -728,7 +730,7 @@ type pngPrinter struct { fullHeight bool } -func (p *pngPrinter) action(dst chan []byte, _ *renderingOptions) chromedp.Action { +func (p *pngPrinter) action(dst chan []byte, opts *renderingOptions) chromedp.Action { return chromedp.ActionFunc(func(ctx context.Context) error { tracer := tracer(ctx) ctx, span := tracer.Start(ctx, "pngPrinter.action", @@ -739,7 +741,10 @@ func (p *pngPrinter) action(dst chan []byte, _ *renderingOptions) chromedp.Actio output, err := page.CaptureScreenshot(). WithFormat(page.CaptureScreenshotFormatPng). - WithCaptureBeyondViewport(p.fullHeight). + // We don't want to use this option: it doesn't take a full window screenshot, + // rather it takes a screenshot including content that bleeds outside the viewport (e.g. something 110vh tall). + // Instead, we change the viewport height to match the content height. + WithCaptureBeyondViewport(false). Do(ctx) if err != nil { span.SetStatus(codes.Error, err.Error()) @@ -826,6 +831,54 @@ func setCookies(cookies []*network.SetCookieParams) chromedp.Action { }) } +func resizeViewportForFullHeight(opts *renderingOptions) chromedp.Action { + return chromedp.ActionFunc(func(ctx context.Context) error { + // Only resize for PNG printers with fullHeight enabled + pngPrinter, ok := opts.printer.(*pngPrinter) + if !ok || !pngPrinter.fullHeight { + return nil // Skip for non-PNG or non-fullHeight screenshots + } + + tracer := tracer(ctx) + ctx, span := tracer.Start(ctx, "resizeViewportForFullHeight") + defer span.End() + + var scrollHeight int + err := chromedp.Evaluate(`document.body.scrollHeight`, &scrollHeight).Do(ctx) + if err != nil { + span.SetStatus(codes.Error, "failed to get scroll height: "+err.Error()) + return fmt.Errorf("failed to get scroll height: %w", err) + } + + // Only resize if the page is actually taller than the current viewport + if scrollHeight > opts.viewportHeight { + span.AddEvent("resizing viewport for full height capture", + trace.WithAttributes( + attribute.Int("originalHeight", opts.viewportHeight), + attribute.Int("newHeight", scrollHeight), + )) + + // Determine orientation from options + orientation := chromedp.EmulatePortrait + if opts.landscape { + orientation = chromedp.EmulateLandscape + } + + err = chromedp.EmulateViewport(int64(opts.viewportWidth), int64(scrollHeight), orientation).Do(ctx) + if err != nil { + span.SetStatus(codes.Error, "failed to resize viewport: "+err.Error()) + return fmt.Errorf("failed to resize viewport for full height: %w", err) + } + + span.SetStatus(codes.Ok, "viewport resized successfully") + } else { + span.AddEvent("no viewport resize needed", trace.WithAttributes(attribute.Int("pageHeight", scrollHeight))) + } + + return nil + }) +} + func scrollForElements(timeBetweenScrolls time.Duration) chromedp.Action { return chromedp.ActionFunc(func(ctx context.Context) error { tracer := tracer(ctx) diff --git a/tests/acceptance/fixtures/render-very-long-prometheus-dashboard-full-height-landscape-false.png b/tests/acceptance/fixtures/render-very-long-prometheus-dashboard-full-height-landscape-false.png new file mode 100644 index 00000000..2373a9da Binary files /dev/null and b/tests/acceptance/fixtures/render-very-long-prometheus-dashboard-full-height-landscape-false.png differ diff --git a/tests/acceptance/fixtures/render-very-long-prometheus-dashboard-full-height-landscape-true.png b/tests/acceptance/fixtures/render-very-long-prometheus-dashboard-full-height-landscape-true.png new file mode 100644 index 00000000..2373a9da Binary files /dev/null and b/tests/acceptance/fixtures/render-very-long-prometheus-dashboard-full-height-landscape-true.png differ diff --git a/tests/acceptance/rendering_grafana_test.go b/tests/acceptance/rendering_grafana_test.go index d1231a6c..0d435aae 100644 --- a/tests/acceptance/rendering_grafana_test.go +++ b/tests/acceptance/rendering_grafana_test.go @@ -290,7 +290,7 @@ func TestRenderingGrafana(t *testing.T) { } }) - t.Run("render very long prometheus dashboard as PDF", func(t *testing.T) { + t.Run("render very long prometheus dashboard", func(t *testing.T) { t.Parallel() net, err := network.New(t.Context()) @@ -305,7 +305,7 @@ func TestRenderingGrafana(t *testing.T) { WithEnv("GF_RENDERING_CALLBACK_URL", "http://grafana:3000/"), WithEnv("GF_RENDERING_RENDERER_TOKEN", rendererAuthToken)) - t.Run("render many pages", func(t *testing.T) { + t.Run("render PDF of many pages", func(t *testing.T) { t.Parallel() req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, svc.HTTPEndpoint+"/render", nil) @@ -340,7 +340,7 @@ func TestRenderingGrafana(t *testing.T) { "first 3": "1-3", "1 and 3": "1, 3", } { - t.Run("print with pageRanges="+name, func(t *testing.T) { + t.Run("print PDF with pageRanges="+name, func(t *testing.T) { t.Parallel() req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, svc.HTTPEndpoint+"/render", nil) @@ -368,6 +368,41 @@ func TestRenderingGrafana(t *testing.T) { } }) } + + t.Run("render many pages as PNG with full height", func(t *testing.T) { + t.Parallel() + + for _, isLandscape := range []bool{true, false} { + t.Run("landscape="+fmt.Sprintf("%v", isLandscape), func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, svc.HTTPEndpoint+"/render", nil) + require.NoError(t, err, "could not construct HTTP request to Grafana") + req.Header.Set("Accept", "image/png") + req.Header.Set("X-Auth-Token", "-") + query := req.URL.Query() + query.Set("url", "http://grafana:3000/d/very-long-prometheus-dashboard?render=1&from=1699333200000&to=1699344000000&kiosk=true") + query.Set("encoding", "png") + query.Set("renderKey", renderKey) + query.Set("domain", "grafana") + query.Set("height", "-1") + query.Set("landscape", fmt.Sprintf("%v", isLandscape)) + req.URL.RawQuery = query.Encode() + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err, "could not send HTTP request to Grafana") + require.Equal(t, http.StatusOK, resp.StatusCode, "unexpected HTTP status code from Grafana") + + body := ReadBody(t, resp.Body) + image := ReadRGBA(t, body) + fixture := fmt.Sprintf("render-very-long-prometheus-dashboard-full-height-landscape-%v.png", isLandscape) + fixtureImg := ReadFixtureRGBA(t, fixture) + if !AssertPixelDifference(t, fixtureImg, image, 125_000) { // this is a very long image, so data may be off by a little bit + UpdateFixtureIfEnabled(t, fixture, body) + } + }) + } + }) }) t.Run("render panel dashboards as PNG", func(t *testing.T) {