IMG-33: adds simple integration tests for image.Load() (#1462)

* Load() integration test

* Added test-images as submodule
This commit is contained in:
Victor Sokolov
2025-07-23 19:09:49 +02:00
committed by GitHub
parent 676cd7088c
commit aa91342686
16 changed files with 329 additions and 142 deletions

View File

@@ -31,7 +31,7 @@ ENV GOPATH=$HOME/go
ENV PATH=$PATH:$GOROOT/bin:$GOPATH/bin
# gnupg is requiered for clang-format
RUN apt-get install -y gnupg lsb-release ssh unzip
RUN apt-get install -y gnupg lsb-release ssh
# Install air
RUN go install github.com/air-verse/air@latest

View File

@@ -28,6 +28,5 @@
]
}
},
"initializeCommand": "mkdir -p ${localWorkspaceFolder}/.devcontainer/images/ && curl -O -L https://github.com/imgproxy/test-images/archive/refs/heads/main.zip && unzip main.zip -d ${localWorkspaceFolder}/.devcontainer/images/ && rm main.zip && mv ${localWorkspaceFolder}/.devcontainer/images/test-images-main/* ${localWorkspaceFolder}/.devcontainer/images/ && rmdir ${localWorkspaceFolder}/.devcontainer/images/test-images-main",
"postCreateCommand": "lefthook install"
}

View File

@@ -1,66 +0,0 @@
FROM public.ecr.aws/ubuntu/ubuntu:noble
ARG VIPS_VERSIONS="8.14 8.15 8.16"
RUN apt-get -qq update \
&& apt-get install -y --no-install-recommends \
bash \
curl \
git \
ca-certificates \
build-essential \
gobject-introspection \
libgirepository1.0-dev \
python3-pip \
python3-venv \
libssl-dev \
libglib2.0-dev \
libxml2-dev \
libjpeg-dev \
libpng-dev \
libwebp-dev \
librsvg2-dev \
libexif-dev \
liblcms2-dev \
&& python3 -m venv /root/.python \
&& /root/.python/bin/pip install meson ninja \
&& rm -rf /var/lib/apt/lists/*
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y \
&& export PATH="/root/.cargo/bin:$PATH" \
&& cargo install cargo-c \
&& cd /root \
&& git clone --depth 1 https://github.com/DarthSim/quantizr.git \
&& cd quantizr \
&& cargo cinstall --release --library-type=cdylib --prefix=/usr/local --libdir=/usr/local/lib \
&& rm -rf /root/.rustup /root/.cargo
ENV PATH="/root/.python/bin:$PATH"
ENV LD_LIBRARY_PATH="/usr/local/lib"
RUN \
mkdir /root/vips \
&& cd /root/vips \
&& curl -s -S -L -o vips_releases.json "https://api.github.com/repos/libvips/libvips/releases" \
&& for VIPS_VERSION in $VIPS_VERSIONS; do \
mkdir $VIPS_VERSION \
&& export VIPS_RELEASE=$(grep -m 1 "\"tag_name\": \"v$VIPS_VERSION." vips_releases.json | sed -E 's/.*"v([^"]+)".*/\1/') \
&& echo "Building Vips $VIPS_RELEASE as $VIPS_VERSION" \
&& curl -s -S -L -o libvips-$VIPS_RELEASE.tar.gz https://github.com/libvips/libvips/archive/refs/tags/v$VIPS_RELEASE.tar.gz \
&& tar -xzf libvips-$VIPS_RELEASE.tar.gz \
&& cd libvips-$VIPS_RELEASE \
&& meson setup _build \
--buildtype=release \
--strip \
--prefix=/root/vips/$VIPS_VERSION \
--libdir=lib \
-Dgtk_doc=false \
&& ninja -C _build \
&& ninja -C _build install \
&& cd .. \
&& rm -rf libvips-$VIPS_RELEASE.tar.gz libvips-$VIPS_RELEASE; \
done
WORKDIR /go/src
ENTRYPOINT [ "/bin/bash" ]

View File

@@ -1,4 +0,0 @@
#!/bin/bash
DATETAG=$(date +%Y%m%d%H%M)
docker tag $IMAGE_NAME $DOCKER_REPO:$DATETAG
docker push $DOCKER_REPO:$DATETAG

View File

