Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5cf8641
Add end-to-end testing workflow to CI/CD pipeline
timonrieger Jan 11, 2026
a455d95
reorg tests
timonrieger Jan 11, 2026
d013c68
update tests
timonrieger Jan 11, 2026
a4997f9
Add end-to-end tests for client_wrapper modules and update pytest con…
timonrieger Jan 11, 2026
e7f3657
fix test path
timonrieger Jan 11, 2026
5a4612c
avoid name collision
timonrieger Jan 11, 2026
a45f4ad
lint
timonrieger Jan 12, 2026
7981362
Add GitHub Container Registry login step to CI/CD workflow to avoid m…
timonrieger Jan 12, 2026
85d6d7f
type check
timonrieger Jan 12, 2026
91bff2d
Increase timeout duration in CI/CD workflow for Immich server readine…
timonrieger Jan 12, 2026
1bdab8a
run end-to-end tests on main branch pushes and pull requests with "ru…
timonrieger Jan 12, 2026
5fb558e
gate release with e2e tests
timonrieger Jan 12, 2026
a52a47d
trigger e2e tests
timonrieger Jan 12, 2026
d27aca2
match upstream test
timonrieger Jan 12, 2026
f09dc78
Refactor Immich server startup in CI/CD workflow to align with upstre…
timonrieger Jan 12, 2026
1d307e2
fix ci
timonrieger Jan 12, 2026
aad7979
fix
timonrieger Jan 12, 2026
24190a4
make signup idempotent
timonrieger Jan 12, 2026
0cb460d
fix tests
timonrieger Jan 12, 2026
0079fc0
update pillow
timonrieger Jan 12, 2026
1ce7761
lint
timonrieger Jan 12, 2026
01533bb
fix linux path issue
timonrieger Jan 12, 2026
5da9ee4
Update CI/CD workflow to use `mise_toml` for Python version managemen…
timonrieger Jan 12, 2026
599d56d
Add mise.ci.toml for constant tool definitions and update CI workflow…
timonrieger Jan 12, 2026
85a6d7c
Refactor MP4 generation logic in e2e tests to use a base64-decoded te…
timonrieger Jan 12, 2026
4eb9443
cleanup
timonrieger Jan 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,74 @@ jobs:
- name: Install mise
uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3
with:
install-args: "python@${{ matrix.python-version }}"
# Workaround: Use `--env ci` to load mise.ci.toml which contains constant tools (uv).
# This allows us to override only the Python version via install-args without duplicating
# tool definitions.
install-args: "python@${{ matrix.python-version }} --env ci"

- name: Run unit tests
run: mise run ci:test:unit

e2e-tests:
runs-on: ubuntu-latest
if: (github.ref == 'refs/heads/main' && github.event_name == 'push') || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e'))
permissions:
contents: read

steps:
- name: Check-out repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
persist-credentials: false

- name: Install mise
uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3

- name: Read Immich version
id: immich-version
run: echo "version=$(cat IMMICH-VERSION | tr -d '[:space:]')" >> $GITHUB_OUTPUT

- name: Clone Immich repository
run: |
git clone --depth 1 --branch $IMMICH_VERSION https://github.com/immich-app/immich.git immich-git-repo
env:
IMMICH_VERSION: ${{ steps.immich-version.outputs.version }}

- name: Login to GitHub Container Registry
run: |
echo "${{ github.token }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin

- name: Start Immich server
working-directory: ./immich-git-repo/e2e
run: |
docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans
echo "Waiting for Immich e2e environment to be ready..."
timeout 60s bash -lc 'until docker logs immich-e2e-server 2>&1 | grep -q "Immich Microservices is running"; do sleep 1; done'
echo "Immich e2e environment is ready!"
# Note: This startup process is identical to the one in Immich's e2e setup.
# We are depending on the same log message to be printed. For reference, see:
# - server/src/workers/microservices.ts
# - e2e/src/setup/docker-compose.ts

- name: Run e2e tests
env:
IMMICH_URL: http://127.0.0.1:2285
IMMICH_API_URL: http://127.0.0.1:2285/api
run: mise run ci:test:e2e

- name: Show docker logs on failure
if: failure()
working-directory: ./immich-git-repo/e2e
run: docker compose logs

- name: Teardown Immich server
if: always()
working-directory: ./immich-git-repo/e2e
run: docker compose down

release:
runs-on: ubuntu-latest
needs: [lint, test]
needs: [lint, test, e2e-tests]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
concurrency:
group: ${{ github.workflow }}-release-${{ github.ref_name }}
Expand Down
11 changes: 0 additions & 11 deletions bin/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import shutil
import subprocess # nosec: B404
from pathlib import Path
import requests # type: ignore[import-untyped]


def project_root() -> Path:
Expand Down Expand Up @@ -72,16 +71,6 @@ def main() -> int:
print(f"Generating Immich client from ref: {args.ref}")
print(f"Spec URL: {url}")

# quick sanity check that the spec is reachable
try:
resp = requests.get(url, timeout=30)
if resp.status_code != 200:
print(f"Failed to fetch OpenAPI spec (status={resp.status_code})")
return 1
except requests.RequestException as e:
print(f"Failed to fetch OpenAPI spec: {e}")
return 1

