mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-17 21:52:45 +00:00
Compare commits
3 Commits
script
...
ods/v0.5.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ddf4fb8fe | ||
|
|
f65a4c770f | ||
|
|
37536e8563 |
110
.github/workflows/pr-playwright-tests.yml
vendored
110
.github/workflows/pr-playwright-tests.yml
vendored
@@ -15,6 +15,8 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for OIDC-based AWS credential exchange (S3 access)
|
||||
pull-requests: write # Required for posting visual diff PR comments
|
||||
|
||||
env:
|
||||
# Test Environment Variables
|
||||
@@ -428,8 +430,6 @@ jobs:
|
||||
env:
|
||||
PROJECT: ${{ matrix.project }}
|
||||
run: |
|
||||
# Create test-results directory to ensure it exists for artifact upload
|
||||
mkdir -p test-results
|
||||
npx playwright test --project ${PROJECT}
|
||||
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
@@ -437,9 +437,113 @@ jobs:
|
||||
with:
|
||||
# Includes test results and trace.zip files
|
||||
name: playwright-test-results-${{ matrix.project }}-${{ github.run_id }}
|
||||
path: ./web/test-results/
|
||||
path: ./web/output/playwright/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload screenshots
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-screenshots-${{ matrix.project }}-${{ github.run_id }}
|
||||
path: ./web/screenshots/
|
||||
retention-days: 30
|
||||
|
||||
# --- Visual Regression Diff ---
|
||||
- name: Configure AWS credentials
|
||||
if: always()
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Setup Go
|
||||
if: always()
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # ratchet:actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: tools/ods/go.mod
|
||||
cache-dependency-path: tools/ods/go.sum
|
||||
|
||||
- name: Build ods
|
||||
if: always()
|
||||
run: cd tools/ods && go build -o "${GITHUB_WORKSPACE}/ods-bin" . && chmod +x "${GITHUB_WORKSPACE}/ods-bin"
|
||||
|
||||
- name: Generate visual diff report
|
||||
if: always()
|
||||
env:
|
||||
PROJECT: ${{ matrix.project }}
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE}/ods-bin" playwright-diff compare \
|
||||
--baseline "s3://onyx-internal-tools/playwright-baselines/${PROJECT}/" \
|
||||
--current ./web/screenshots/ \
|
||||
--output ./web/output/visual-diff/index.html
|
||||
|
||||
- name: Upload visual diff report to S3
|
||||
if: always()
|
||||
env:
|
||||
PROJECT: ${{ matrix.project }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
aws s3 sync ./web/output/visual-diff/ \
|
||||
"s3://onyx-internal-tools/playwright-reports/pr-${PR_NUMBER}/${RUN_ID}/${PROJECT}/"
|
||||
|
||||
- name: Upload visual diff report artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
if: always()
|
||||
with:
|
||||
name: visual-diff-report-${{ matrix.project }}-${{ github.run_id }}
|
||||
path: ./web/output/visual-diff/
|
||||
retention-days: 30
|
||||
|
||||
- name: Comment visual diff on PR
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
PROJECT: ${{ matrix.project }}
|
||||
run: |
|
||||
REPORT_URL="https://onyx-internal-tools.s3.us-east-2.amazonaws.com/playwright-reports/pr-${PR_NUMBER}/${RUN_ID}/${PROJECT}/index.html"
|
||||
MARKER="<!-- visual-regression-${PROJECT} -->"
|
||||
|
||||
BODY=$(cat <<EOF
|
||||
${MARKER}
|
||||
### Visual Regression Report (\`${PROJECT}\`)
|
||||
|
||||
**Run:** [#${RUN_ID}](https://github.com/${{ github.repository }}/actions/runs/${RUN_ID})
|
||||
|
||||
[View Visual Diff Report](${REPORT_URL})
|
||||
|
||||
_This report is informational only and does not block the PR._
|
||||
EOF
|
||||
)
|
||||
|
||||
# Check if a comment with this marker already exists
|
||||
EXISTING_COMMENT_ID=$(gh api \
|
||||
"repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
|
||||
--jq ".[] | select(.body | startswith(\"${MARKER}\")) | .id" \
|
||||
2>/dev/null | head -1)
|
||||
|
||||
if [ -n "${EXISTING_COMMENT_ID}" ]; then
|
||||
gh api \
|
||||
--method PATCH \
|
||||
"repos/${{ github.repository }}/issues/comments/${EXISTING_COMMENT_ID}" \
|
||||
-f body="${BODY}"
|
||||
else
|
||||
gh api \
|
||||
--method POST \
|
||||
"repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
|
||||
-f body="${BODY}"
|
||||
fi
|
||||
|
||||
- name: Update S3 baselines
|
||||
if: github.ref == 'refs/heads/main' && success()
|
||||
env:
|
||||
PROJECT: ${{ matrix.project }}
|
||||
run: |
|
||||
aws s3 sync ./web/screenshots/ \
|
||||
"s3://onyx-internal-tools/playwright-baselines/${PROJECT}/" --delete
|
||||
|
||||
# save before stopping the containers so the logs can be captured
|
||||
- name: Save Docker logs
|
||||
if: success() || failure()
|
||||
|
||||
266
tools/ods/cmd/playwright_diff.go
Normal file
266
tools/ods/cmd/playwright_diff.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/onyx-dot-app/onyx/tools/ods/internal/imgdiff"
|
||||
"github.com/onyx-dot-app/onyx/tools/ods/internal/s3"
|
||||
)
|
||||
|
||||
// PlaywrightDiffCompareOptions holds options for the compare subcommand.
|
||||
type PlaywrightDiffCompareOptions struct {
|
||||
Baseline string
|
||||
Current string
|
||||
Output string
|
||||
Threshold float64
|
||||
MaxDiffRatio float64
|
||||
}
|
||||
|
||||
// PlaywrightDiffUploadOptions holds options for the upload-baselines subcommand.
|
||||
type PlaywrightDiffUploadOptions struct {
|
||||
Dir string
|
||||
Dest string
|
||||
Delete bool
|
||||
}
|
||||
|
||||
// NewPlaywrightDiffCommand creates the playwright-diff command with subcommands.
|
||||
func NewPlaywrightDiffCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "playwright-diff",
|
||||
Short: "Visual regression testing for Playwright screenshots",
|
||||
Long: `Compare Playwright screenshots against baselines and generate visual diff reports.
|
||||
|
||||
Supports comparing local directories and downloading baselines from S3.
|
||||
The generated HTML report is self-contained (images base64-inlined) and can
|
||||
be opened locally or hosted on S3.
|
||||
|
||||
Example usage:
|
||||
|
||||
# Compare local directories
|
||||
ods playwright-diff compare --baseline ./baselines --current ./screenshots
|
||||
|
||||
# Compare against S3 baselines
|
||||
ods playwright-diff compare --baseline s3://bucket/baselines/admin/ --current ./screenshots
|
||||
|
||||
# Upload new baselines to S3
|
||||
ods playwright-diff upload-baselines --dir ./screenshots --dest s3://bucket/baselines/admin/`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
_ = cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newCompareCommand())
|
||||
cmd.AddCommand(newUploadBaselinesCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCompareCommand() *cobra.Command {
|
||||
opts := &PlaywrightDiffCompareOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "compare",
|
||||
Short: "Compare screenshots against baselines and generate a diff report",
|
||||
Long: `Compare current screenshots against baseline screenshots and produce
|
||||
a self-contained HTML visual diff report.
|
||||
|
||||
The --baseline flag accepts either a local directory path or an S3 URL
|
||||
(s3://bucket/prefix/). When an S3 URL is provided, baselines are downloaded
|
||||
to a temporary directory before comparison.
|
||||
|
||||
Examples:
|
||||
|
||||
# Local comparison
|
||||
ods playwright-diff compare \
|
||||
--baseline ./baselines \
|
||||
--current ./screenshots \
|
||||
--output ./report/index.html
|
||||
|
||||
# Compare against S3 baselines
|
||||
ods playwright-diff compare \
|
||||
--baseline s3://onyx-internal-tools/playwright-baselines/admin/ \
|
||||
--current ./web/screenshots/ \
|
||||
--output ./visual-diff/index.html`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runCompare(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Baseline, "baseline", "", "Baseline directory or S3 URL (s3://...)")
|
||||
cmd.Flags().StringVar(&opts.Current, "current", "", "Current screenshots directory")
|
||||
cmd.Flags().StringVar(&opts.Output, "output", "visual-diff/index.html", "Output path for the HTML report")
|
||||
cmd.Flags().Float64Var(&opts.Threshold, "threshold", 0.2, "Per-channel pixel difference threshold (0.0-1.0)")
|
||||
cmd.Flags().Float64Var(&opts.MaxDiffRatio, "max-diff-ratio", 0.01, "Max diff pixel ratio before marking as changed (informational)")
|
||||
|
||||
_ = cmd.MarkFlagRequired("baseline")
|
||||
_ = cmd.MarkFlagRequired("current")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newUploadBaselinesCommand() *cobra.Command {
|
||||
opts := &PlaywrightDiffUploadOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "upload-baselines",
|
||||
Short: "Upload screenshots to S3 as new baselines",
|
||||
Long: `Upload a local directory of screenshots to S3 to serve as the new
|
||||
baseline for future comparisons. Typically run after tests pass on the
|
||||
main branch.
|
||||
|
||||
Examples:
|
||||
|
||||
# Upload to default location
|
||||
ods playwright-diff upload-baselines \
|
||||
--dir ./web/screenshots/ \
|
||||
--dest s3://onyx-internal-tools/playwright-baselines/admin/
|
||||
|
||||
# Upload with delete (remove old baselines not in current set)
|
||||
ods playwright-diff upload-baselines \
|
||||
--dir ./web/screenshots/ \
|
||||
--dest s3://onyx-internal-tools/playwright-baselines/admin/ \
|
||||
--delete`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runUploadBaselines(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Dir, "dir", "", "Local directory containing screenshots to upload")
|
||||
cmd.Flags().StringVar(&opts.Dest, "dest", "", "S3 destination URL (s3://...)")
|
||||
cmd.Flags().BoolVar(&opts.Delete, "delete", false, "Delete S3 files not present locally")
|
||||
|
||||
_ = cmd.MarkFlagRequired("dir")
|
||||
_ = cmd.MarkFlagRequired("dest")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runCompare(opts *PlaywrightDiffCompareOptions) {
|
||||
baselineDir := opts.Baseline
|
||||
|
||||
// If baseline is an S3 URL, download to a temp directory
|
||||
if strings.HasPrefix(opts.Baseline, "s3://") {
|
||||
tmpDir, err := os.MkdirTemp("", "playwright-baselines-*")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
if err := s3.SyncDown(opts.Baseline, tmpDir); err != nil {
|
||||
log.Fatalf("Failed to download baselines from S3: %v", err)
|
||||
}
|
||||
baselineDir = tmpDir
|
||||
}
|
||||
|
||||
// Verify directories exist
|
||||
if _, err := os.Stat(baselineDir); os.IsNotExist(err) {
|
||||
log.Warnf("Baseline directory does not exist: %s", baselineDir)
|
||||
log.Warn("This may be the first run -- no baselines to compare against.")
|
||||
// Create an empty dir so CompareDirectories works (all files will be "added")
|
||||
if err := os.MkdirAll(baselineDir, 0755); err != nil {
|
||||
log.Fatalf("Failed to create baseline directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(opts.Current); os.IsNotExist(err) {
|
||||
log.Fatalf("Current screenshots directory does not exist: %s", opts.Current)
|
||||
}
|
||||
|
||||
log.Infof("Comparing screenshots...")
|
||||
log.Infof(" Baseline: %s", baselineDir)
|
||||
log.Infof(" Current: %s", opts.Current)
|
||||
log.Infof(" Threshold: %.2f", opts.Threshold)
|
||||
|
||||
results, err := imgdiff.CompareDirectories(baselineDir, opts.Current, opts.Threshold)
|
||||
if err != nil {
|
||||
log.Fatalf("Comparison failed: %v", err)
|
||||
}
|
||||
|
||||
// Print terminal summary
|
||||
printSummary(results)
|
||||
|
||||
// Generate HTML report
|
||||
outputPath := opts.Output
|
||||
if !filepath.IsAbs(outputPath) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
outputPath = filepath.Join(cwd, outputPath)
|
||||
}
|
||||
|
||||
log.Infof("Generating report: %s", outputPath)
|
||||
if err := imgdiff.GenerateReport(results, outputPath); err != nil {
|
||||
log.Fatalf("Failed to generate report: %v", err)
|
||||
}
|
||||
|
||||
log.Infof("Report generated successfully: %s", outputPath)
|
||||
}
|
||||
|
||||
func runUploadBaselines(opts *PlaywrightDiffUploadOptions) {
|
||||
if _, err := os.Stat(opts.Dir); os.IsNotExist(err) {
|
||||
log.Fatalf("Screenshots directory does not exist: %s", opts.Dir)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(opts.Dest, "s3://") {
|
||||
log.Fatalf("Destination must be an S3 URL (s3://...): %s", opts.Dest)
|
||||
}
|
||||
|
||||
log.Infof("Uploading baselines...")
|
||||
log.Infof(" Source: %s", opts.Dir)
|
||||
log.Infof(" Dest: %s", opts.Dest)
|
||||
|
||||
if err := s3.SyncUp(opts.Dir, opts.Dest, opts.Delete); err != nil {
|
||||
log.Fatalf("Failed to upload baselines: %v", err)
|
||||
}
|
||||
|
||||
log.Info("Baselines uploaded successfully.")
|
||||
}
|
||||
|
||||
func printSummary(results []imgdiff.Result) {
|
||||
changed, added, removed, unchanged := 0, 0, 0, 0
|
||||
for _, r := range results {
|
||||
switch r.Status {
|
||||
case imgdiff.StatusChanged:
|
||||
changed++
|
||||
case imgdiff.StatusAdded:
|
||||
added++
|
||||
case imgdiff.StatusRemoved:
|
||||
removed++
|
||||
case imgdiff.StatusUnchanged:
|
||||
unchanged++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("╔══════════════════════════════════════════════╗")
|
||||
fmt.Println("║ Visual Regression Summary ║")
|
||||
fmt.Println("╠══════════════════════════════════════════════╣")
|
||||
fmt.Printf("║ Changed: %-32d ║\n", changed)
|
||||
fmt.Printf("║ Added: %-32d ║\n", added)
|
||||
fmt.Printf("║ Removed: %-32d ║\n", removed)
|
||||
fmt.Printf("║ Unchanged: %-32d ║\n", unchanged)
|
||||
fmt.Printf("║ Total: %-32d ║\n", len(results))
|
||||
fmt.Println("╚══════════════════════════════════════════════╝")
|
||||
fmt.Println()
|
||||
|
||||
if changed > 0 || added > 0 || removed > 0 {
|
||||
for _, r := range results {
|
||||
switch r.Status {
|
||||
case imgdiff.StatusChanged:
|
||||
fmt.Printf(" ⚠ CHANGED %s (%.2f%% diff)\n", r.Name, r.DiffPercent)
|
||||
case imgdiff.StatusAdded:
|
||||
fmt.Printf(" ✚ ADDED %s\n", r.Name)
|
||||
case imgdiff.StatusRemoved:
|
||||
fmt.Printf(" ✖ REMOVED %s\n", r.Name)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ func NewRootCommand() *cobra.Command {
|
||||
cmd.AddCommand(NewLogsCommand())
|
||||
cmd.AddCommand(NewPullCommand())
|
||||
cmd.AddCommand(NewRunCICommand())
|
||||
cmd.AddCommand(NewPlaywrightDiffCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
321
tools/ods/internal/imgdiff/compare.go
Normal file
321
tools/ods/internal/imgdiff/compare.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package imgdiff
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Status represents the comparison status of a screenshot.
|
||||
type Status int
|
||||
|
||||
const (
|
||||
// StatusUnchanged means the baseline and current images are identical (within threshold).
|
||||
StatusUnchanged Status = iota
|
||||
// StatusChanged means the images differ beyond the threshold.
|
||||
StatusChanged
|
||||
// StatusAdded means the image exists only in the current directory (no baseline).
|
||||
StatusAdded
|
||||
// StatusRemoved means the image exists only in the baseline directory (no current).
|
||||
StatusRemoved
|
||||
)
|
||||
|
||||
// String returns a human-readable string for the status.
|
||||
func (s Status) String() string {
|
||||
switch s {
|
||||
case StatusUnchanged:
|
||||
return "unchanged"
|
||||
case StatusChanged:
|
||||
return "changed"
|
||||
case StatusAdded:
|
||||
return "added"
|
||||
case StatusRemoved:
|
||||
return "removed"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Result holds the comparison result for a single screenshot.
|
||||
type Result struct {
|
||||
// Name is the filename of the screenshot (e.g. "admin-documents-explorer.png").
|
||||
Name string
|
||||
|
||||
// Status is the comparison status.
|
||||
Status Status
|
||||
|
||||
// DiffPercent is the percentage of pixels that differ (0.0 to 100.0).
|
||||
DiffPercent float64
|
||||
|
||||
// DiffPixels is the number of pixels that differ.
|
||||
DiffPixels int
|
||||
|
||||
// TotalPixels is the total number of pixels compared.
|
||||
TotalPixels int
|
||||
|
||||
// BaselinePath is the path to the baseline image (empty if added).
|
||||
BaselinePath string
|
||||
|
||||
// CurrentPath is the path to the current image (empty if removed).
|
||||
CurrentPath string
|
||||
|
||||
// DiffImage is the generated diff overlay image (nil if unchanged, added, or removed).
|
||||
DiffImage image.Image
|
||||
}
|
||||
|
||||
// Compare compares two PNG images pixel-by-pixel and returns the result.
|
||||
// The threshold parameter (0.0 to 1.0) controls per-channel sensitivity:
|
||||
// a pixel is considered different if any channel differs by more than threshold * 255.
|
||||
func Compare(baselinePath, currentPath string, threshold float64) (*Result, error) {
|
||||
baseline, err := decodePNG(baselinePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode baseline %s: %w", baselinePath, err)
|
||||
}
|
||||
|
||||
current, err := decodePNG(currentPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode current %s: %w", currentPath, err)
|
||||
}
|
||||
|
||||
baselineBounds := baseline.Bounds()
|
||||
currentBounds := current.Bounds()
|
||||
|
||||
// Use the larger dimensions to ensure we compare the full area
|
||||
width := max(baselineBounds.Dx(), currentBounds.Dx())
|
||||
height := max(baselineBounds.Dy(), currentBounds.Dy())
|
||||
totalPixels := width * height
|
||||
|
||||
if totalPixels == 0 {
|
||||
return &Result{
|
||||
Name: filepath.Base(currentPath),
|
||||
Status: StatusUnchanged,
|
||||
BaselinePath: baselinePath,
|
||||
CurrentPath: currentPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
diffImage := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
diffPixels := 0
|
||||
thresholdValue := threshold * 255.0
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
// Get pixel from each image (transparent if out of bounds)
|
||||
var br, bg, bb, ba uint32
|
||||
var cr, cg, cb, ca uint32
|
||||
|
||||
if x < baselineBounds.Dx() && y < baselineBounds.Dy() {
|
||||
br, bg, bb, ba = baseline.At(baselineBounds.Min.X+x, baselineBounds.Min.Y+y).RGBA()
|
||||
}
|
||||
if x < currentBounds.Dx() && y < currentBounds.Dy() {
|
||||
cr, cg, cb, ca = current.At(currentBounds.Min.X+x, currentBounds.Min.Y+y).RGBA()
|
||||
}
|
||||
|
||||
// Convert from 16-bit to 8-bit
|
||||
br8 := float64(br >> 8)
|
||||
bg8 := float64(bg >> 8)
|
||||
bb8 := float64(bb >> 8)
|
||||
ba8 := float64(ba >> 8)
|
||||
cr8 := float64(cr >> 8)
|
||||
cg8 := float64(cg >> 8)
|
||||
cb8 := float64(cb >> 8)
|
||||
ca8 := float64(ca >> 8)
|
||||
|
||||
// Check if channels differ beyond threshold
|
||||
isDiff := math.Abs(br8-cr8) > thresholdValue ||
|
||||
math.Abs(bg8-cg8) > thresholdValue ||
|
||||
math.Abs(bb8-cb8) > thresholdValue ||
|
||||
math.Abs(ba8-ca8) > thresholdValue
|
||||
|
||||
if isDiff {
|
||||
diffPixels++
|
||||
// Highlight in magenta for diff overlay
|
||||
diffImage.Set(x, y, color.RGBA{R: 255, G: 0, B: 255, A: 255})
|
||||
} else {
|
||||
// Dim the unchanged pixel (30% opacity of the current image)
|
||||
diffImage.Set(x, y, color.RGBA{
|
||||
R: uint8(cr8 * 0.3),
|
||||
G: uint8(cg8 * 0.3),
|
||||
B: uint8(cb8 * 0.3),
|
||||
A: uint8(math.Max(ca8*0.3, 50)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
diffPercent := float64(diffPixels) / float64(totalPixels) * 100.0
|
||||
|
||||
status := StatusUnchanged
|
||||
if diffPixels > 0 {
|
||||
status = StatusChanged
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Name: filepath.Base(currentPath),
|
||||
Status: status,
|
||||
DiffPercent: diffPercent,
|
||||
DiffPixels: diffPixels,
|
||||
TotalPixels: totalPixels,
|
||||
BaselinePath: baselinePath,
|
||||
CurrentPath: currentPath,
|
||||
DiffImage: diffImage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CompareDirectories compares all PNG files in two directories.
|
||||
// Files are matched by name. Files only in baseline are "removed",
|
||||
// files only in current are "added", and matching files are compared.
|
||||
func CompareDirectories(baselineDir, currentDir string, threshold float64) ([]Result, error) {
|
||||
baselineFiles, err := listPNGs(baselineDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list baseline directory: %w", err)
|
||||
}
|
||||
|
||||
currentFiles, err := listPNGs(currentDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list current directory: %w", err)
|
||||
}
|
||||
|
||||
// Build maps for lookup
|
||||
baselineMap := make(map[string]string, len(baselineFiles))
|
||||
for _, f := range baselineFiles {
|
||||
baselineMap[filepath.Base(f)] = f
|
||||
}
|
||||
|
||||
currentMap := make(map[string]string, len(currentFiles))
|
||||
for _, f := range currentFiles {
|
||||
currentMap[filepath.Base(f)] = f
|
||||
}
|
||||
|
||||
// Collect all unique names
|
||||
allNames := make(map[string]struct{})
|
||||
for name := range baselineMap {
|
||||
allNames[name] = struct{}{}
|
||||
}
|
||||
for name := range currentMap {
|
||||
allNames[name] = struct{}{}
|
||||
}
|
||||
|
||||
var results []Result
|
||||
|
||||
for name := range allNames {
|
||||
baselinePath, inBaseline := baselineMap[name]
|
||||
currentPath, inCurrent := currentMap[name]
|
||||
|
||||
switch {
|
||||
case inBaseline && inCurrent:
|
||||
result, err := Compare(baselinePath, currentPath, threshold)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compare %s: %w", name, err)
|
||||
}
|
||||
results = append(results, *result)
|
||||
|
||||
case inBaseline && !inCurrent:
|
||||
results = append(results, Result{
|
||||
Name: name,
|
||||
Status: StatusRemoved,
|
||||
BaselinePath: baselinePath,
|
||||
})
|
||||
|
||||
case !inBaseline && inCurrent:
|
||||
results = append(results, Result{
|
||||
Name: name,
|
||||
Status: StatusAdded,
|
||||
CurrentPath: currentPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: changed first (by diff % descending), then added, removed, unchanged
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
if results[i].Status != results[j].Status {
|
||||
return statusOrder(results[i].Status) < statusOrder(results[j].Status)
|
||||
}
|
||||
if results[i].Status == StatusChanged {
|
||||
return results[i].DiffPercent > results[j].DiffPercent
|
||||
}
|
||||
return results[i].Name < results[j].Name
|
||||
})
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SaveDiffImage writes a diff overlay image to the specified path as PNG.
|
||||
func SaveDiffImage(img image.Image, path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
return fmt.Errorf("failed to encode PNG: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodePNG reads and decodes a PNG file.
|
||||
func decodePNG(path string) (image.Image, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
img, err := png.Decode(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// listPNGs returns all .png files in a directory (non-recursive).
|
||||
func listPNGs(dir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pngs []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(entry.Name()), ".png") {
|
||||
pngs = append(pngs, filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
return pngs, nil
|
||||
}
|
||||
|
||||
// statusOrder returns a sort priority for each status.
|
||||
func statusOrder(s Status) int {
|
||||
switch s {
|
||||
case StatusChanged:
|
||||
return 0
|
||||
case StatusAdded:
|
||||
return 1
|
||||
case StatusRemoved:
|
||||
return 2
|
||||
case StatusUnchanged:
|
||||
return 3
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
309
tools/ods/internal/imgdiff/compare_test.go
Normal file
309
tools/ods/internal/imgdiff/compare_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package imgdiff
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// createTestPNG creates a solid-color PNG file at the given path.
|
||||
func createTestPNG(t *testing.T, path string, width, height int, c color.Color) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatalf("failed to create dir: %v", err)
|
||||
}
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
t.Fatalf("failed to encode PNG: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// createTestPNGWithBlock creates a PNG with a colored block at the specified position.
|
||||
func createTestPNGWithBlock(t *testing.T, path string, width, height int, bg, block color.Color, bx, by, bw, bh int) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatalf("failed to create dir: %v", err)
|
||||
}
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
if x >= bx && x < bx+bw && y >= by && y < by+bh {
|
||||
img.Set(x, y, block)
|
||||
} else {
|
||||
img.Set(x, y, bg)
|
||||
}
|
||||
}
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
t.Fatalf("failed to encode PNG: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompare_IdenticalImages(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
baselinePath := filepath.Join(dir, "baseline.png")
|
||||
currentPath := filepath.Join(dir, "current.png")
|
||||
|
||||
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
createTestPNG(t, baselinePath, 100, 100, white)
|
||||
createTestPNG(t, currentPath, 100, 100, white)
|
||||
|
||||
result, err := Compare(baselinePath, currentPath, 0.2)
|
||||
if err != nil {
|
||||
t.Fatalf("Compare failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Status != StatusUnchanged {
|
||||
t.Errorf("expected StatusUnchanged, got %s", result.Status)
|
||||
}
|
||||
if result.DiffPercent != 0.0 {
|
||||
t.Errorf("expected 0%% diff, got %.2f%%", result.DiffPercent)
|
||||
}
|
||||
if result.DiffPixels != 0 {
|
||||
t.Errorf("expected 0 diff pixels, got %d", result.DiffPixels)
|
||||
}
|
||||
if result.TotalPixels != 10000 {
|
||||
t.Errorf("expected 10000 total pixels, got %d", result.TotalPixels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompare_DifferentImages(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
baselinePath := filepath.Join(dir, "baseline.png")
|
||||
currentPath := filepath.Join(dir, "current.png")
|
||||
|
||||
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
red := color.RGBA{R: 255, G: 0, B: 0, A: 255}
|
||||
|
||||
// Baseline: all white
|
||||
createTestPNG(t, baselinePath, 100, 100, white)
|
||||
// Current: white with a 10x10 red block (100 pixels different)
|
||||
createTestPNGWithBlock(t, currentPath, 100, 100, white, red, 0, 0, 10, 10)
|
||||
|
||||
result, err := Compare(baselinePath, currentPath, 0.2)
|
||||
if err != nil {
|
||||
t.Fatalf("Compare failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Status != StatusChanged {
|
||||
t.Errorf("expected StatusChanged, got %s", result.Status)
|
||||
}
|
||||
if result.DiffPixels != 100 {
|
||||
t.Errorf("expected 100 diff pixels, got %d", result.DiffPixels)
|
||||
}
|
||||
if result.DiffPercent != 1.0 {
|
||||
t.Errorf("expected 1.0%% diff, got %.2f%%", result.DiffPercent)
|
||||
}
|
||||
if result.DiffImage == nil {
|
||||
t.Error("expected non-nil DiffImage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompare_SubtleDifferenceBelowThreshold(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
baselinePath := filepath.Join(dir, "baseline.png")
|
||||
currentPath := filepath.Join(dir, "current.png")
|
||||
|
||||
// Two very similar colors -- difference of 10 on one channel
|
||||
c1 := color.RGBA{R: 200, G: 200, B: 200, A: 255}
|
||||
c2 := color.RGBA{R: 210, G: 200, B: 200, A: 255}
|
||||
|
||||
createTestPNG(t, baselinePath, 10, 10, c1)
|
||||
createTestPNG(t, currentPath, 10, 10, c2)
|
||||
|
||||
// Threshold 0.2 = 51 pixel value difference. 10 < 51, so should be unchanged.
|
||||
result, err := Compare(baselinePath, currentPath, 0.2)
|
||||
if err != nil {
|
||||
t.Fatalf("Compare failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Status != StatusUnchanged {
|
||||
t.Errorf("expected StatusUnchanged (diff below threshold), got %s", result.Status)
|
||||
}
|
||||
if result.DiffPixels != 0 {
|
||||
t.Errorf("expected 0 diff pixels (below threshold), got %d", result.DiffPixels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompare_DifferentSizes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
baselinePath := filepath.Join(dir, "baseline.png")
|
||||
currentPath := filepath.Join(dir, "current.png")
|
||||
|
||||
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
createTestPNG(t, baselinePath, 100, 100, white)
|
||||
createTestPNG(t, currentPath, 100, 120, white) // Taller
|
||||
|
||||
result, err := Compare(baselinePath, currentPath, 0.2)
|
||||
if err != nil {
|
||||
t.Fatalf("Compare failed: %v", err)
|
||||
}
|
||||
|
||||
// The extra 20 rows (2000 pixels) should be "different" (white vs transparent/zero)
|
||||
if result.Status != StatusChanged {
|
||||
t.Errorf("expected StatusChanged for different sizes, got %s", result.Status)
|
||||
}
|
||||
if result.TotalPixels != 12000 { // 100*120
|
||||
t.Errorf("expected 12000 total pixels, got %d", result.TotalPixels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDirectories(t *testing.T) {
|
||||
baselineDir := filepath.Join(t.TempDir(), "baseline")
|
||||
currentDir := filepath.Join(t.TempDir(), "current")
|
||||
|
||||
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
red := color.RGBA{R: 255, G: 0, B: 0, A: 255}
|
||||
blue := color.RGBA{R: 0, G: 0, B: 255, A: 255}
|
||||
|
||||
// shared-unchanged.png: identical in both
|
||||
createTestPNG(t, filepath.Join(baselineDir, "shared-unchanged.png"), 10, 10, white)
|
||||
createTestPNG(t, filepath.Join(currentDir, "shared-unchanged.png"), 10, 10, white)
|
||||
|
||||
// shared-changed.png: different in both
|
||||
createTestPNG(t, filepath.Join(baselineDir, "shared-changed.png"), 10, 10, white)
|
||||
createTestPNG(t, filepath.Join(currentDir, "shared-changed.png"), 10, 10, red)
|
||||
|
||||
// removed.png: only in baseline
|
||||
createTestPNG(t, filepath.Join(baselineDir, "removed.png"), 10, 10, white)
|
||||
|
||||
// added.png: only in current
|
||||
createTestPNG(t, filepath.Join(currentDir, "added.png"), 10, 10, blue)
|
||||
|
||||
results, err := CompareDirectories(baselineDir, currentDir, 0.2)
|
||||
if err != nil {
|
||||
t.Fatalf("CompareDirectories failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 4 {
|
||||
t.Fatalf("expected 4 results, got %d", len(results))
|
||||
}
|
||||
|
||||
// Results should be sorted: changed first, then added, removed, unchanged
|
||||
statusCounts := map[Status]int{}
|
||||
for _, r := range results {
|
||||
statusCounts[r.Status]++
|
||||
}
|
||||
|
||||
if statusCounts[StatusChanged] != 1 {
|
||||
t.Errorf("expected 1 changed, got %d", statusCounts[StatusChanged])
|
||||
}
|
||||
if statusCounts[StatusAdded] != 1 {
|
||||
t.Errorf("expected 1 added, got %d", statusCounts[StatusAdded])
|
||||
}
|
||||
if statusCounts[StatusRemoved] != 1 {
|
||||
t.Errorf("expected 1 removed, got %d", statusCounts[StatusRemoved])
|
||||
}
|
||||
if statusCounts[StatusUnchanged] != 1 {
|
||||
t.Errorf("expected 1 unchanged, got %d", statusCounts[StatusUnchanged])
|
||||
}
|
||||
|
||||
// First result should be the changed one (sort order)
|
||||
if results[0].Status != StatusChanged {
|
||||
t.Errorf("expected first result to be changed, got %s", results[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDirectories_EmptyBaseline(t *testing.T) {
|
||||
baselineDir := filepath.Join(t.TempDir(), "baseline")
|
||||
currentDir := filepath.Join(t.TempDir(), "current")
|
||||
|
||||
if err := os.MkdirAll(baselineDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
createTestPNG(t, filepath.Join(currentDir, "new.png"), 10, 10, white)
|
||||
|
||||
results, err := CompareDirectories(baselineDir, currentDir, 0.2)
|
||||
if err != nil {
|
||||
t.Fatalf("CompareDirectories failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
if results[0].Status != StatusAdded {
|
||||
t.Errorf("expected StatusAdded, got %s", results[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReport(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
baselineDir := filepath.Join(dir, "baseline")
|
||||
currentDir := filepath.Join(dir, "current")
|
||||
|
||||
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
red := color.RGBA{R: 255, G: 0, B: 0, A: 255}
|
||||
|
||||
createTestPNG(t, filepath.Join(baselineDir, "page.png"), 50, 50, white)
|
||||
createTestPNG(t, filepath.Join(currentDir, "page.png"), 50, 50, red)
|
||||
|
||||
results, err := CompareDirectories(baselineDir, currentDir, 0.2)
|
||||
if err != nil {
|
||||
t.Fatalf("CompareDirectories failed: %v", err)
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(dir, "report", "index.html")
|
||||
if err := GenerateReport(results, outputPath); err != nil {
|
||||
t.Fatalf("GenerateReport failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file was created and has content
|
||||
info, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("report file not found: %v", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Error("report file is empty")
|
||||
}
|
||||
|
||||
// Verify it contains expected HTML elements
|
||||
content, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read report: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
for _, expected := range []string{
|
||||
"Visual Regression Report",
|
||||
"data:image/png;base64,",
|
||||
"page.png",
|
||||
"changed",
|
||||
} {
|
||||
if !contains(contentStr, expected) {
|
||||
t.Errorf("report missing expected content: %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchString(s, substr)
|
||||
}
|
||||
|
||||
func searchString(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
344
tools/ods/internal/imgdiff/report.go
Normal file
344
tools/ods/internal/imgdiff/report.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package imgdiff
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// reportEntry holds data for a single screenshot in the HTML template.
|
||||
type reportEntry struct {
|
||||
Name string
|
||||
Status string
|
||||
DiffPercent string
|
||||
BaselineDataURI template.URL
|
||||
CurrentDataURI template.URL
|
||||
DiffDataURI template.URL
|
||||
HasBaseline bool
|
||||
HasCurrent bool
|
||||
HasDiff bool
|
||||
}
|
||||
|
||||
// reportData holds all data for the HTML template.
|
||||
type reportData struct {
|
||||
Entries []reportEntry
|
||||
ChangedCount int
|
||||
AddedCount int
|
||||
RemovedCount int
|
||||
UnchangedCount int
|
||||
TotalCount int
|
||||
HasDifferences bool
|
||||
}
|
||||
|
||||
// GenerateReport produces a self-contained HTML file from comparison results.
|
||||
// All images are base64-encoded inline as data URIs.
|
||||
func GenerateReport(results []Result, outputPath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
data := reportData{}
|
||||
|
||||
for _, r := range results {
|
||||
entry := reportEntry{
|
||||
Name: r.Name,
|
||||
Status: r.Status.String(),
|
||||
}
|
||||
|
||||
switch r.Status {
|
||||
case StatusChanged:
|
||||
data.ChangedCount++
|
||||
entry.DiffPercent = fmt.Sprintf("%.2f%%", r.DiffPercent)
|
||||
case StatusAdded:
|
||||
data.AddedCount++
|
||||
case StatusRemoved:
|
||||
data.RemovedCount++
|
||||
case StatusUnchanged:
|
||||
data.UnchangedCount++
|
||||
entry.DiffPercent = "0.00%"
|
||||
}
|
||||
|
||||
if r.BaselinePath != "" {
|
||||
uri, err := pngFileToDataURI(r.BaselinePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode baseline %s: %w", r.Name, err)
|
||||
}
|
||||
entry.BaselineDataURI = template.URL(uri)
|
||||
entry.HasBaseline = true
|
||||
}
|
||||
|
||||
if r.CurrentPath != "" {
|
||||
uri, err := pngFileToDataURI(r.CurrentPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode current %s: %w", r.Name, err)
|
||||
}
|
||||
entry.CurrentDataURI = template.URL(uri)
|
||||
entry.HasCurrent = true
|
||||
}
|
||||
|
||||
if r.DiffImage != nil {
|
||||
uri, err := imageToDataURI(r.DiffImage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode diff %s: %w", r.Name, err)
|
||||
}
|
||||
entry.DiffDataURI = template.URL(uri)
|
||||
entry.HasDiff = true
|
||||
}
|
||||
|
||||
data.Entries = append(data.Entries, entry)
|
||||
}
|
||||
|
||||
data.TotalCount = len(results)
|
||||
data.HasDifferences = data.ChangedCount > 0 || data.AddedCount > 0 || data.RemovedCount > 0
|
||||
|
||||
tmpl, err := template.New("report").Parse(htmlTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse template: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
if err := tmpl.Execute(f, data); err != nil {
|
||||
return fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pngFileToDataURI reads a PNG file and returns a base64 data URI.
|
||||
func pngFileToDataURI(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
return "data:image/png;base64," + encoded, nil
|
||||
}
|
||||
|
||||
// imageToDataURI encodes an image.Image to a PNG base64 data URI.
|
||||
func imageToDataURI(img image.Image) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return "", err
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
return "data:image/png;base64," + encoded, nil
|
||||
}
|
||||
|
||||
const htmlTemplate = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Visual Regression Report</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
.header { background: #1a1a2e; color: #fff; padding: 24px 32px; }
|
||||
.header h1 { font-size: 24px; font-weight: 600; }
|
||||
.header p { margin-top: 8px; opacity: 0.8; font-size: 14px; }
|
||||
.summary { display: flex; gap: 16px; padding: 20px 32px; background: #fff; border-bottom: 1px solid #e0e0e0; flex-wrap: wrap; }
|
||||
.summary-card { padding: 12px 20px; border-radius: 8px; font-size: 14px; font-weight: 500; }
|
||||
.summary-changed { background: #fff3e0; color: #e65100; }
|
||||
.summary-added { background: #e8f5e9; color: #2e7d32; }
|
||||
.summary-removed { background: #fce4ec; color: #c62828; }
|
||||
.summary-unchanged { background: #e3f2fd; color: #1565c0; }
|
||||
.content { padding: 24px 32px; max-width: 1400px; margin: 0 auto; }
|
||||
.section-title { font-size: 18px; font-weight: 600; margin: 24px 0 16px; padding-bottom: 8px; border-bottom: 2px solid #e0e0e0; }
|
||||
.no-changes { text-align: center; padding: 60px 20px; color: #666; }
|
||||
.no-changes h2 { font-size: 24px; margin-bottom: 8px; color: #2e7d32; }
|
||||
.card { background: #fff; border-radius: 12px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid #eee; }
|
||||
.card-name { font-weight: 600; font-size: 15px; }
|
||||
.card-badge { font-size: 12px; padding: 4px 10px; border-radius: 12px; font-weight: 500; }
|
||||
.badge-changed { background: #fff3e0; color: #e65100; }
|
||||
.badge-added { background: #e8f5e9; color: #2e7d32; }
|
||||
.badge-removed { background: #fce4ec; color: #c62828; }
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid #eee; }
|
||||
.tab { padding: 10px 20px; cursor: pointer; font-size: 13px; font-weight: 500; color: #666; border-bottom: 2px solid transparent; transition: all 0.2s; }
|
||||
.tab:hover { color: #333; background: #f9f9f9; }
|
||||
.tab.active { color: #1a1a2e; border-bottom-color: #1a1a2e; }
|
||||
.tab-content { display: none; padding: 20px; }
|
||||
.tab-content.active { display: block; }
|
||||
.slider-container { position: relative; overflow: hidden; cursor: ew-resize; user-select: none; border: 1px solid #eee; border-radius: 4px; }
|
||||
.slider-container img { display: block; max-width: 100%; height: auto; }
|
||||
.slider-baseline { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; }
|
||||
.slider-baseline img { display: block; max-width: none; height: auto; }
|
||||
.slider-divider { position: absolute; top: 0; width: 3px; height: 100%; background: #e65100; z-index: 10; cursor: ew-resize; }
|
||||
.slider-divider::before { content: ""; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 32px; height: 32px; background: #e65100; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
|
||||
.slider-divider::after { content: "\2194"; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #fff; font-size: 16px; z-index: 1; }
|
||||
.slider-label { position: absolute; top: 10px; padding: 4px 10px; background: rgba(0,0,0,0.6); color: #fff; font-size: 11px; border-radius: 4px; z-index: 5; pointer-events: none; }
|
||||
.slider-label-left { left: 10px; }
|
||||
.slider-label-right { right: 10px; }
|
||||
.side-by-side { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.side-by-side .img-container { border: 1px solid #eee; border-radius: 4px; overflow: hidden; }
|
||||
.side-by-side .img-label { font-size: 12px; font-weight: 500; padding: 8px 12px; background: #f5f5f5; color: #666; }
|
||||
.side-by-side img { display: block; width: 100%; height: auto; }
|
||||
.diff-overlay img { display: block; max-width: 100%; height: auto; border: 1px solid #eee; border-radius: 4px; }
|
||||
.single-image img { display: block; max-width: 100%; height: auto; border: 1px solid #eee; border-radius: 4px; }
|
||||
.unchanged-section { margin-top: 32px; }
|
||||
.unchanged-toggle { cursor: pointer; font-size: 14px; color: #666; padding: 12px 0; }
|
||||
.unchanged-toggle:hover { color: #333; }
|
||||
.unchanged-list { display: none; }
|
||||
.unchanged-list.open { display: block; }
|
||||
.unchanged-item { padding: 8px 0; font-size: 13px; color: #888; border-bottom: 1px solid #f0f0f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>Visual Regression Report</h1>
|
||||
<p>{{.TotalCount}} screenshot{{if ne .TotalCount 1}}s{{end}} compared</p>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
{{if gt .ChangedCount 0}}<div class="summary-card summary-changed">{{.ChangedCount}} Changed</div>{{end}}
|
||||
{{if gt .AddedCount 0}}<div class="summary-card summary-added">{{.AddedCount}} Added</div>{{end}}
|
||||
{{if gt .RemovedCount 0}}<div class="summary-card summary-removed">{{.RemovedCount}} Removed</div>{{end}}
|
||||
<div class="summary-card summary-unchanged">{{.UnchangedCount}} Unchanged</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{{if not .HasDifferences}}
|
||||
<div class="no-changes">
|
||||
<h2>No visual changes detected</h2>
|
||||
<p>All {{.TotalCount}} screenshots match their baselines.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{range .Entries}}
|
||||
{{if eq .Status "changed"}}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-name">{{.Name}}</span>
|
||||
<span class="card-badge badge-changed">{{.DiffPercent}} changed</span>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="switchTab(this, 'slider')">Slider</div>
|
||||
<div class="tab" onclick="switchTab(this, 'sidebyside')">Side by Side</div>
|
||||
<div class="tab" onclick="switchTab(this, 'diff')">Diff Overlay</div>
|
||||
</div>
|
||||
<div class="tab-content active" data-tab="slider">
|
||||
<div class="slider-container" onmousedown="startSlider(event, this)" onmousemove="moveSlider(event, this)" ontouchstart="startSlider(event, this)" ontouchmove="moveSlider(event, this)">
|
||||
<img src="{{.CurrentDataURI}}" alt="Current" draggable="false">
|
||||
<div class="slider-baseline" style="width: 50%;">
|
||||
<img src="{{.BaselineDataURI}}" alt="Baseline" draggable="false">
|
||||
</div>
|
||||
<div class="slider-divider" style="left: calc(50% - 1.5px);"></div>
|
||||
<span class="slider-label slider-label-left">Baseline</span>
|
||||
<span class="slider-label slider-label-right">Current</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-content" data-tab="sidebyside">
|
||||
<div class="side-by-side">
|
||||
<div class="img-container">
|
||||
<div class="img-label">Baseline</div>
|
||||
<img src="{{.BaselineDataURI}}" alt="Baseline">
|
||||
</div>
|
||||
<div class="img-container">
|
||||
<div class="img-label">Current</div>
|
||||
<img src="{{.CurrentDataURI}}" alt="Current">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-content" data-tab="diff">
|
||||
<div class="diff-overlay">
|
||||
{{if .HasDiff}}<img src="{{.DiffDataURI}}" alt="Diff overlay">{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Status "added"}}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-name">{{.Name}}</span>
|
||||
<span class="card-badge badge-added">added</span>
|
||||
</div>
|
||||
<div class="tab-content active" data-tab="single">
|
||||
<div class="single-image">
|
||||
{{if .HasCurrent}}<img src="{{.CurrentDataURI}}" alt="New screenshot">{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Status "removed"}}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-name">{{.Name}}</span>
|
||||
<span class="card-badge badge-removed">removed</span>
|
||||
</div>
|
||||
<div class="tab-content active" data-tab="single">
|
||||
<div class="single-image">
|
||||
{{if .HasBaseline}}<img src="{{.BaselineDataURI}}" alt="Removed screenshot">{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if gt .UnchangedCount 0}}
|
||||
<div class="unchanged-section">
|
||||
<div class="unchanged-toggle" onclick="toggleUnchanged(this)">
|
||||
▶ {{.UnchangedCount}} unchanged screenshot{{if ne .UnchangedCount 1}}s{{end}} (click to expand)
|
||||
</div>
|
||||
<div class="unchanged-list">
|
||||
{{range .Entries}}{{if eq .Status "unchanged"}}<div class="unchanged-item">{{.Name}}</div>{{end}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Tab switching
|
||||
function switchTab(tabEl, tabName) {
|
||||
const card = tabEl.closest('.card');
|
||||
card.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
card.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
tabEl.classList.add('active');
|
||||
card.querySelector('[data-tab="' + tabName + '"]').classList.add('active');
|
||||
}
|
||||
|
||||
// Slider interaction
|
||||
let sliderActive = false;
|
||||
|
||||
function startSlider(e, container) {
|
||||
sliderActive = true;
|
||||
moveSlider(e, container);
|
||||
const stopSlider = function() { sliderActive = false; };
|
||||
document.addEventListener('mouseup', stopSlider, { once: true });
|
||||
document.addEventListener('touchend', stopSlider, { once: true });
|
||||
}
|
||||
|
||||
function moveSlider(e, container) {
|
||||
if (!sliderActive) return;
|
||||
e.preventDefault();
|
||||
const rect = container.getBoundingClientRect();
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
let x = clientX - rect.left;
|
||||
x = Math.max(0, Math.min(x, rect.width));
|
||||
const percent = (x / rect.width) * 100;
|
||||
container.querySelector('.slider-baseline').style.width = percent + '%';
|
||||
container.querySelector('.slider-divider').style.left = 'calc(' + percent + '% - 1.5px)';
|
||||
}
|
||||
|
||||
// Unchanged section toggle
|
||||
function toggleUnchanged(el) {
|
||||
const list = el.nextElementSibling;
|
||||
const isOpen = list.classList.toggle('open');
|
||||
el.innerHTML = (isOpen ? '▼' : '▶') + ' {{.UnchangedCount}} unchanged screenshot{{if ne .UnchangedCount 1}}s{{end}} (click to ' + (isOpen ? 'collapse' : 'expand') + ')';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
49
tools/ods/internal/s3/sync.go
Normal file
49
tools/ods/internal/s3/sync.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// SyncDown downloads an S3 prefix to a local directory using AWS CLI.
|
||||
// This is equivalent to: aws s3 sync <s3url> <destDir>
|
||||
func SyncDown(s3url string, destDir string) error {
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("Downloading from %s to %s ...", s3url, destDir)
|
||||
cmd := exec.Command("aws", "s3", "sync", s3url, destDir)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("aws s3 sync failed: %w\n\nTo authenticate, run:\n aws sso login\n\nOr configure AWS credentials with:\n aws configure sso", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncUp uploads a local directory to an S3 prefix using AWS CLI.
|
||||
// If delete is true, files in S3 that don't exist locally are removed.
|
||||
// This is equivalent to: aws s3 sync <srcDir> <s3url> [--delete]
|
||||
func SyncUp(srcDir string, s3url string, delete bool) error {
|
||||
args := []string{"s3", "sync", srcDir, s3url}
|
||||
if delete {
|
||||
args = append(args, "--delete")
|
||||
}
|
||||
|
||||
log.Infof("Uploading from %s to %s ...", srcDir, s3url)
|
||||
cmd := exec.Command("aws", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("aws s3 sync failed: %w\n\nTo authenticate, run:\n aws sso login\n\nOr configure AWS credentials with:\n aws configure sso", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -61,35 +61,46 @@ Bring up the entire application.
|
||||
|
||||
0. Install playwright dependencies
|
||||
|
||||
```cd web
|
||||
```bash
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
1. Run playwright
|
||||
|
||||
```
|
||||
cd web
|
||||
```bash
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
To run a single test:
|
||||
|
||||
```
|
||||
```bash
|
||||
npx playwright test landing-page.spec.ts
|
||||
```
|
||||
|
||||
If running locally, interactive options can help you see exactly what is happening in
|
||||
the test.
|
||||
|
||||
```
|
||||
```bash
|
||||
npx playwright test --ui
|
||||
npx playwright test --headed
|
||||
```
|
||||
|
||||
2. Inspect results
|
||||
3. Inspect results
|
||||
|
||||
By default, playwright.config.ts is configured to output the results to:
|
||||
|
||||
```
|
||||
```bash
|
||||
web/test-results
|
||||
```
|
||||
|
||||
4. Visual regression screenshots
|
||||
|
||||
Screenshots are captured automatically during test runs when `VISUAL_REGRESSION=true` is set.
|
||||
Baselines are stored in `web/tests/e2e/__screenshots__/` and can be updated with
|
||||
`npx playwright test --update-snapshots`.
|
||||
|
||||
To compare screenshots across CI runs, use:
|
||||
|
||||
```bash
|
||||
ods playwright-diff compare --run-id <CI_RUN_ID>
|
||||
```
|
||||
|
||||
4
web/output/.gitignore
vendored
Normal file
4
web/output/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Output directory for tests.
|
||||
# Ignore everything except this ignore file.
|
||||
*
|
||||
!.gitignore
|
||||
@@ -8,6 +8,12 @@ export default defineConfig({
|
||||
timeout: 100000, // 100 seconds timeout
|
||||
expect: {
|
||||
timeout: 15000, // 15 seconds timeout for all assertions to reduce flakiness
|
||||
toHaveScreenshot: {
|
||||
// Allow up to 1% of pixels to differ (accounts for anti-aliasing, subpixel rendering)
|
||||
maxDiffPixelRatio: 0.01,
|
||||
// Threshold per-channel (0-1): how different a pixel can be before it counts as changed
|
||||
threshold: 0.2,
|
||||
},
|
||||
},
|
||||
retries: process.env.CI ? 2 : 0, // Retry failed tests 2 times in CI, 0 locally
|
||||
|
||||
@@ -20,7 +26,7 @@ export default defineConfig({
|
||||
reporter: [["list"]],
|
||||
// Only run Playwright tests from tests/e2e directory (ignore Jest tests in src/)
|
||||
testMatch: /.*\/tests\/e2e\/.*\.spec\.ts/,
|
||||
outputDir: "test-results",
|
||||
outputDir: "output/playwright",
|
||||
use: {
|
||||
// Base URL for the application, can be overridden via BASE_URL environment variable
|
||||
baseURL: process.env.BASE_URL || "http://localhost:3000",
|
||||
|
||||
1
web/screenshots/.gitignore
vendored
Normal file
1
web/screenshots/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.png
|
||||
182
web/tests/e2e/admin_pages.spec.ts
Normal file
182
web/tests/e2e/admin_pages.spec.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expectScreenshot } from "./utils/visualRegression";
|
||||
|
||||
test.use({ storageState: "admin_auth.json" });
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
interface AdminPageSnapshot {
|
||||
name: string;
|
||||
path: string;
|
||||
pageTitle: string;
|
||||
options?: {
|
||||
paragraphText?: string | RegExp;
|
||||
buttonName?: string;
|
||||
subHeaderText?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ADMIN_PAGES: AdminPageSnapshot[] = [
|
||||
{
|
||||
name: "Document Management - Explorer",
|
||||
path: "documents/explorer",
|
||||
pageTitle: "Document Explorer",
|
||||
},
|
||||
{
|
||||
name: "Connectors - Add Connector",
|
||||
path: "add-connector",
|
||||
pageTitle: "Add Connector",
|
||||
},
|
||||
{
|
||||
name: "Custom Assistants - Assistants",
|
||||
path: "assistants",
|
||||
pageTitle: "Assistants",
|
||||
options: {
|
||||
paragraphText:
|
||||
"Assistants are a way to build custom search/question-answering experiences for different use cases.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Configuration - Document Processing",
|
||||
path: "configuration/document-processing",
|
||||
pageTitle: "Document Processing",
|
||||
},
|
||||
{
|
||||
name: "Document Management - Document Sets",
|
||||
path: "documents/sets",
|
||||
pageTitle: "Document Sets",
|
||||
options: {
|
||||
paragraphText:
|
||||
"Document Sets allow you to group logically connected documents into a single bundle. These can then be used as a filter when performing searches to control the scope of information Onyx searches over.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom Assistants - Slack Bots",
|
||||
path: "bots",
|
||||
pageTitle: "Slack Bots",
|
||||
options: {
|
||||
paragraphText:
|
||||
"Setup Slack bots that connect to Onyx. Once setup, you will be able to ask questions to Onyx directly from Slack. Additionally, you can:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom Assistants - Standard Answers",
|
||||
path: "standard-answer",
|
||||
pageTitle: "Standard Answers",
|
||||
},
|
||||
{
|
||||
name: "Performance - Usage Statistics",
|
||||
path: "performance/usage",
|
||||
pageTitle: "Usage Statistics",
|
||||
},
|
||||
{
|
||||
name: "Document Management - Feedback",
|
||||
path: "documents/feedback",
|
||||
pageTitle: "Document Feedback",
|
||||
},
|
||||
{
|
||||
name: "Configuration - LLM",
|
||||
path: "configuration/llm",
|
||||
pageTitle: "LLM Setup",
|
||||
},
|
||||
{
|
||||
name: "Connectors - Existing Connectors",
|
||||
path: "indexing/status",
|
||||
pageTitle: "Existing Connectors",
|
||||
},
|
||||
{
|
||||
name: "User Management - Groups",
|
||||
path: "groups",
|
||||
pageTitle: "Manage User Groups",
|
||||
},
|
||||
{
|
||||
name: "Appearance & Theming",
|
||||
path: "theme",
|
||||
pageTitle: "Appearance & Theming",
|
||||
},
|
||||
{
|
||||
name: "Configuration - Search Settings",
|
||||
path: "configuration/search",
|
||||
pageTitle: "Search Settings",
|
||||
},
|
||||
{
|
||||
name: "Custom Assistants - MCP Actions",
|
||||
path: "actions/mcp",
|
||||
pageTitle: "MCP Actions",
|
||||
},
|
||||
{
|
||||
name: "Custom Assistants - OpenAPI Actions",
|
||||
path: "actions/open-api",
|
||||
pageTitle: "OpenAPI Actions",
|
||||
},
|
||||
{
|
||||
name: "User Management - Token Rate Limits",
|
||||
path: "token-rate-limits",
|
||||
pageTitle: "Token Rate Limits",
|
||||
options: {
|
||||
paragraphText:
|
||||
"Token rate limits enable you control how many tokens can be spent in a given time period. With token rate limits, you can:",
|
||||
buttonName: "Create a Token Rate Limit",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function verifyAdminPageNavigation(
|
||||
page: Page,
|
||||
path: string,
|
||||
pageTitle: string,
|
||||
options?: {
|
||||
paragraphText?: string | RegExp;
|
||||
buttonName?: string;
|
||||
subHeaderText?: string;
|
||||
}
|
||||
) {
|
||||
await page.goto(`/admin/${path}`);
|
||||
|
||||
try {
|
||||
await expect(page.locator('[aria-label="admin-page-title"]')).toHaveText(
|
||||
pageTitle,
|
||||
{
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to find admin-page title with text "${pageTitle}" for path "${path}"`
|
||||
);
|
||||
// NOTE: This is a temporary measure for debugging the issue
|
||||
console.error(await page.content());
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (options?.paragraphText) {
|
||||
await expect(page.locator("p.text-sm").nth(0)).toHaveText(
|
||||
options.paragraphText
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.buttonName) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: options.buttonName })
|
||||
).toHaveCount(1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const snapshot of ADMIN_PAGES) {
|
||||
test(`Admin - ${snapshot.name}`, async ({ page }) => {
|
||||
await verifyAdminPageNavigation(
|
||||
page,
|
||||
snapshot.path,
|
||||
snapshot.pageTitle,
|
||||
snapshot.options
|
||||
);
|
||||
|
||||
// Wait for all network requests to settle before capturing the screenshot.
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Capture a screenshot for visual regression review.
|
||||
// The screenshot name is derived from the admin page path to ensure uniqueness.
|
||||
const screenshotName = `admin-${snapshot.path.replace(/\//g, "-")}`;
|
||||
await expectScreenshot(page, { name: screenshotName });
|
||||
});
|
||||
}
|
||||
124
web/tests/e2e/utils/visualRegression.ts
Normal file
124
web/tests/e2e/utils/visualRegression.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { Page, PageScreenshotOptions } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Whether visual regression assertions are enabled.
|
||||
*
|
||||
* When `VISUAL_REGRESSION=true` is set, `expectScreenshot()` calls
|
||||
* `toHaveScreenshot()` which will fail if the screenshot differs from the
|
||||
* stored baseline.
|
||||
*
|
||||
* When disabled (the default), screenshots are still captured and saved but
|
||||
* mismatches do NOT fail the test — this lets CI collect screenshots for later
|
||||
* review without gating on them.
|
||||
*/
|
||||
const VISUAL_REGRESSION_ENABLED =
|
||||
process.env.VISUAL_REGRESSION?.toLowerCase() === "true";
|
||||
|
||||
/**
|
||||
* Default selectors to mask across all screenshots so that dynamic content
|
||||
* (timestamps, avatars, etc.) doesn't cause spurious diffs.
|
||||
*/
|
||||
const DEFAULT_MASK_SELECTORS: string[] = [
|
||||
// Add selectors for dynamic content that should be masked, e.g.:
|
||||
// '[data-testid="timestamp"]',
|
||||
// '[data-testid="user-avatar"]',
|
||||
];
|
||||
|
||||
interface ScreenshotOptions {
|
||||
/**
|
||||
* Name for the screenshot file. If omitted, Playwright auto-generates one
|
||||
* from the test title.
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* Additional CSS selectors to mask (on top of the defaults).
|
||||
* Masked areas are replaced with a pink box so they don't cause diffs.
|
||||
*/
|
||||
mask?: string[];
|
||||
|
||||
/**
|
||||
* If true, capture the full scrollable page instead of just the viewport.
|
||||
* Defaults to false.
|
||||
*/
|
||||
fullPage?: boolean;
|
||||
|
||||
/**
|
||||
* Override the max diff pixel ratio for this specific screenshot.
|
||||
*/
|
||||
maxDiffPixelRatio?: number;
|
||||
|
||||
/**
|
||||
* Override the per-channel threshold for this specific screenshot.
|
||||
*/
|
||||
threshold?: number;
|
||||
|
||||
/**
|
||||
* Additional Playwright screenshot options.
|
||||
*/
|
||||
screenshotOptions?: PageScreenshotOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot and optionally assert it matches the stored baseline.
|
||||
*
|
||||
* Behavior depends on the `VISUAL_REGRESSION` environment variable:
|
||||
* - `VISUAL_REGRESSION=true` → assert via `toHaveScreenshot()` (fails on diff)
|
||||
* - Otherwise → capture and save the screenshot for review only
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import { expectScreenshot } from "@tests/e2e/utils/visualRegression";
|
||||
*
|
||||
* test("admin page looks right", async ({ page }) => {
|
||||
* await page.goto("/admin/settings");
|
||||
* await expectScreenshot(page, { name: "admin-settings" });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function expectScreenshot(
|
||||
page: Page,
|
||||
options: ScreenshotOptions = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
name,
|
||||
mask = [],
|
||||
fullPage = false,
|
||||
maxDiffPixelRatio,
|
||||
threshold,
|
||||
} = options;
|
||||
|
||||
// Combine default masks with per-call masks
|
||||
const allMaskSelectors = [...DEFAULT_MASK_SELECTORS, ...mask];
|
||||
const maskLocators = allMaskSelectors.map((selector) =>
|
||||
page.locator(selector)
|
||||
);
|
||||
|
||||
// Build the screenshot name array (Playwright expects string[])
|
||||
const nameArg = name ? [name + ".png"] : undefined;
|
||||
|
||||
if (VISUAL_REGRESSION_ENABLED) {
|
||||
// Assert mode — fail the test if the screenshot differs from baseline
|
||||
const screenshotOpts = {
|
||||
fullPage,
|
||||
mask: maskLocators.length > 0 ? maskLocators : undefined,
|
||||
...(maxDiffPixelRatio !== undefined && { maxDiffPixelRatio }),
|
||||
...(threshold !== undefined && { threshold }),
|
||||
};
|
||||
|
||||
if (nameArg) {
|
||||
await expect(page).toHaveScreenshot(nameArg, screenshotOpts);
|
||||
} else {
|
||||
await expect(page).toHaveScreenshot(screenshotOpts);
|
||||
}
|
||||
} else {
|
||||
// Capture-only mode — save the screenshot without asserting
|
||||
const screenshotPath = name ? `screenshots/${name}.png` : undefined;
|
||||
await page.screenshot({
|
||||
path: screenshotPath,
|
||||
fullPage,
|
||||
...options.screenshotOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user