@@ -1,44 +0,0 @@
name: Build CI Docker
on:
workflow_dispatch:
inputs:
vips_versions:
description: 'Whitespace separated list of libvips versions to build'
required: true
default: "8.14 8.15 8.16"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate Docker tag
id: tag
run: echo "tag=ghcr.io/imgproxy/imgproxy-ci:$(date +%Y%m%d%H%M)" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./.github/ci-docker/Dockerfile
tags: ${{ steps.tag.outputs.tag }}
platforms: linux/amd64
build-args: |
"VIPS_VERSIONS=${{ github.event.inputs.vips_versions }}"
push: true

View File

@@ -5,44 +5,54 @@ on:
env:
CGO_LDFLAGS_ALLOW: "-s|-w"
PKG_CONFIG_LIBDIR: /opt/imgproxy/lib/pkgconfig
LD_LIBRARY_PATH: /opt/imgproxy/lib
GOFLAGS: -buildvcs=false
jobs:
test:
runs-on: ubuntu-latest
container:
image: ghcr.io/imgproxy/imgproxy-ci:202410292002
strategy:
matrix:
go-version: ["1.23.x", "1.22.x", "1.21.x"]
vips-version: ["8.16", "8.15", "8.14"]
image: ghcr.io/imgproxy/imgproxy-base:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
submodules: true
- name: Setup cache
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download mods
run: go mod download
- name: Mark git workspace as safe
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Test
run: go test ./...
env:
LD_LIBRARY_PATH: "/usr/local/lib:/root/vips/${{ matrix.vips-version }}/lib"
PKG_CONFIG_PATH: "/usr/local/lib/pkgconfig:/root/vips/${{ matrix.vips-version }}/lib/pkgconfig"
run: go test -tags integration ./...
lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/imgproxy/imgproxy-ci:202410292002
strategy:
matrix:
go-version: ["1.23.x"]
vips-version: ["8.16"]
image: ghcr.io/imgproxy/imgproxy-base:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
submodules: true
- name: Setup cache
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download mods
run: go mod download
- name: Lint
@@ -51,20 +61,25 @@ jobs:
version: v2.1.6
args: --timeout 10m0s
env:
LD_LIBRARY_PATH: "/usr/local/lib:/root/vips/${{ matrix.vips-version }}/lib"
PKG_CONFIG_PATH: "/usr/local/lib/pkgconfig:/root/vips/${{ matrix.vips-version }}/lib/pkgconfig"
PKG_CONFIG_LIBDIR: /opt/imgproxy/lib/pkgconfig
GOFLAGS: -buildvcs=false
c-lint:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: cpp-linter/cpp-linter-action@v2
id: linter
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
style: file
version: 18 # Ubuntu 24.04 provides clang-format-18
tidy-checks: '-*' # disable clang-tidy
tidy-checks: "-*" # disable clang-tidy
- name: Fail fast
continue-on-error: true # TODO: remove this line in the future

1
.gitignore vendored
View File