if client_dir.exists():
print("Deleting existing generated client folder:", client_dir)
shutil.rmtree(client_dir)
Expand Down
2 changes: 1 addition & 1 deletion immich/_internal/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ def get_file_times(path: Path, stats: os.stat_result) -> tuple[datetime, datetim
if sys.platform == "win32":
ctime = stats.st_ctime
else:
ctime = statx(path).btime
ctime = statx(os.fspath(path)).btime

return datetime.fromtimestamp(
ctime or mtime, tz=timezone.utc
Expand Down
3 changes: 3 additions & 0 deletions mise.ci.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[tools]
# only include constant tools here, dynamic versions are handled by the CI workflow
uv = "0.9.21"
15 changes: 13 additions & 2 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@ run = "uv run ty check"
description = "Run type checking"

[tasks."test:unit"]
run = "uv run pytest tests/"
run = "uv run pytest tests/unit"
description = "Run tests with pytest"

[tasks."test:e2e"]
run = "uv run pytest tests/e2e"
description = "Run e2e tests"

[tasks."test:unit:cov"]
run = "uv run pytest tests/ --cov=immich --cov-report=html --cov-report=term"
run = "uv run pytest tests/unit --cov=immich --cov-report=html --cov-report=term"
description = "Run tests with coverage report"

[tasks.install]
Expand Down Expand Up @@ -80,6 +84,13 @@ run = [
{ task = "test:unit" },
]

[tasks."ci:test:e2e"]
description = "Run e2e tests in CI"
run = [
{ task = "ci:install" },
{ task = "test:e2e" },
]

[tasks.clean]
run = "rm -rf build/ dist/ *.egg-info/ .pytest_cache/ htmlcov/ .coverage .ruff_cache/"
description = "Clean build artifacts and cache files"
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ build-backend = "uv_build"

[dependency-groups]
dev = [
"pillow>=12.1.0,<13.0.0",
"pytest>=9.0.0,<10.0.0",
"pytest-asyncio>=1.2.0,<2.0.0",
"pytest-cov>=7.0.0,<8.0.0",
Expand Down Expand Up @@ -84,6 +85,10 @@ lint.ignore = ["E721", "F403"]

[tool.pytest.ini_options]
asyncio_default_fixture_loop_scope = "function"
asyncio_mode = "auto"
markers = [
"e2e: marks tests as end-to-end tests (deselect with '-m \"not e2e\"')",
]

[tool.ty]
environment.root = ['immich']
Expand Down
Empty file added tests/e2e/client/__init__.py
Empty file.
87 changes: 87 additions & 0 deletions tests/e2e/client/generators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Image and video generators for E2E tests."""

from __future__ import annotations

import base64
import io
from typing import Iterator

from PIL import Image


def _create_jpeg(r: int, g: int, b: int) -> bytes:
"""Create a minimal 1x1 JPEG with the specified RGB color."""
img = Image.new("RGB", (1, 1), (r, g, b))
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=95)
return buffer.getvalue()


def _new_jpeg_factory() -> Iterator[bytes]:
"""Generator that yields unique JPEG images by cycling through RGB values."""
for r in range(0, 256, 16): # Step by 16 to avoid too many combinations
for g in range(0, 256, 16):
for b in range(0, 256, 16):
yield _create_jpeg(r, g, b)


_jpeg_factory = _new_jpeg_factory()


def make_random_image() -> bytes:
"""Generate a random unique JPEG image for testing."""
global _jpeg_factory
try:
return next(_jpeg_factory)
except StopIteration:
# Reset factory if we run out
_jpeg_factory = _new_jpeg_factory()
return next(_jpeg_factory)


# Decode the MP4 template once at module import
_MP4_BASE64 = "AAAAIGZ0eXBtcDQyAAAAAG1wNDJtcDQxaXNvbWF2YzEAAAGhtZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE0OCByMjA1OCBlYjc2Y2U1IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxNyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3VibWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTEgbG9va2FoZWFkX3RocmVhZHM9MSBzbGljZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2JpYXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBrZXlpbnRfbWluPTI1IHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAgcmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMAA="
MODULE_TEMPLATE = base64.b64decode(_MP4_BASE64)


def _create_minimal_mp4(seed: int) -> bytes:
"""Create a minimal valid MP4 with variable seed data."""
# Copy the template before mutating
mp4_bytes = bytearray(MODULE_TEMPLATE)

# Locate the mdat box (media data box) for safe payload region
mdat_pos = mp4_bytes.find(b"mdat")
if mdat_pos == -1:
# If mdat not found, append a harmless free box at the end
# free box: 8 bytes (size + type) + payload
free_box = b"\x00\x00\x00\x10free" # 16 bytes total (8 header + 8 payload)
mp4_bytes.extend(free_box)
payload_start = len(mp4_bytes) - 8 # Start of the free box payload
else:
# mdat box structure: [4 bytes size][4 bytes 'mdat'][payload...]
# Payload starts after the 8-byte header
payload_start = mdat_pos + 8

# Apply seed-based mutations in the payload region
seed_bytes = seed.to_bytes(8, byteorder="big")
for i, byte_val in enumerate(seed_bytes):
if payload_start + i < len(mp4_bytes):
mp4_bytes[payload_start + i] = byte_val

return bytes(mp4_bytes)


def _new_mp4_factory() -> Iterator[bytes]:
"""Generator that yields unique MP4 videos by incrementing seed."""
seed = 0
while True:
yield _create_minimal_mp4(seed)
seed = (seed + 1) % (2**32)


_mp4_factory = _new_mp4_factory()


def make_random_video() -> bytes:
"""Generate a random unique MP4 video for testing."""
return next(_mp4_factory)
Loading
Loading