test(suites): add a portal screenshot generator (#13069)

The portal screenshots published in the README and on the documentation
site were captured by hand, so they drifted out of date as the frontend
changed and there was no way to reproduce them.

Capture them from a running `DuoPush` suite instead, which is the suite
that offers all three second factor methods. The theme is forced through
the `theme.name` local storage key rather than the suite configuration
so a capture does not depend on how the suite is themed, and the
generator waits on `document.getAnimations()` before each capture so the
method selection dialog is recorded fully opened rather than part way
through its opening transition.

The generator rewrites tracked files, so it is skipped unless
`AUTHELIA_PORTAL_SCREENSHOTS` is set and never runs in CI.

Add `ViewportScreenshot` alongside `FullPageScreenshot` for the capture
itself, sharing the scrollbar suppression both need.
This commit is contained in:
Amir Zarrinkafsh authored and GitHub committed 2026-09-09 10:51:31 +00:00
1 parent 994a652ccf
commit 4ac3dcad2b
2 files changed
+145 -3

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
// SPDX-FileCopyrightText: 2026 Authelia
//
// SPDX-License-Identifier: Apache-2.0
//go:build !externalsuites
// +build !externalsuites
package suites
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/go-rod/rod"
"github.com/stretchr/testify/require"
)
// The portal screenshots published in the README and on the documentation site. Each theme is
// captured at portalScreenshotWidth by portalScreenshotHeight and emitted at
// portalScreenshotScale, giving a 1350x2100 image.
//
// The height is what the layout needs rather than a frame chosen for its proportions: the sign in
// column runs to 624 pixels at this width, so the brand footer falls past the fold on anything
// shorter than 640 and the taller frame leaves it room to grow.
const (
portalScreenshotWidth = 450
portalScreenshotHeight = 700
portalScreenshotScale = 3
portalScreenshotDir = "docs/static/images"
portalScreenshotEnv = "AUTHELIA_PORTAL_SCREENSHOTS"
)
// TestPortalScreenshots regenerates the portal screenshots from a running DuoPush suite, which is
// the suite that offers all three second factor methods. It rewrites tracked files, so it only runs
// when portalScreenshotEnv is set:
//
// authelia-scripts suites setup DuoPush
// AUTHELIA_PORTAL_SCREENSHOTS=true go test -count=1 -v ./internal/suites -run '^TestPortalScreenshots$'
//
// The sign in pages are reproducible byte for byte. The method selection dialogs are not, because
// the time-based one-time password icon draws the portion of the period that remains, so those two
// images differ by the handful of pixels that wedge covers from one run to the next.
func TestPortalScreenshots(t *testing.T) {
if os.Getenv(portalScreenshotEnv) == "" {
t.Skipf("skipping portal screenshot generation, set %s to regenerate", portalScreenshotEnv)
}
session, err := NewRodSession(RodSessionWithoutDevtools())
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, session.Stop())
})
cwd, err := os.Getwd()
require.NoError(t, err)
dir := filepath.Join(strings.TrimSuffix(cwd, "/internal/suites"), portalScreenshotDir)
for _, theme := range []string{"light", "dark"} {
t.Run(theme, func(t *testing.T) {
session.doCapturePortalScreenshots(t, dir, theme)
})
}
}
// doCapturePortalScreenshots writes the sign in page and the second factor method selection dialog
// for a single theme.
func (rs *RodSession) doCapturePortalScreenshots(t *testing.T, dir, theme string) {
page := rs.doCreateTab(t, LoginBaseURL)
defer page.MustClose()
page.MustSetViewport(portalScreenshotWidth, portalScreenshotHeight, portalScreenshotScale, false)
// The stored theme takes precedence over the one the server supplies, so the capture does not
// depend on the suite's configured theme.
_, err := page.Eval(fmt.Sprintf(`() => localStorage.setItem("theme.name", %q)`, theme))
require.NoError(t, err)
rs.doVisitLoginPage(t, page, BaseDomain, "")
rs.WaitElementLocatedByID(t, page, "username-textfield")
rs.WaitForVisualStable(t, page)
// The portal focuses the username field on load, which would leave a focus ring on the image.
_, err = page.Eval(`() => document.activeElement instanceof HTMLElement && document.activeElement.blur()`)
require.NoError(t, err)
rs.doWaitForAnimations(t, page)
doWritePortalScreenshot(t, dir, fmt.Sprintf("%s.png", theme), rs.ViewportScreenshot(t, page))
rs.doFillLoginPageAndClick(t, page, testUsername, testPassword, false)
rs.ClickElementLocatedByID(t, page, "methods-button")
rs.WaitElementLocatedByID(t, page, "methods-dialog")
rs.WaitForVisualStable(t, page)
rs.doWaitForAnimations(t, page)
doWritePortalScreenshot(t, dir, fmt.Sprintf("2fa-methods-%s.png", theme), rs.ViewportScreenshot(t, page))
}
// doWaitForAnimations blocks until every running transition and animation has finished, so a dialog
// is captured fully opened rather than part way through its opening transition.
func (rs *RodSession) doWaitForAnimations(t *testing.T, page *rod.Page) {
_, err := page.Eval(`async () => {
await new Promise(resolve => requestAnimationFrame(resolve));
await Promise.all(document.getAnimations().map(animation => animation.finished.catch(() => {})));
await new Promise(resolve => requestAnimationFrame(resolve));
return true;
}`)
require.NoError(t, err)
}
func doWritePortalScreenshot(t *testing.T, dir, name string, screenshot []byte) {
path := filepath.Join(dir, name)
require.NoError(t, os.WriteFile(path, screenshot, 0600))
t.Logf("wrote %s (%d bytes)", path, len(screenshot))
}
+22 -3
View File
@@ -632,9 +632,8 @@ func (rs *RodSession) SetColorScheme(t *testing.T, page *rod.Page, scheme string
require.NoError(t, err)
}
// FullPageScreenshot captures a PNG of the full scrollable page with scrollbars hidden
// so width deltas don't flap between runs.
func (rs *RodSession) FullPageScreenshot(t *testing.T, page *rod.Page) []byte {
// doHideScrollbars suppresses the scrollbars so width deltas don't flap between runs.
func (rs *RodSession) doHideScrollbars(t *testing.T, page *rod.Page) {
_, err := page.Eval(`() => new Promise(resolve => {
const style = document.createElement('style');
style.textContent = 'html { scrollbar-width: none; } html::-webkit-scrollbar { display: none; }';
@@ -642,6 +641,12 @@ func (rs *RodSession) FullPageScreenshot(t *testing.T, page *rod.Page) []byte {
requestAnimationFrame(() => resolve(true));
})`)
require.NoError(t, err)
}
// FullPageScreenshot captures a PNG of the full scrollable page with scrollbars hidden
// so width deltas don't flap between runs.
func (rs *RodSession) FullPageScreenshot(t *testing.T, page *rod.Page) []byte {
rs.doHideScrollbars(t, page)
screenshot, err := page.Screenshot(true, &proto.PageCaptureScreenshot{
Format: proto.PageCaptureScreenshotFormatPng,
@@ -651,6 +656,20 @@ func (rs *RodSession) FullPageScreenshot(t *testing.T, page *rod.Page) []byte {
return screenshot
}
// ViewportScreenshot captures a PNG of the visible viewport with scrollbars hidden. The
// capture is emitted at the viewport scale factor, so a 450x600 viewport set to a scale of
// 3 yields a 1350x1800 image.
func (rs *RodSession) ViewportScreenshot(t *testing.T, page *rod.Page) []byte {
rs.doHideScrollbars(t, page)
screenshot, err := page.Screenshot(false, &proto.PageCaptureScreenshot{
Format: proto.PageCaptureScreenshotFormatPng,
})
require.NoError(t, err)
return screenshot
}
// DoAndWaitForNavigation runs action and blocks until the next main-frame navigation fires,
// returning the destination URL from the CDP event.
func (rs *RodSession) DoAndWaitForNavigation(ctx context.Context, page *rod.Page, action func()) string {