@@ -8,5 +8,4 @@ tmp/
docker-base
docs/sitemap.txt
.env
.devcontainer/images/*
k6/*.json

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "testdata/test-images"]
path = testdata/test-images
url = git@github.com:imgproxy/test-images.git

View File

@@ -14,4 +14,4 @@ fi
export CGO_LDFLAGS_ALLOW="-s|-w"
export CGO_CFLAGS_ALLOW="-I|-Xpreprocessor"
golangci-lint run
golangci-lint --build-tags integration run

2
go.mod
View File

@@ -25,6 +25,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssm v1.60.0
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0
github.com/bugsnag/bugsnag-go/v2 v2.5.1
github.com/corona10/goimagehash v1.1.0
github.com/felixge/httpsnoop v1.0.4
github.com/fsouza/fake-gcs-server v1.42.2
github.com/getsentry/sentry-go v0.34.1
@@ -160,6 +161,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/outcaste-io/ristretto v0.2.3 // indirect
github.com/pborman/uuid v1.2.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect

4
go.sum
View File

@@ -170,6 +170,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI=
github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -342,6 +344,8 @@ github.com/newrelic/go-agent/v3 v3.39.0 h1:VVhsJR422oOxU/sJ1HZrop/OC7G1GTClIviVJ
github.com/newrelic/go-agent/v3 v3.39.0/go.mod h1:4QXvru0vVy/iu7mfkNHT7T2+9TC9zPGO8aUEdKqY138=
github.com/newrelic/newrelic-telemetry-sdk-go v0.8.1 h1:6OX5VXMuj2salqNBc41eXKz6K+nV6OB/hhlGnAKCbwU=
github.com/newrelic/newrelic-telemetry-sdk-go v0.8.1/go.mod h1:2kY6OeOxrJ+RIQlVjWDc/pZlT3MIf30prs6drzMfJ6E=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=

141
integration/load_test.go Normal file
View File

@@ -0,0 +1,141 @@
//go:build integration
// +build integration
package integration
import (
"bytes"
"fmt"
"image/png"
"os"
"path"
"path/filepath"
"strings"
"testing"
"github.com/corona10/goimagehash"
"github.com/imgproxy/imgproxy/v3/imagetype"
"github.com/imgproxy/imgproxy/v3/vips"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
similarityThreshold = 5 // Distance between images to be considered similar
)
// testLoadFolder fetches images iterates over images in the specified folder,
// runs imgproxy on each image, and compares the result with the reference image
// which is expected to be in the `integration` folder with the same name
// but with `.png` extension.
func testLoadFolder(t *testing.T, cs, sourcePath, folder string) {
t.Logf("Testing folder: %s", folder)
walkPath := path.Join(sourcePath, folder)
// Iterate over the files in the source folder
err := filepath.Walk(walkPath, func(path string, info os.FileInfo, err error) error {
require.NoError(t, err)
// Skip directories
if info.IsDir() {
return nil
}
// get the base name of the file (8-bpp.png)
basePath := filepath.Base(path)
// Replace the extension with .png
referencePath := strings.TrimSuffix(basePath, filepath.Ext(basePath)) + ".png"
// Construct the full path to the reference image (integration/ folder)
referencePath = filepath.Join(sourcePath, "integration", folder, referencePath)
// Construct the source URL for imgproxy (no processing)
sourceUrl := fmt.Sprintf("insecure/plain/local:///%s/%s@png", folder, basePath)
imgproxyImageBytes := fetchImage(t, cs, sourceUrl)
imgproxyImage, err := png.Decode(bytes.NewReader(imgproxyImageBytes))
require.NoError(t, err, "Failed to decode PNG image from imgproxy for %s", basePath)
referenceFile, err := os.Open(referencePath)
require.NoError(t, err)
defer referenceFile.Close()
referenceImage, err := png.Decode(referenceFile)
require.NoError(t, err, "Failed to decode PNG reference image for %s", referencePath)
hash1, err := goimagehash.DifferenceHash(imgproxyImage)
require.NoError(t, err)
hash2, err := goimagehash.DifferenceHash(referenceImage)
require.NoError(t, err)
distance, err := hash1.Distance(hash2)
require.NoError(t, err)
assert.LessOrEqual(t, distance, similarityThreshold,
"Image %s differs from reference image %s by %d, which is greater than the allowed threshold of %d",
basePath, referencePath, distance, similarityThreshold)
return nil
})
require.NoError(t, err)
}
// TestLoadSaveToPng ensures that our load pipeline works,
// including standard and custom loaders. For each source image
// in the folder, it does the passthrough request through imgproxy:
// no processing, just convert format of the source file to png.
// Then, it compares the result with the reference image.
func TestLoadSaveToPng(t *testing.T) {
ctx := t.Context()
// TODO: Will be moved to test suite (like in processing_test.go)
// Since we use SupportsLoad, we need to initialize vips
defer vips.Shutdown() // either way it needs to be deinitialized
err := vips.Init()
require.NoError(t, err, "Failed to initialize vips")
path, err := testImagesPath(t)
require.NoError(t, err)
cs := startImgproxy(t, ctx, path)
if vips.SupportsLoad(imagetype.GIF) {
testLoadFolder(t, cs, path, "gif")
}
if vips.SupportsLoad(imagetype.JPEG) {
testLoadFolder(t, cs, path, "jpg")
}
if vips.SupportsLoad(imagetype.HEIC) {
testLoadFolder(t, cs, path, "heif")
}
if vips.SupportsLoad(imagetype.JXL) {
testLoadFolder(t, cs, path, "jxl")
}
if vips.SupportsLoad(imagetype.SVG) {
testLoadFolder(t, cs, path, "svg")
}
if vips.SupportsLoad(imagetype.TIFF) {
testLoadFolder(t, cs, path, "tiff")
}
if vips.SupportsLoad(imagetype.WEBP) {
testLoadFolder(t, cs, path, "webp")
}
if vips.SupportsLoad(imagetype.BMP) {
testLoadFolder(t, cs, path, "bmp")
}
if vips.SupportsLoad(imagetype.ICO) {
testLoadFolder(t, cs, path, "ico")
}
}

137
integration/test_utils.go Normal file
View File

@@ -0,0 +1,137 @@
//go:build integration
// +build integration
// Integration test helpers for imgproxy.
// We use regular `go build` instead of Docker to make sure
// tests run in the same environment as other tests,
// including in CI, where everything runs in a custom Docker image
// against the different libvips versions.
package integration
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
)
const (
buildContext = ".." // Source code folder
binPath = "/tmp/imgproxy-test" // Path to the built imgproxy binary
bindPort = 9090 // Port to bind imgproxy to
bindHost = "127.0.0.1" // Host to bind imgproxy to
)
var (
buildCmd = []string{"build", "-v", "-ldflags=-s -w", "-o", binPath} // imgproxy build command
)
// waitForPort tries to connect to host:port until successful or timeout
func waitForPort(host string, port int, timeout time.Duration) error {
var address string
if net.ParseIP(host) != nil && net.ParseIP(host).To4() == nil {
// IPv6 address, wrap in brackets
address = fmt.Sprintf("[%s]:%d", host, port)
} else {
address = fmt.Sprintf("%s:%d", host, port)
}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", address, 500*time.Millisecond)
if err == nil {
conn.Close()
return nil // port is open
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for port %s", address)
}
func startImgproxy(t *testing.T, ctx context.Context, testImagesPath string) string {
// Build the imgproxy binary
buildCmd := exec.Command("go", buildCmd...)
buildCmd.Dir = buildContext
buildCmd.Env = os.Environ()
buildOut, err := buildCmd.CombinedOutput()
require.NoError(t, err, "failed to build imgproxy: %v\n%s", err, string(buildOut))
// Start imgproxy in the background
cmd := exec.CommandContext(ctx, binPath)
// Set environment variables for imgproxy
cmd.Env = append(os.Environ(), "IMGPROXY_BIND=:"+fmt.Sprintf("%d", bindPort))
cmd.Env = append(cmd.Env, "IMGPROXY_LOCAL_FILESYSTEM_ROOT="+testImagesPath)
cmd.Env = append(cmd.Env, "IMGPROXY_MAX_ANIMATION_FRAMES=999")
cmd.Env = append(cmd.Env, "IMGPROXY_VIPS_LEAK_CHECK=true")
cmd.Env = append(cmd.Env, "IMGPROXY_LOG_MEM_STATS=true")
cmd.Env = append(cmd.Env, "IMGPROXY_DEVELOPMENT_ERRORS_MODE=true")
// That one is for the build logs
stdout, _ := os.CreateTemp("", "imgproxy-stdout-*")
stderr, _ := os.CreateTemp("", "imgproxy-stderr-*")
cmd.Stdout = stdout
cmd.Stderr = stderr
err = cmd.Start()
require.NoError(t, err, "failed to start imgproxy: %v", err)
// Wait for port 8090 to be available
err = waitForPort(bindHost, bindPort, 5*time.Second)
if err != nil {
cmd.Process.Kill()
require.NoError(t, err, "imgproxy did not start in time")
}
// Return a dummy container (nil) and connection string
t.Cleanup(func() {
cmd.Process.Kill()
stdout.Close()
stderr.Close()
os.Remove(stdout.Name())
os.Remove(stderr.Name())
os.Remove(binPath)
})
return fmt.Sprintf("%s:%d", bindHost, bindPort)
}
// fetchImage fetches an image from the imgproxy server
func fetchImage(t *testing.T, cs string, path string) []byte {
url := fmt.Sprintf("http://%s/%s", cs, path)
resp, err := http.Get(url)
require.NoError(t, err, "Failed to fetch image from %s", url)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "Expected status code 200 OK, got %d, url: %s", resp.StatusCode, url)
bytes, err := io.ReadAll(resp.Body)
require.NoError(t, err, "Failed to read response body from %s", url)
return bytes
}
// testImagesPath returns the absolute path to the test images directory
func testImagesPath(t *testing.T) (string, error) {
// Get current working directory
dir, err := os.Getwd()
require.NoError(t, err)
// Convert to absolute path (if it's not already)
absPath, err := filepath.Abs(dir)
require.NoError(t, err)
return path.Join(absPath, "../testdata/test-images"), nil
}

View File

@@ -84,7 +84,7 @@ func shutdown() {
errorreport.Close()
}
func run() error {
func run(ctx context.Context) error {
if err := initialize(); err != nil {
return err
}
@@ -103,7 +103,7 @@ func run() error {
}
}()
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(ctx)
if err := prometheus.StartServer(cancel); err != nil {
return err
@@ -137,7 +137,7 @@ func main() {
os.Exit(0)
}
if err := run(); err != nil {
if err := run(context.Background()); err != nil {
log.Fatal(err)
}
}

1
testdata/test-images vendored Submodule

Submodule testdata/test-images added at 9bee50dcc1