Compare commits

..

12 Commits

Author SHA1 Message Date
Claude b374d7eb87 fix: resolve lint and formatting failures
- Fix ESLint errors in compare.ts: remove unused params, use toSorted(),
  restructure negated conditions, move imgTag to module scope, replace
  process.exit with throw
- Fix ESLint errors in analyze-deps.ts: use toSorted(), wrap callback
  in arrow function, replace process.exit with throw
- Run Prettier on compare.ts, run-scenarios.ts, and navigation-bar.svelte

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 22:22:33 +00:00
Claude d20def9f66 fix: base screenshots + switch to artifact-based image hosting
- Fix blank base screenshots by aggressively killing the preview server
  between PR and base steps (fuser -k on port 4173) and clearing the
  SvelteKit build cache before rebuilding the base version
- Replace git branch push with GitHub Actions artifact upload:
  - Upload full screenshots as a zipped artifact for download
  - Upload an HTML report with embedded base64 images as a non-zipped
    artifact (archive: false) for direct browser viewing
- Update compare.ts to generate both a text-only markdown summary
  (for the PR comment) and a self-contained HTML visual comparison
- Downgrade permissions from contents:write to contents:read since
  we no longer push to the repository

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 20:38:58 +00:00
Claude 13e8a0121f fix: disable pnpm verifyDepsBeforeRun for base steps and restore PR source
The workspace has verifyDepsBeforeRun: install which triggers pnpm install
before every pnpm exec/run. After checking out the base web/SDK code, the
package.json files no longer match the lockfile, causing pnpm to re-install
and corrupt the workspace state. This broke both the base build and the
compare step.

- Set PNPM_VERIFY_DEPS_BEFORE_RUN=false on all steps after the base checkout
- Add a "Restore PR source" step to reset web/SDK to the PR version before
  running compare, so subsequent pnpm commands work normally

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:59:29 +00:00
Claude fe4c0a95d5 fix: use pnpm build without install for base and ensure dirs exist
- Remove pnpm install from base build steps since it modifies the workspace
  lockfile and can break tsx/other deps used by later steps
- Just run pnpm build using the existing node_modules from the PR install
- Ensure screenshot directories exist before compare step runs

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude c5abd18a64 fix: handle base build failures and add resilient API mocking
- Drop --frozen-lockfile for base builds since the PR's lockfile may not
  match the base branch's package.json
- Add continue-on-error and step dependencies so the workflow continues
  gracefully when base builds fail
- Add catch-all /api/** mock to return empty JSON for unmocked endpoints
- Block socket.io WebSocket connections that prevent networkidle
- Add networkidle timeout fallback in screenshot runner

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude cf1a9ed3f5 fix: add catch-all API mock and socket.io block for base screenshots
Base screenshots showed loading spinners or were missing entirely because:
1. Unmocked API calls (e.g. /api/people, /api/search/explore) hit the static
   preview server which returns HTML instead of JSON, preventing networkidle
2. Socket.io WebSocket connections never complete handshake, blocking networkidle

Add a catch-all /api/** mock (registered first, so specific mocks take priority)
that returns empty JSON for any unmocked endpoint. Block socket.io connections.
Also add a networkidle timeout fallback in run-scenarios.ts so screenshots are
still captured even if networkidle doesn't resolve within 15s.

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude feacf9b134 fix: improve screenshot waiting, add inline images to PR comments
- Increase waitForSelector timeout from 5s to 15s for slower page loads
- Add explicit wait for loading spinners to disappear before screenshot
- Push screenshot images to a temporary branch for inline display
- Read report.md from compare.ts with raw.githubusercontent.com URLs
- Accept optional image base URL in compare.ts CLI
- Upgrade contents permission to write for pushing screenshot branch

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude 67eb33b3a7 fix: use GitHub API for changed files detection and replace add-pr-comment
- Use pulls.listFiles API instead of git diff to detect changed web files
  (resolves issue where git diff returned empty due to checkout strategy)
- Replace mshick/add-pr-comment with actions/github-script for all PR
  comments (fixes "Unexpected input 'github-token'" warning)
- Skip pnpm/playwright setup when no web changes detected (faster skip)
- Add diagnostic logging for changed file detection

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude 23e3d43578 fix: add tsx dependency and fix workflow step ordering
- Add tsx as an e2e devDependency (was missing, causing silent failures)
- Replace `npx tsx` with `pnpm exec tsx` throughout
- Move `pnpm install` before `playwright install` (correct ordering)
- Remove `2>/dev/null` that was hiding analyzer errors
- Add debug logging to route analysis step
- Set default working-directory for the job

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude 028c8a2276 fix: use env vars instead of template expansion in visual-review workflow
Moves all ${{ }} expressions out of `run:` blocks and into `env:` to
prevent potential code injection via template expansion (zizmor finding).
Also switches the initial PR comment to use github-script with env vars
instead of add-pr-comment with inline template interpolation.

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude b7f4cc8171 test: add visible navbar changes to test visual review workflow
Adds a red background tint and [VISUAL TEST] label to the navigation bar.
This commit is intended to be reverted after testing the visual-review
workflow — it exercises the dependency analyzer by changing a shared
component used across many pages.

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
Claude 5c11d15008 feat: add visual review workflow for automated PR screenshot comparison
Adds a label-triggered GitHub Actions workflow that automatically generates
before/after screenshots when web UI changes are made in a PR. Uses smart
dependency analysis to only screenshot pages affected by the changed files.

Key components:
- Reverse dependency analyzer: traces changed files through the import graph
  to find which +page.svelte routes are affected
- Screenshot scenarios: Playwright tests using existing mock-network infrastructure
  (no Docker/backend needed) for fast, deterministic screenshots
- Pixel comparison: generates diff images highlighting changed pixels
- GitHub Actions workflow: triggered by 'visual-review' label, posts results
  as a PR comment with change percentages per page

https://claude.ai/code/session_01XSTqDJXuR4jaLN7SGm3uES
2026-03-01 19:52:59 +00:00
1184 changed files with 37610 additions and 49914 deletions
+1 -1
View File
@@ -1 +1 @@
24.14.1
24.13.1
+2 -2
View File
@@ -1,7 +1,7 @@
{
"scripts": {
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different ."
"format": "prettier --check .",
"format:fix": "prettier --write ."
},
"devDependencies": {
"prettier": "^3.7.4"
-148
View File
@@ -1,148 +0,0 @@
name: Auto-close PRs
on:
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited, labeled]
permissions: {}
jobs:
parse_template:
runs-on: ubuntu-latest
if: ${{ github.event.action != 'labeled' && github.event.pull_request.head.repo.fork == true }}
permissions:
contents: read
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Check required sections
id: check
env:
BODY: ${{ github.event.pull_request.body }}
run: |
OK=true
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
echo "uses_template=$OK" | tee --append "$GITHUB_OUTPUT"
close_template:
runs-on: ubuntu-latest
needs: parse_template
if: >-
${{
needs.parse_template.outputs.uses_template == 'false'
&& github.event.pull_request.state != 'closed'
&& !contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
}}
permissions:
pull-requests: write
steps:
- name: Comment and close
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
- name: Add label
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "auto-closed:template"
close_llm:
runs-on: ubuntu-latest
if: ${{ github.event.action == 'labeled' && github.event.label.name == 'auto-closed:llm' }}
permissions:
pull-requests: write
steps:
- name: Comment and close
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
reopen:
runs-on: ubuntu-latest
needs: parse_template
if: >-
${{
needs.parse_template.outputs.uses_template == 'true'
&& github.event.pull_request.state == 'closed'
&& contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
}}
permissions:
pull-requests: write
steps:
- name: Remove template label
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --remove-label "auto-closed:template" || true
- name: Check for remaining auto-closed labels
id: check_labels
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
REMAINING=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels \
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
echo "remaining=$REMAINING" | tee --append "$GITHUB_OUTPUT"
- name: Reopen PR
if: ${{ steps.check_labels.outputs.remaining == '0' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f query='
mutation ReopenPR($prId: ID!) {
reopenPullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
+11 -11
View File
@@ -51,14 +51,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -79,7 +79,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -103,7 +103,7 @@ jobs:
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/caches
@@ -114,7 +114,7 @@ jobs:
key: build-mobile-gradle-${{ runner.os }}-main
- name: Setup Flutter SDK
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -153,14 +153,14 @@ jobs:
fi
- name: Publish Android Artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
if: github.ref == 'refs/heads/main'
with:
path: |
@@ -185,13 +185,13 @@ jobs:
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- name: Setup Flutter SDK
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -210,7 +210,7 @@ jobs:
working-directory: ./mobile
- name: Setup Ruby
uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
@@ -291,7 +291,7 @@ jobs:
security delete-keychain build.keychain || true
- name: Upload IPA artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: ios-release-ipa
path: mobile/ios/Runner.ipa
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
actions: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+2 -1
View File
@@ -24,7 +24,8 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@1f38ea5ea0b4a2e4e49901c3bcdf4386a05e9ea1 # v0.0.37
# sha is pinning to a commit instead of a tag since the action does not tag versions
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
+9 -9
View File
@@ -31,7 +31,7 @@ jobs:
working-directory: ./cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -42,10 +42,10 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'
@@ -71,7 +71,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -83,13 +83,13 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
@@ -104,7 +104,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
flavor: |
latest=false
@@ -115,7 +115,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
file: cli/Dockerfile
platforms: linux/amd64,linux/arm64
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:557cca601891b8b7d78b940071d35aaf7aaeb9b327d19b22cf282118edbc5272
image: ghcr.io/immich-app/mdq:main@sha256:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:
+38
View File
@@ -0,0 +1,38 @@
name: Close LLM-generated PRs
on:
pull_request_target:
types: [labeled]
permissions: {}
jobs:
comment_and_close:
runs-on: ubuntu-latest
if: ${{ github.event.label.name == 'llm-generated' }}
permissions:
pull-requests: write
steps:
- name: Comment and close
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
+4 -4
View File
@@ -44,7 +44,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
with:
category: '/language:${{matrix.language}}'
+9 -9
View File
@@ -23,14 +23,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -131,8 +131,8 @@ jobs:
- device: rocm
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "pokedex-large"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0
runner-mapping: '{"linux/amd64": "pokedex-giant"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
permissions:
contents: read
actions: read
@@ -155,7 +155,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
permissions:
contents: read
actions: read
@@ -178,7 +178,7 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
with:
needs: ${{ toJSON(needs) }}
@@ -189,6 +189,6 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
with:
needs: ${{ toJSON(needs) }}
+6 -6
View File
@@ -21,14 +21,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -54,7 +54,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -67,10 +67,10 @@ jobs:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './docs/.nvmrc'
cache: 'pnpm'
@@ -86,7 +86,7 @@ jobs:
run: pnpm build
- name: Upload build output
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: docs-build-output
path: docs/build/
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
artifact: ${{ steps.get-artifact.outputs.result }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -119,7 +119,7 @@ jobs:
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -131,7 +131,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
- name: Load parameters
id: parameters
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
- name: Destroy Docs Subdomain
env:
+3 -3
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -29,10 +29,10 @@ jobs:
persist-credentials: true
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+2 -2
View File
@@ -14,13 +14,13 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Require PR to have a changelog label
uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2
uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5.5.1
with:
token: ${{ steps.token.outputs.token }}
mode: exactly
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+7 -8
View File
@@ -50,7 +50,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -63,13 +63,13 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -124,7 +124,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -136,13 +136,13 @@ jobs:
persist-credentials: false
- name: Download APK
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
draft: true
tag_name: ${{ needs.bump_version.outputs.version }}
@@ -151,7 +151,6 @@ jobs:
body_path: misc/release/notes.tmpl
files: |
docker/docker-compose.yml
docker/docker-compose.rootless.yml
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml
+5 -5
View File
@@ -14,12 +14,12 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
@@ -32,7 +32,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -48,14 +48,14 @@ jobs:
name: 'preview'
})
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: ${{ github.event.pull_request.head.repo.fork }}
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
message: 'PRs from forks cannot have preview environments.'
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
github-token: ${{ steps.token.outputs.token }}
+170
View File
@@ -0,0 +1,170 @@
name: Manage release PR
on:
workflow_dispatch:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions: {}
jobs:
bump:
runs-on: ubuntu-latest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Determine release type
id: bump-type
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
with:
token: ${{ steps.generate-token.outputs.token }}
- name: Bump versions
env:
TYPE: ${{ steps.bump-type.outputs.bump }}
run: |
if [ "$TYPE" == "none" ]; then
exit 1 # TODO: Is there a cleaner way to abort the workflow?
fi
misc/release/pump-version.sh -s $TYPE -m true
- name: Manage Outline release document
id: outline
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const fs = require('fs');
const outlineKey = process.env.OUTLINE_API_KEY;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
let documentId;
let documentUrl;
let documentText;
if (!document) {
// Create new document
console.log('No existing document found. Creating new one...');
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'next',
text: notesTmpl,
collectionId: collectionId,
parentDocumentId: parentDocumentId,
publish: true
})
});
if (!createResponse.ok) {
throw new Error(`Failed to create document: ${createResponse.statusText}`);
}
const createData = await createResponse.json();
documentId = createData.data.id;
const urlId = createData.data.urlId;
documentUrl = `${baseUrl}/doc/next-${urlId}`;
documentText = createData.data.text || '';
console.log(`Created new document: ${documentUrl}`);
} else {
documentId = document.id;
const docPath = document.url;
documentUrl = `${baseUrl}${docPath}`;
documentText = document.text || '';
console.log(`Found existing document: ${documentUrl}`);
}
// Generate GitHub release notes
console.log('Generating GitHub release notes...');
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `${process.env.NEXT_VERSION}`,
});
// Combine the content
const changelog = `
# ${process.env.NEXT_VERSION}
${documentText}
${releaseNotesResponse.data.body}
---
`
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
core.setOutput('document_url', documentUrl);
- name: Create PR
id: create-pr
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
labels: 'changelog:skip'
branch: 'release/next'
draft: true
+149
View File
@@ -0,0 +1,149 @@
name: release.yml
on:
pull_request:
types: [closed]
paths:
- CHANGELOG.md
jobs:
# Maybe double check PR source branch?
merge_translations:
uses: ./.github/workflows/merge-translations.yml
permissions:
pull-requests: write
secrets:
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
build_mobile:
uses: ./.github/workflows/build-mobile.yml
needs: merge_translations
permissions:
contents: read
secrets:
KEY_JKS: ${{ secrets.KEY_JKS }}
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
# iOS secrets
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
with:
ref: main
environment: production
prepare_release:
runs-on: ubuntu-latest
needs: build_mobile
permissions:
actions: read # To download the app artifact
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
ref: main
- name: Extract changelog
id: changelog
run: |
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download APK
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
tag_name: ${{ steps.version.outputs.result }}
token: ${{ steps.generate-token.outputs.token }}
body_path: ${{ steps.changelog.outputs.path }}
draft: true
files: |
docker/docker-compose.yml
docker/docker-compose.rootless.yml
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml
docker/prometheus.yml
*.apk
- name: Rename Outline document
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
continue-on-error: true
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
VERSION: ${{ steps.changelog.outputs.version }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const outlineKey = process.env.OUTLINE_API_KEY;
const version = process.env.VERSION;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
if (document) {
console.log(`Found document 'next', renaming to '${version}'...`);
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: document.id,
title: version
})
});
if (!updateResponse.ok) {
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
}
} else {
console.log('No document titled "next" found to rename');
}
+3 -3
View File
@@ -19,7 +19,7 @@ jobs:
working-directory: ./open-api/typescript-sdk
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -30,10 +30,10 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'
+4 -4
View File
@@ -20,14 +20,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -49,7 +49,7 @@ jobs:
working-directory: ./mobile
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -61,7 +61,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
+52 -52
View File
@@ -17,14 +17,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -63,7 +63,7 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -75,9 +75,9 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -108,7 +108,7 @@ jobs:
working-directory: ./cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -119,9 +119,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -155,7 +155,7 @@ jobs:
working-directory: ./cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -166,9 +166,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -197,7 +197,7 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -208,9 +208,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -241,7 +241,7 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -252,9 +252,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -279,7 +279,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -290,9 +290,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -327,7 +327,7 @@ jobs:
working-directory: ./e2e
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -338,9 +338,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -373,7 +373,7 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -385,9 +385,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -412,7 +412,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -424,9 +424,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -464,7 +464,7 @@ jobs:
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
- name: Archive Docker logs
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: e2e-server-docker-logs-${{ matrix.runner }}
@@ -484,7 +484,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -496,9 +496,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -522,7 +522,7 @@ jobs:
run: pnpm test:web
if: ${{ !cancelled() }}
- name: Archive e2e test (web) results
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure()
with:
name: e2e-web-test-results-${{ matrix.runner }}
@@ -533,7 +533,7 @@ jobs:
run: pnpm test:web:ui
if: ${{ !cancelled() }}
- name: Archive ui test (web) results
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure()
with:
name: e2e-ui-test-results-${{ matrix.runner }}
@@ -544,7 +544,7 @@ jobs:
run: pnpm test:web:maintenance
if: ${{ !cancelled() }}
- name: Archive maintenance tests (web) results
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure()
with:
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
@@ -554,7 +554,7 @@ jobs:
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
- name: Archive Docker logs
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: e2e-web-docker-logs-${{ matrix.runner }}
@@ -566,7 +566,7 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
with:
needs: ${{ toJSON(needs) }}
mobile-unit-tests:
@@ -578,7 +578,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -588,7 +588,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -610,7 +610,7 @@ jobs:
working-directory: ./machine-learning
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -620,7 +620,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
with:
python-version: 3.11
- name: Install dependencies
@@ -650,7 +650,7 @@ jobs:
working-directory: ./.github
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -661,9 +661,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
@@ -680,7 +680,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -701,7 +701,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -712,9 +712,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -763,7 +763,7 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -774,9 +774,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
+406
View File
@@ -0,0 +1,406 @@
name: Visual Review
on:
pull_request:
types: [labeled, synchronize]
permissions: {}
jobs:
visual-diff:
name: Visual Diff Screenshots
runs-on: ubuntu-latest
if: >-
(github.event.action == 'labeled' && github.event.label.name == 'visual-review') ||
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'visual-review'))
permissions:
contents: read
pull-requests: write
defaults:
run:
working-directory: ./e2e
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout PR branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
fetch-depth: 0
- name: Determine changed web files
id: changed-files
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const files = [];
const perPage = 100;
let page = 1;
while (true) {
const { data } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: perPage,
page,
});
files.push(...data);
if (data.length < perPage) break;
page++;
}
const webPrefixes = ['web/', 'i18n/', 'open-api/typescript-sdk/'];
const webFiles = files
.map(f => f.filename)
.filter(f => webPrefixes.some(p => f.startsWith(p)));
console.log(`Total PR files: ${files.length}`);
console.log(`Web-related files: ${webFiles.length}`);
for (const f of webFiles) {
console.log(` ${f}`);
}
core.setOutput('files', webFiles.join('\n'));
core.setOutput('has_changes', webFiles.length > 0 ? 'true' : 'false');
- name: Setup pnpm
if: steps.changed-files.outputs.has_changes == 'true'
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
if: steps.changed-files.outputs.has_changes == 'true'
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install e2e dependencies
if: steps.changed-files.outputs.has_changes == 'true'
run: pnpm install --frozen-lockfile
- name: Install Playwright
if: steps.changed-files.outputs.has_changes == 'true'
run: pnpm exec playwright install chromium --only-shell
- name: Analyze affected routes
if: steps.changed-files.outputs.has_changes == 'true'
id: routes
env:
CHANGED_FILES: ${{ steps.changed-files.outputs.files }}
run: |
echo "Changed files:"
echo "$CHANGED_FILES"
echo "---"
ROUTES=$(echo "$CHANGED_FILES" | xargs pnpm exec tsx src/screenshots/analyze-deps.ts 2>&1 | tee /dev/stderr | grep "^ /" | sed 's/^ //' || true)
echo "routes<<EOF" >> "$GITHUB_OUTPUT"
echo "$ROUTES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
if [ -z "$ROUTES" ]; then
echo "has_routes=false" >> "$GITHUB_OUTPUT"
else
echo "has_routes=true" >> "$GITHUB_OUTPUT"
# Build the scenario filter JSON array
SCENARIO_NAMES=$(pnpm exec tsx -e "
import { getScenariosForRoutes } from './src/screenshots/page-map.ts';
const routes = process.argv.slice(1);
const scenarios = getScenariosForRoutes(routes);
console.log(JSON.stringify(scenarios.map(s => s.name)));
" $ROUTES)
echo "scenarios=$SCENARIO_NAMES" >> "$GITHUB_OUTPUT"
echo "Scenarios: $SCENARIO_NAMES"
fi
- name: Post initial comment
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
AFFECTED_ROUTES: ${{ steps.routes.outputs.routes }}
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const routes = process.env.AFFECTED_ROUTES || '';
const body = `## Visual Review\n\nGenerating screenshots for affected pages...\n\nAffected routes:\n\`\`\`\n${routes}\n\`\`\``;
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
# === Screenshot PR version ===
- name: Build SDK (PR)
if: steps.routes.outputs.has_routes == 'true'
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
- name: Build web (PR)
if: steps.routes.outputs.has_routes == 'true'
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./web
- name: Take screenshots (PR)
if: steps.routes.outputs.has_routes == 'true'
env:
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/pr
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
run: |
# Start the preview server in background
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
SERVER_PID=$!
# Wait for server to be ready
for i in $(seq 1 30); do
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
echo "Server ready after ${i}s"
break
fi
sleep 1
done
# Run screenshot tests
pnpm exec playwright test --config playwright.screenshot.config.ts || true
# Stop the preview server and all children (pnpm spawns vite as child)
kill $SERVER_PID 2>/dev/null || true
sleep 1
# Ensure port is fully released — kill any lingering vite process
fuser -k 4173/tcp 2>/dev/null || true
sleep 1
# === Screenshot base version ===
# Disable pnpm's verifyDepsBeforeRun for all base steps since the base
# checkout changes package.json files, making them mismatch the lockfile.
- name: Checkout base web directory
if: steps.routes.outputs.has_routes == 'true'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
# Restore web directory from base branch
git checkout "$BASE_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
# Clear SvelteKit build cache to avoid stale artifacts from the PR build
rm -rf web/.svelte-kit web/build
working-directory: .
- name: Build SDK (base)
if: steps.routes.outputs.has_routes == 'true'
continue-on-error: true
id: base-sdk
env:
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
run: pnpm build
working-directory: ./open-api/typescript-sdk
- name: Build web (base)
if: steps.routes.outputs.has_routes == 'true' && steps.base-sdk.outcome == 'success'
continue-on-error: true
id: base-web
env:
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
run: pnpm build
working-directory: ./web
- name: Take screenshots (base)
if: steps.routes.outputs.has_routes == 'true' && steps.base-web.outcome == 'success'
env:
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/base
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
run: |
# Kill any process still on port 4173 from the PR step
fuser -k 4173/tcp 2>/dev/null || true
sleep 1
# Start the preview server in background
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
SERVER_PID=$!
# Wait for server to be ready
for i in $(seq 1 30); do
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
echo "Server ready after ${i}s"
break
fi
sleep 1
done
# Run screenshot tests
pnpm exec playwright test --config playwright.screenshot.config.ts || true
# Stop the preview server
kill $SERVER_PID 2>/dev/null || true
fuser -k 4173/tcp 2>/dev/null || true
- name: Restore PR source
if: steps.routes.outputs.has_routes == 'true'
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
git checkout "$HEAD_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
working-directory: .
# === Compare and report ===
- name: Compare screenshots
if: steps.routes.outputs.has_routes == 'true'
env:
WORKSPACE_DIR: ${{ github.workspace }}
run: |
# Ensure directories exist even if base screenshots were skipped
mkdir -p "$WORKSPACE_DIR/screenshots/base" "$WORKSPACE_DIR/screenshots/pr" "$WORKSPACE_DIR/screenshots/diff"
pnpm exec tsx src/screenshots/compare.ts \
"$WORKSPACE_DIR/screenshots/base" \
"$WORKSPACE_DIR/screenshots/pr" \
"$WORKSPACE_DIR/screenshots/diff"
- name: Upload screenshot artifacts
if: steps.routes.outputs.has_routes == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: visual-review-screenshots
path: screenshots/
retention-days: 14
- name: Upload HTML report
if: steps.routes.outputs.has_routes == 'true'
id: html-report
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
path: screenshots/diff/visual-review.html
archive: false
retention-days: 14
- name: Post comparison results
if: steps.routes.outputs.has_routes == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
REPORT_URL: ${{ steps.html-report.outputs.artifact-url }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const fs = require('fs');
const path = require('path');
const reportPath = path.join(process.env.GITHUB_WORKSPACE, 'screenshots', 'diff', 'report.md');
let body;
try {
body = fs.readFileSync(reportPath, 'utf8');
} catch {
body = '## Visual Review\n\nScreenshot comparison failed. Check the workflow artifacts for details.';
}
// Append links to the HTML report artifact and workflow run
const reportUrl = process.env.REPORT_URL;
const runUrl = process.env.RUN_URL;
body += '\n---\n';
if (reportUrl) {
body += `[View full visual comparison](${reportUrl}) | `;
}
body += `[Download all screenshots](${runUrl}#artifacts)\n`;
// Find and update existing comment or create new one
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.data.find(c =>
c.body && c.body.includes('## Visual Review')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: No web changes
if: steps.changed-files.outputs.has_changes != 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const body = '## Visual Review\n\nNo web-related file changes detected in this PR. Visual review not needed.';
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner, repo: context.repo.repo,
comment_id: existing.id, body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number, body,
});
}
- name: No affected routes
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes != 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const body = '## Visual Review\n\nChanged files don\'t affect any pages with screenshot scenarios configured.\nTo add coverage, define new scenarios in `e2e/src/screenshots/page-map.ts`.';
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner, repo: context.repo.repo,
comment_id: existing.id, body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number, body,
});
}
+4 -4
View File
@@ -24,14 +24,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -47,7 +47,7 @@ jobs:
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -68,6 +68,6 @@ jobs:
permissions: {}
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
with:
needs: ${{ toJSON(needs) }}
+1 -2
View File
@@ -24,9 +24,8 @@ open-api/typescript-sdk/build
mobile/android/fastlane/report.xml
mobile/ios/fastlane/report.xml
screenshots-output
vite.config.js.timestamp-*
.pnpm-store
.devcontainer/library
.devcontainer/.env*
*.tsbuildinfo
*.tsbuildInfo
+1 -8
View File
@@ -5,13 +5,6 @@
"dbaeumer.vscode-eslint",
"dart-code.flutter",
"dart-code.dart-code",
"dcmdev.dcm-vscode-extension",
"bradlc.vscode-tailwindcss",
"ms-playwright.playwright",
"vitest.explorer",
"editorconfig.editorconfig",
"foxundermoon.shell-format",
"timonwong.shellcheck",
"bluebrown.yamlfmt"
"dcmdev.dcm-vscode-extension"
]
}
+13 -35
View File
@@ -1,7 +1,8 @@
{
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[dart]": {
"editor.defaultFormatter": "Dart-Code.dart-code",
@@ -18,15 +19,18 @@
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[svelte]": {
"editor.codeActionsOnSave": {
@@ -34,7 +38,8 @@
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[typescript]": {
"editor.codeActionsOnSave": {
@@ -42,45 +47,18 @@
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"cSpell.words": ["immich"],
"css.lint.unknownAtRules": "ignore",
"editor.bracketPairColorization.enabled": true,
"editor.formatOnSave": true,
"eslint.useFlatConfig": true,
"eslint.validate": ["javascript", "typescript", "svelte"],
"eslint.workingDirectories": [
{ "directory": "cli", "changeProcessCWD": true },
{ "directory": "e2e", "changeProcessCWD": true },
{ "directory": "server", "changeProcessCWD": true },
{ "directory": "web", "changeProcessCWD": true }
],
"files.watcherExclude": {
"**/.jj/**": true,
"**/.git/**": true,
"**/node_modules/**": true,
"**/build/**": true,
"**/dist/**": true,
"**/.svelte-kit/**": true
},
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
},
"search.exclude": {
"**/node_modules": true,
"**/build": true,
"**/dist": true,
"**/.svelte-kit": true,
"**/open-api/typescript-sdk/src": true
},
"svelte.enable-ts-plugin": true,
"tailwindCSS.experimental.configFile": {
"web/src/app.css": "web/src/**"
},
"js/ts.preferences.importModuleSpecifier": "non-relative",
"vitest.maximumConfigs": 10
"typescript.preferences.importModuleSpecifier": "non-relative"
}
-2
View File
@@ -15,8 +15,6 @@ Please try to keep pull requests as focused as possible. A PR should do exactly
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
We usually do not assign issues to new contributors, since it happens often that a PR is never even opened. Again, reach out on Discord if you fear putting a lot of time into fixing an issue, but ending up with a duplicate PR.
## Use of generative AI
We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request.
+1 -1
View File
@@ -1 +1 @@
24.14.1
24.13.1
+12 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.7.5",
"version": "2.5.6",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@@ -20,23 +20,24 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.12.0",
"@vitest/coverage-v8": "^4.0.0",
"@types/node": "^24.10.13",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^64.0.0",
"eslint-plugin-unicorn": "^63.0.0",
"globals": "^17.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^6.0.0",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.0",
"vitest": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"vite": "^7.0.0",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^3.0.0",
"vitest-fetch-mock": "^0.4.0",
"yaml": "^2.3.1"
},
@@ -48,8 +49,8 @@
"prepack": "pnpm run build",
"test": "vitest",
"test:cov": "vitest --coverage",
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different .",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"check": "tsc --noEmit"
},
"repository": {
@@ -68,6 +69,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "24.14.1"
"node": "24.13.1"
}
}
+42 -51
View File
@@ -1,10 +1,10 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { describe, expect, it, MockedFunction, vi } from 'vitest';
import { AssetRejectReason, AssetUploadAction, checkBulkUpload, defaults, getSupportedMediaTypes } from '@immich/sdk';
import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk';
import createFetchMock from 'vitest-fetch-mock';
import {
@@ -58,7 +58,7 @@ describe('uploadFiles', () => {
});
it('returns new assets when upload file is successful', async () => {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
return {
status: 200,
body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }),
@@ -75,7 +75,7 @@ describe('uploadFiles', () => {
it('returns new assets when upload file retry is successful', async () => {
let counter = 0;
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
counter++;
if (counter < retry) {
throw new Error('Network error');
@@ -96,7 +96,7 @@ describe('uploadFiles', () => {
});
it('returns new assets when upload file retry is failed', async () => {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
throw new Error('Network error');
});
@@ -120,7 +120,7 @@ describe('checkForDuplicates', () => {
vi.mocked(checkBulkUpload).mockResolvedValue({
results: [
{
action: AssetUploadAction.Accept,
action: Action.Accept,
id: testFilePath,
},
],
@@ -144,10 +144,10 @@ describe('checkForDuplicates', () => {
vi.mocked(checkBulkUpload).mockResolvedValue({
results: [
{
action: AssetUploadAction.Reject,
action: Action.Reject,
id: testFilePath,
assetId: 'fc5621b1-86f6-44a1-9905-403e607df9f5',
reason: AssetRejectReason.Duplicate,
reason: Reason.Duplicate,
},
],
});
@@ -167,7 +167,7 @@ describe('checkForDuplicates', () => {
vi.mocked(checkBulkUpload).mockResolvedValue({
results: [
{
action: AssetUploadAction.Accept,
action: Action.Accept,
id: testFilePath,
},
],
@@ -187,7 +187,7 @@ describe('checkForDuplicates', () => {
mocked.mockResolvedValue({
results: [
{
action: AssetUploadAction.Accept,
action: Action.Accept,
id: testFilePath,
},
],
@@ -236,19 +236,16 @@ describe('startWatch', () => {
await sleep(100); // to debounce the watcher from considering the test file as a existing file
await fs.promises.writeFile(testFilePath, 'testjpg');
await vi.waitFor(
() =>
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: [
expect.objectContaining({
id: testFilePath,
}),
],
},
}),
{ timeout: 5000 },
);
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: [
expect.objectContaining({
id: testFilePath,
}),
],
},
});
});
it('should filter out unsupported files', async () => {
@@ -260,19 +257,16 @@ describe('startWatch', () => {
await fs.promises.writeFile(testFilePath, 'testjpg');
await fs.promises.writeFile(unsupportedFilePath, 'testtxt');
await vi.waitFor(
() =>
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: testFilePath,
}),
]),
},
}),
{ timeout: 5000 },
);
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: testFilePath,
}),
]),
},
});
expect(checkBulkUpload).not.toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
@@ -297,19 +291,16 @@ describe('startWatch', () => {
await fs.promises.writeFile(testFilePath, 'testjpg');
await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg');
await vi.waitFor(
() =>
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: testFilePath,
}),
]),
},
}),
{ timeout: 5000 },
);
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: testFilePath,
}),
]),
},
});
expect(checkBulkUpload).not.toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
+2 -2
View File
@@ -1,9 +1,9 @@
import {
Action,
AssetBulkUploadCheckItem,
AssetBulkUploadCheckResult,
AssetMediaResponseDto,
AssetMediaStatus,
AssetUploadAction,
Permission,
addAssetsToAlbum,
checkBulkUpload,
@@ -234,7 +234,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
const results = response.results as AssetBulkUploadCheckResults;
for (const { id: filepath, assetId, action } of results) {
if (action === AssetUploadAction.Accept) {
if (action === Action.Accept) {
newFiles.push(filepath);
} else {
// rejects are always duplicates
+1 -1
View File
@@ -81,7 +81,7 @@ export const connect = async (url: string, key: string) => {
const [error] = await withError(getMyUser());
if (isHttpError(error)) {
logError(error, `Failed to connect to server ${url}`);
logError(error, 'Failed to connect to server');
process.exit(1);
}
+2 -6
View File
@@ -15,12 +15,8 @@
"incremental": true,
"skipLibCheck": true,
"esModuleInterop": true,
"rootDir": "./src",
"paths": {
"src/*": ["./src/*"],
},
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
"baseUrl": "./",
"types": ["vitest/globals"]
},
"exclude": ["dist", "node_modules", "vite.config.ts"]
"exclude": ["dist", "node_modules"]
}
+6 -11
View File
@@ -1,12 +1,10 @@
import { defineConfig, UserConfig } from 'vite';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
resolve: {
alias: { src: '/src' },
tsconfigPaths: true,
},
resolve: { alias: { src: '/src' } },
build: {
rolldownOptions: {
rollupOptions: {
input: 'src/index.ts',
output: {
dir: 'dist',
@@ -18,8 +16,5 @@ export default defineConfig({
// bundle everything except for Node built-ins
noExternal: /^(?!node:).*$/,
},
test: {
name: 'cli:unit',
globals: true,
},
} as UserConfig);
plugins: [tsconfigPaths()],
});
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
},
});
+2 -2
View File
@@ -1,6 +1,6 @@
[tools]
terragrunt = "0.99.5"
opentofu = "1.11.5"
terragrunt = "0.98.0"
opentofu = "1.11.4"
[tasks."tg:fmt"]
run = "terragrunt hclfmt"
+1 -2
View File
@@ -90,7 +90,6 @@ services:
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
IMMICH_HELMET_FILE: 'true'
ports:
- 9230:9230
- 9231:9231
@@ -156,7 +155,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
healthcheck:
test: redis-cli ping || exit 1
+3 -3
View File
@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:dda13e28bf95a5e5ca5b8ed56852006094c1c8e8871d9c9dbeed30aa6e55271f
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
@@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.4.2-ubuntu@sha256:78839fe49e1425c02416fa8072591533a72bd9598e563b54a07d78f9e27fb5d3
image: grafana/grafana:12.3.2-ubuntu@sha256:6cca4b429a1dc0d37d401dee54825c12d40056c3c6f3f56e3f0d6318ce77749b
volumes:
- grafana-data:/var/lib/grafana
+1 -1
View File
@@ -61,7 +61,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
user: '1000:1000'
security_opt:
- no-new-privileges:true
+1 -1
View File
@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
healthcheck:
test: redis-cli ping || exit 1
restart: always
+1 -1
View File
@@ -1 +1 @@
24.14.1
24.13.1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

+3 -4
View File
@@ -67,8 +67,7 @@ graph TD
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
D --> E[Smart Search]
D --> F[Face Detection]
D --> G[OCR]
D --> H[Video Transcoding]
E --> I[Duplicate Detection]
F --> J[Facial Recognition]
D --> G[Video Transcoding]
E --> H[Duplicate Detection]
F --> I[Facial Recognition]
```
-37
View File
@@ -14,7 +14,6 @@ Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an i
- [Authelia](https://www.authelia.com/integration/openid-connect/immich/)
- [Okta](https://www.okta.com/openid-connect/)
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
- [Keycloak](https://www.keycloak.org)
## Prerequisites
@@ -254,40 +253,4 @@ Configuration of OAuth in Immich System Settings
</details>
<details>
<summary>Keycloak Example</summary>
### Keycloak Example
Here's an example of OAuth configured for Keycloak:
Create your immich client on your Keycloak Realm.
<img src={require('./img/keycloak-general-settings.webp').default} width='100%' title="Keycloak Client general Settings" />
<img src={require('./img/keycloak-access-settings.webp').default} width='100%' title="Keycloak Client Access Settings" />
<img src={require('./img/keycloak-capability-config.webp').default} width='100%' title="Keycloak Client Capability Configuration" />
Configuration of OAuth in Immich System Settings
| Setting | Value |
| ---------------------------- | ----------------------------------------------------- |
| Issuer URL | `https://<KEYCLOAK_DOMAIN>/realms/<YOUR_REALM>` |
| Client ID | immich |
| Client Secret | can be optained from Clients -> immich -> Credentials |
| Scope | openid email profile |
| Signing Algorithm | RS256 |
| Storage Label Claim | preferred_username |
| Role Claim | immich_role |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
| Button Text | Sign in with Keycloak (recommended) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled (optional) |
| Mobile Redirect URI Override | Disabled |
| Mobile Redirect URI | |
Role Claim can be managed via Client Role. Remember to create a mapper with claim name `immich_role`.
</details>
[oidc]: https://openid.net/connect/
+1 -1
View File
@@ -230,7 +230,7 @@ The default value is `ultrafast`.
### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec}
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `opus`.
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `libopus`.
The default value is `aac`.
-28
View File
@@ -1,28 +0,0 @@
# Duplicates Utility
Immich comes with a duplicates utility to help you detect assets that look visually similar. The duplicate detection feature relies on machine learning and is enabled by default. For more information about when the duplicate detection job runs, see [Jobs and Workers](/administration/jobs-workers). Once an asset has been processed and added to a duplicate group, it becomes available to review in the "Review duplicates" utility, which can be found [here](https://my.immich.app/utilities/duplicates).
## Reviewing duplicates
The review duplicates page allows the user to individually select which assets should be kept and which ones should be trashed. When more than one asset is kept, there is an option to automatically put the kept assets into a stack.
### Automatic preselection
When using "Deduplicate All" or viewing suggestions, Immich automatically preselects which assets to keep based on:
1. **Image size in bytes** — larger files are preferred as they typically have higher quality.
2. **Count of EXIF data** — assets with more metadata are preferred.
### Synchronizing metadata
When resolving duplicates, metadata from trashed assets is automatically synchronized to the kept assets. The following metadata is synchronized:
| Name | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Album | The kept assets will be added to _every_ album that the other assets in the group belong to. |
| Favorite | If any of the assets in the group have been added to favorites, every kept asset will also be added to favorites. |
| Rating | If one or more assets in the duplicate group have a rating, the highest rating is selected and synchronized to the kept assets. |
| Description | Descriptions from each asset are combined together and synchronized to all the kept assets. |
| Visibility | The most restrictive visibility is applied to the kept assets. |
| Location | Latitude and longitude are copied if all assets with geolocation data in the group share the same coordinates. |
| Tag | Tags from all assets in the group are merged and applied to every kept asset. |
+1 -1
View File
@@ -26,7 +26,7 @@ You can search the following types of content:
| Time frame | Start and end date of a specific time bucket |
| Media type | Image or video or both |
| Display options | In Archive, in Favorites or Not in any album |
| Star rating | User-assigned star rating |
| Start rating | User-assigned start rating |
<img src={require('./img/advanced-search-filters.webp').default} width="70%" title='Advanced search filters' />
+14 -14
View File
@@ -28,17 +28,17 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
## Video formats
| Format | Extension(s) | Supported? | Notes |
| :---------- | :-------------------------- | :----------------: | :---- |
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
| `AVI` | `.avi` | :white_check_mark: | |
| `FLV` | `.flv` | :white_check_mark: | |
| `M4V` | `.m4v` | :white_check_mark: | |
| `MATROSKA` | `.mkv` | :white_check_mark: | |
| `MP2T` | `.mts` `.m2ts` `.m2t` `.ts` | :white_check_mark: | |
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
| `MXF` | `.mxf` | :white_check_mark: | |
| `QUICKTIME` | `.mov` | :white_check_mark: | |
| `WEBM` | `.webm` | :white_check_mark: | |
| `WMV` | `.wmv` | :white_check_mark: | |
| Format | Extension(s) | Supported? | Notes |
| :---------- | :-------------------- | :----------------: | :---- |
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
| `AVI` | `.avi` | :white_check_mark: | |
| `FLV` | `.flv` | :white_check_mark: | |
| `M4V` | `.m4v` | :white_check_mark: | |
| `MATROSKA` | `.mkv` | :white_check_mark: | |
| `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | |
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
| `MXF` | `.mxf` | :white_check_mark: | |
| `QUICKTIME` | `.mov` | :white_check_mark: | |
| `WEBM` | `.webm` | :white_check_mark: | |
| `WMV` | `.wmv` | :white_check_mark: | |
+2 -2
View File
@@ -3,8 +3,8 @@
You may decide that you'd like to modify the style document which is used to
draw the maps in Immich. In addition to visual customization, this also allows
you to pick your own map tile provider instead of the default one. The default
`style.json` for [light theme](https://tiles.immich.cloud/v1/style/light.json)
and [dark theme](https://tiles.immich.cloud/v1/style/dark.json)
`style.json` for [light theme](https://github.com/immich-app/immich/tree/main/server/resources/style-light.json)
and [dark theme](https://github.com/immich-app/immich/blob/main/server/resources/style-dark.json)
can be used as a basis for creating your own style.
There are several sources for already-made `style.json` map themes, as well as
+1 -1
View File
@@ -27,7 +27,7 @@ The default configuration looks like this:
"ffmpeg": {
"accel": "disabled",
"accelDecode": false,
"acceptedAudioCodecs": ["aac", "mp3", "opus"],
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
"acceptedContainers": ["mov", "ogg", "webm"],
"acceptedVideoCodecs": ["h264"],
"bframes": -1,
+16 -19
View File
@@ -29,23 +29,22 @@ These environment variables are used by the `docker-compose.yml` file and do **N
## General
| Variable | Description | Default | Containers | Workers |
| :---------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `IMMICH_HELMET_FILE` | Path to a json file with [helmet](https://www.npmjs.com/package/helmet) options. Set to `false` to disable. Set to `true` to use `server/helmet.json`. | `false` | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
| Variable | Description | Default | Containers | Workers |
| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
@@ -167,8 +166,6 @@ Redis (Sentinel) URL example JSON before encoding:
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
+2 -6
View File
@@ -8,7 +8,7 @@ Hardware and software requirements for Immich:
## Hardware
- **OS**: Recommended Linux or \*nix 64-bit operating system (Ubuntu, Debian, etc).
- **OS**: Recommended Linux or \*nix operating system (Ubuntu, Debian, etc).
- Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged.
Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced.
If you still want to try to use a non-Linux OS, you can set it up as follows:
@@ -19,10 +19,6 @@ Hardware and software requirements for Immich:
If you have issues, we recommend that you switch to a supported VM deployment.
- **RAM**: Minimum 6GB, recommended 8GB.
- **CPU**: Minimum 2 cores, recommended 4 cores.
- Immich runs on the `amd64` and `arm64` platforms.
Since `v2.6`, the machine learning container on `amd64` requires the `>= x86-64-v2` [microarchitecture level](https://en.wikipedia.org/wiki/X86-64#Microarchitecture_levels).
Most CPUs released since ~2012 support this microarchitecture.
If you are using a virtual machine, ensure you have selected a [supported microarchitecture](https://pve.proxmox.com/pve-docs/chapter-qm.html#_qemu_cpu_types).
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
@@ -49,7 +45,7 @@ Immich requires [**Docker**](https://docs.docker.com/get-started/get-docker/) wi
The Compose plugin will be installed by both Docker Engine and Desktop by following the linked installation guides; it can also be [separately installed](https://docs.docker.com/compose/install/).
:::note
Immich requires the command `docker compose`; the similarly named `docker-compose` is [deprecated](https://docs.docker.com/retired/#docker-compose-v1-replaced-by-compose-v2) and is no longer supported by Immich.
Immich requires the command `docker compose`; the similarly named `docker-compose` is [deprecated](https://docs.docker.com/compose/migrate/) and is no longer supported by Immich.
:::
### Special requirements for Windows users
-2
View File
@@ -6,8 +6,6 @@ You can read more about the differences between storage template engine on and o
The admin user can set the template by using the template builder in the `Administration -> Settings -> Storage Template`. Immich provides a set of variables that you can use in constructing the template, along with additional custom text. If the template produces [multiple files with the same filename, they won't be overwritten](https://github.com/immich-app/immich/discussions/3324) as a sequence number is appended to the filename.
Date and time variables in storage templates are rendered in the server's local timezone.
```bash title="Default template"
Year/Year-Month-Day/Filename.Extension
```
+7 -7
View File
@@ -4,8 +4,8 @@
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different .",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"start": "docusaurus start --port 3005",
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
"build": "pnpm run copy:openapi && docusaurus build",
@@ -30,17 +30,17 @@
"postcss": "^8.4.25",
"prism-react-renderer": "^2.3.1",
"raw-loader": "^4.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"tailwindcss": "^3.2.4",
"url": "^0.11.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "~3.9.0",
"@docusaurus/tsconfig": "^3.10.0",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.7.0",
"prettier": "^3.7.4",
"typescript": "^6.0.0"
"typescript": "^5.1.6"
},
"browserslist": {
"production": [
@@ -58,6 +58,6 @@
"node": ">=20"
},
"volta": {
"node": "24.14.1"
"node": "24.13.1"
}
}
-8
View File
@@ -1,12 +1,4 @@
[
{
"label": "v2.7.5",
"url": "https://docs.v2.7.5.archive.immich.app"
},
{
"label": "v2.6.3",
"url": "https://docs.v2.6.3.archive.immich.app"
},
{
"label": "v2.5.6",
"url": "https://docs.v2.5.6.archive.immich.app"
+5 -1
View File
@@ -1,4 +1,8 @@
{
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@docusaurus/tsconfig"
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": "."
}
}
+4 -25
View File
@@ -10,7 +10,6 @@ export enum OAuthClient {
export enum OAuthUser {
NO_EMAIL = 'no-email',
NO_NAME = 'no-name',
ID_TOKEN_CLAIMS = 'id-token-claims',
WITH_QUOTA = 'with-quota',
WITH_USERNAME = 'with-username',
WITH_ROLE = 'with-role',
@@ -53,25 +52,12 @@ const withDefaultClaims = (sub: string) => ({
email_verified: true,
});
const getClaims = (sub: string, use?: string) => {
if (sub === OAuthUser.ID_TOKEN_CLAIMS) {
return {
sub,
email: `oauth-${sub}@immich.app`,
email_verified: true,
name: use === 'id_token' ? 'ID Token User' : 'Userinfo User',
};
}
return claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
};
const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
const setup = async () => {
const { privateKey, publicKey } = await generateKeyPair('RS256');
const redirectUris = [
'http://127.0.0.1:2285/auth/login',
'https://photos.immich.app/oauth/mobile-redirect',
];
const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect'];
const port = 2286;
const host = '0.0.0.0';
const oidc = new Provider(`http://${host}:${port}`, {
@@ -80,10 +66,7 @@ const setup = async () => {
console.error(error);
ctx.body = 'Internal Server Error';
},
findAccount: (ctx, sub) => ({
accountId: sub,
claims: (use) => getClaims(sub, use),
}),
findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }),
scopes: ['openid', 'email', 'profile'],
claims: {
openid: ['sub'],
@@ -111,7 +94,6 @@ const setup = async () => {
state: 'oidc.state',
},
},
conformIdTokenClaims: false,
pkce: {
required: () => false,
},
@@ -143,10 +125,7 @@ const setup = async () => {
],
});
const onStart = () =>
console.log(
`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`,
);
const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`);
const app = oidc.listen(port, host, onStart);
return () => app.close();
};
+1 -1
View File
@@ -1 +1 @@
24.14.1
24.13.1
+1 -1
View File
@@ -44,7 +44,7 @@ services:
redis:
container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
healthcheck:
test: redis-cli ping || exit 1
+15 -12
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.7.5",
"version": "2.5.6",
"description": "",
"main": "index.js",
"type": "module",
@@ -14,11 +14,14 @@
"start:web": "pnpm exec playwright test --ui --project=web",
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different .",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "pnpm run lint --fix",
"check": "tsc --noEmit"
"check": "tsc --noEmit",
"screenshots": "pnpm exec playwright test --config playwright.screenshot.config.ts",
"screenshots:compare": "pnpm exec tsx src/screenshots/compare.ts",
"screenshots:analyze": "pnpm exec tsx src/screenshots/analyze-deps.ts"
},
"keywords": [],
"author": "",
@@ -27,20 +30,20 @@
"@eslint/js": "^10.0.0",
"@faker-js/faker": "^10.1.0",
"@immich/cli": "workspace:*",
"@immich/e2e-auth-server": "workspace:*",
"@immich/e2e-auth-server": "workspace:*",
"@immich/sdk": "workspace:*",
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.12.0",
"@types/node": "^24.10.13",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^7.0.0",
"@types/supertest": "^6.0.2",
"dotenv": "^17.2.3",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^64.0.0",
"eslint-plugin-unicorn": "^63.0.0",
"exiftool-vendored": "^35.0.0",
"globals": "^17.0.0",
"luxon": "^3.4.4",
@@ -51,13 +54,13 @@
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^6.0.0",
"tsx": "^4.21.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"utimes": "^5.2.1",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.0"
"vitest": "^3.0.0"
},
"volta": {
"node": "24.14.1"
"node": "24.13.1"
}
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
const baseUrl = process.env.SCREENSHOT_BASE_URL ?? 'http://127.0.0.1:4173';
export default defineConfig({
testDir: './src/screenshots',
testMatch: /run-scenarios\.ts/,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: 'list',
use: {
baseURL: baseUrl,
screenshot: 'off',
trace: 'off',
},
workers: 1,
projects: [
{
name: 'screenshots',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1080 },
},
},
],
});
-651
View File
@@ -1,651 +0,0 @@
import { LoginResponseDto } from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/duplicates', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
[user1, user2] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
]);
});
beforeEach(async () => {
// Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs
// Note: We don't reset users since they're set up once in beforeAll
// Stack must be reset before asset due to foreign key constraint
await utils.resetDatabase(['stack', 'asset', 'album', 'tag']);
});
describe('GET /duplicates', () => {
it('should return empty array when no duplicates', async () => {
const { status, body } = await request(app)
.get('/duplicates')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([]);
});
it('should return duplicate groups with suggestedKeepAssetIds', async () => {
// Create assets with different file sizes for duplicate detection
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Manually set duplicateId on both assets to create a duplicate group
const duplicateId = '00000000-0000-4000-8000-000000000001';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.get('/duplicates')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([
{
duplicateId,
assets: expect.arrayContaining([
expect.objectContaining({ id: asset1.id }),
expect.objectContaining({ id: asset2.id }),
]),
suggestedKeepAssetIds: expect.any(Array),
},
]);
expect(body[0].suggestedKeepAssetIds.length).toBe(1);
});
});
describe('POST /duplicates/resolve', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post('/duplicates/resolve')
.send({
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return failure for non-existent duplicate group', async () => {
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId: uuidDto.dummy,
status: 'FAILED',
reason: expect.stringContaining('not found or access denied'),
},
],
});
});
it('should resolve duplicate group with keepers', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000002';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId,
status: 'SUCCESS',
},
],
});
// Verify side effects: duplicateId cleared on kept asset
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
// Verify side effects: trashed asset is trashed and duplicateId cleared
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(trashedAsset.isTrashed).toBe(true);
expect(trashedAsset.duplicateId).toBeNull();
});
it('should reject when keepAssetIds and trashAssetIds overlap', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000003';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('disjoint');
});
it('should require keepAssetIds when partially trashing', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000004';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('must cover all assets');
});
it('should reject partial resolution (not all assets covered)', async () => {
const [asset1, asset2, asset3] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000010';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('must cover all assets');
});
it('should reject asset not in duplicate group', async () => {
const [asset1, asset2, outsideAsset] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000011';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('not a member of duplicate group');
});
it('should allow trash-all without keepers', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000012';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId,
status: 'SUCCESS',
},
],
});
// Verify both assets are trashed
const [asset1Info, asset2Info] = await Promise.all([
utils.getAssetInfo(user1.accessToken, asset1.id),
utils.getAssetInfo(user1.accessToken, asset2.id),
]);
expect(asset1Info.isTrashed).toBe(true);
expect(asset1Info.duplicateId).toBeNull();
expect(asset2Info.isTrashed).toBe(true);
expect(asset2Info.duplicateId).toBeNull();
});
it('should reject cross-user duplicate group access', async () => {
const asset1 = await utils.createAsset(user1.accessToken);
const asset2 = await utils.createAsset(user2.accessToken);
const duplicateId = '00000000-0000-4000-8000-000000000013';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId);
// User1 tries to resolve a group containing user2's asset
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('not a member of duplicate group');
});
it('should synchronize favorites when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Mark one asset as favorite
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], isFavorite: true });
const duplicateId = '00000000-0000-4000-8000-000000000020';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify favorite was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.isFavorite).toBe(true);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize visibility when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Archive one asset
await utils.archiveAssets(user1.accessToken, [asset2.id]);
const duplicateId = '00000000-0000-4000-8000-000000000021';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify visibility was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.visibility).toBe('archive');
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize rating when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set rating on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], rating: 5 });
const duplicateId = '00000000-0000-4000-8000-000000000022';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify rating was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.rating).toBe(5);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize description when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set description on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], description: 'Test description for duplicate' });
const duplicateId = '00000000-0000-4000-8000-000000000023';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify description was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate');
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize location when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set location on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 });
const duplicateId = '00000000-0000-4000-8000-000000000024';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify location was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.latitude).toBe(40.7128);
expect(keptAsset.exifInfo?.longitude).toBe(-74.006);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize albums when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Create albums and add assets to different albums
const album1 = await utils.createAlbum(user1.accessToken, {
albumName: 'Album 1',
assetIds: [asset1.id],
});
const album2 = await utils.createAlbum(user1.accessToken, {
albumName: 'Album 2',
assetIds: [asset2.id],
});
const duplicateId = '00000000-0000-4000-8000-000000000025';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify keeper is now in both albums
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
// Check albums directly
const { status: album1Status, body: album1Body } = await request(app)
.get(`/albums/${album1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
const { status: album2Status, body: album2Body } = await request(app)
.get(`/albums/${album2.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(album1Status).toBe(200);
expect(album2Status).toBe(200);
expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id);
expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id);
});
it('should synchronize tags when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Wait for metadata extraction to complete before adding tags
// Otherwise, metadata jobs will race and overwrite our tags
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
// Create tags and tag assets differently
const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']);
await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]);
await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]);
const duplicateId = '00000000-0000-4000-8000-000000000026';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify keeper has both tags
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
expect(keptAsset.tags).toBeDefined();
const tagIds = keptAsset.tags?.map((t) => t.id) || [];
expect(tagIds).toContain(tags[0].id);
expect(tagIds).toContain(tags[1].id);
});
it('should handle batch resolve with mixed success and failure', async () => {
// Create first group that will succeed
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId1 = '00000000-0000-4000-8000-000000000027';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1);
// Create second group with non-existent duplicate ID (will fail)
const fakeId = '00000000-0000-4000-8000-000000000099';
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [
{ duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] },
{ duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] },
],
});
expect(status).toBe(200);
expect(body.status).toBe('COMPLETED');
expect(body.results).toHaveLength(2);
// First group should succeed
expect(body.results[0].duplicateId).toBe(duplicateId1);
expect(body.results[0].status).toBe('SUCCESS');
// Second group should fail
expect(body.results[1].duplicateId).toBe(fakeId);
expect(body.results[1].status).toBe('FAILED');
expect(body.results[1].reason).toContain('not found or access denied');
// Verify first group was actually resolved despite second failure
const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(asset1Info.duplicateId).toBeNull();
const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(asset2Info.isTrashed).toBe(true);
});
it('should trash assets when trash is enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000028';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
// Ensure trash is enabled (default)
const config = await utils.getSystemConfig(admin.accessToken);
expect(config.trash.enabled).toBe(true);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify asset is trashed (not deleted)
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(trashedAsset.isTrashed).toBe(true);
});
it('should delete assets when trash is disabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000029';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
// Disable trash
await request(app)
.put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
trash: { enabled: false, days: 30 },
});
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Asset should be marked as deleted (force delete)
const { status: getStatus } = await request(app)
.get(`/assets/${asset2.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
// Asset should still be accessible (soft deleted) but marked as deleted
expect(getStatus).toBe(200);
// Re-enable trash for other tests
await utils.resetAdminConfig(admin.accessToken);
});
});
});
-2
View File
@@ -2,8 +2,6 @@ export const uuidDto = {
invalid: 'invalid-uuid',
// valid uuid v4
notFound: '00000000-0000-4000-a000-000000000000',
dummy: '00000000-0000-4000-a000-000000000001',
dummy2: '00000000-0000-4000-a000-000000000002',
};
const adminLoginDto = {
+249
View File
@@ -0,0 +1,249 @@
/**
* Reverse dependency analyzer for the Immich web app.
*
* Given a list of changed files, traces upward through the import graph
* to find which +page.svelte routes are affected, then maps those to URL paths.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
const WEB_SRC = resolve(import.meta.dirname, '../../../web/src');
const LIB_ALIAS = resolve(WEB_SRC, 'lib');
/** Collect all .svelte, .ts, .js files under web/src/ */
function collectFiles(dir: string): string[] {
const results: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === 'node_modules' || entry === '.svelte-kit') {
continue;
}
results.push(...collectFiles(full));
} else if (/\.(svelte|ts|js)$/.test(entry)) {
results.push(full);
}
}
return results;
}
/** Extract import specifiers from a file's source text. */
function extractImports(source: string): string[] {
const specifiers: string[] = [];
// Match: import ... from '...' / import '...' / export ... from '...'
const importRegex = /(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
let match;
while ((match = importRegex.exec(source)) !== null) {
specifiers.push(match[1]);
}
// Match dynamic imports: import('...')
const dynamicRegex = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
while ((match = dynamicRegex.exec(source)) !== null) {
specifiers.push(match[1]);
}
return specifiers;
}
/** Resolve an import specifier to an absolute file path (or null if external). */
function resolveImport(specifier: string, fromFile: string, allFiles: Set<string>): string | null {
// Handle $lib alias
let resolved: string;
if (specifier.startsWith('$lib/') || specifier === '$lib') {
resolved = specifier.replace('$lib', LIB_ALIAS);
} else if (specifier.startsWith('./') || specifier.startsWith('../')) {
resolved = resolve(dirname(fromFile), specifier);
} else {
// External package import — not relevant
return null;
}
// Try exact match, then common extensions
const extensions = ['', '.ts', '.js', '.svelte', '/index.ts', '/index.js', '/index.svelte'];
for (const ext of extensions) {
const candidate = resolved + ext;
if (allFiles.has(candidate)) {
return candidate;
}
}
return null;
}
/** Build the forward dependency graph: file → set of files it imports. */
function buildDependencyGraph(files: string[]): Map<string, Set<string>> {
const fileSet = new Set(files);
const graph = new Map<string, Set<string>>();
for (const file of files) {
const deps = new Set<string>();
graph.set(file, deps);
try {
const source = readFileSync(file, 'utf8');
for (const specifier of extractImports(source)) {
const resolved = resolveImport(specifier, file, fileSet);
if (resolved) {
deps.add(resolved);
}
}
} catch {
// Skip files that can't be read
}
}
return graph;
}
/** Invert the dependency graph: file → set of files that import it. */
function buildReverseDependencyGraph(forwardGraph: Map<string, Set<string>>): Map<string, Set<string>> {
const reverse = new Map<string, Set<string>>();
for (const [file, deps] of forwardGraph) {
for (const dep of deps) {
let importers = reverse.get(dep);
if (!importers) {
importers = new Set();
reverse.set(dep, importers);
}
importers.add(file);
}
}
return reverse;
}
/** BFS from changed files upward through reverse deps to find +page.svelte files. */
function findAffectedPages(changedFiles: string[], reverseGraph: Map<string, Set<string>>): Set<string> {
const visited = new Set<string>();
const pages = new Set<string>();
const queue = [...changedFiles];
while (queue.length > 0) {
const file = queue.shift()!;
if (visited.has(file)) {
continue;
}
visited.add(file);
if (file.endsWith('+page.svelte') || file.endsWith('+layout.svelte')) {
pages.add(file);
// If it's a layout, keep tracing upward because the layout itself
// isn't a page — but the pages under it are affected.
// If it's a +page.svelte, we still want to continue in case
// this page is imported by others.
}
const importers = reverseGraph.get(file);
if (importers) {
for (const importer of importers) {
if (!visited.has(importer)) {
queue.push(importer);
}
}
}
}
// For +layout.svelte hits, also find all +page.svelte under the same directory tree
const layoutDirs: string[] = [];
for (const page of pages) {
if (page.endsWith('+layout.svelte')) {
layoutDirs.push(dirname(page));
pages.delete(page);
}
}
if (layoutDirs.length > 0) {
for (const file of reverseGraph.keys()) {
if (file.endsWith('+page.svelte')) {
for (const layoutDir of layoutDirs) {
if (file.startsWith(layoutDir)) {
pages.add(file);
}
}
}
}
// Also check the forward graph keys for page files under layout dirs
for (const layoutDir of layoutDirs) {
const allFiles = collectFiles(layoutDir);
for (const f of allFiles) {
if (f.endsWith('+page.svelte')) {
pages.add(f);
}
}
}
}
return pages;
}
/** Convert a +page.svelte file path to its URL route. */
export function pageFileToRoute(pageFile: string): string {
const routesDir = resolve(WEB_SRC, 'routes');
let rel = relative(routesDir, dirname(pageFile));
// Remove SvelteKit group markers: (user), (list), etc.
rel = rel.replaceAll(/\([^)]+\)\/?/g, '');
// Remove parameter segments: [albumId=id], [[photos=photos]], [[assetId=id]]
rel = rel.replaceAll(/\[\[?[^\]]+\]\]?\/?/g, '');
// Clean up trailing slashes and normalize
rel = rel.replaceAll(/\/+/g, '/').replace(/\/$/, '');
return '/' + rel;
}
export interface AnalysisResult {
affectedPages: string[];
affectedRoutes: string[];
}
/** Main entry: analyze which routes are affected by the given changed files. */
export function analyzeAffectedRoutes(changedFiles: string[]): AnalysisResult {
// Resolve changed files to absolute paths relative to web/src
const webRoot = resolve(WEB_SRC, '..');
const resolvedChanged = changedFiles
.filter((f) => f.startsWith('web/'))
.map((f) => resolve(webRoot, '..', f))
.filter((f) => statSync(f, { throwIfNoEntry: false })?.isFile());
if (resolvedChanged.length === 0) {
return { affectedPages: [], affectedRoutes: [] };
}
const allFiles = collectFiles(WEB_SRC);
const forwardGraph = buildDependencyGraph(allFiles);
const reverseGraph = buildReverseDependencyGraph(forwardGraph);
const pages = findAffectedPages(resolvedChanged, reverseGraph);
const affectedPages = [...pages].toSorted();
const affectedRoutes = [...new Set(affectedPages.map((f) => pageFileToRoute(f)))].toSorted();
return { affectedPages, affectedRoutes };
}
// CLI usage: node --import tsx analyze-deps.ts file1 file2 ...
if (process.argv[1]?.endsWith('analyze-deps.ts') || process.argv[1]?.endsWith('analyze-deps.js')) {
const files = process.argv.slice(2);
if (files.length === 0) {
console.log('Usage: analyze-deps.ts <changed-file1> <changed-file2> ...');
console.log('Files should be relative to the repo root (e.g. web/src/lib/components/Button.svelte)');
throw new Error('No files provided');
}
const result = analyzeAffectedRoutes(files);
console.log('Affected pages:');
for (const page of result.affectedPages) {
console.log(` ${page}`);
}
console.log('\nAffected routes:');
for (const route of result.affectedRoutes) {
console.log(` ${route}`);
}
}
+335
View File
@@ -0,0 +1,335 @@
/**
* Pixel-level comparison of base vs PR screenshots.
*
* Uses pixelmatch to generate diff images and calculate change percentages.
*
* Usage:
* npx tsx e2e/src/screenshots/compare.ts <base-dir> <pr-dir> <output-dir>
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
import { PNG } from 'pngjs';
// pixelmatch is a lightweight dependency — use a simple inline implementation
// based on the approach from the pixelmatch library to avoid adding a new dependency.
// The e2e package already has pngjs.
function pixelMatch(img1Data: Uint8Array, img2Data: Uint8Array, diffData: Uint8Array): number {
let diffCount = 0;
for (let i = 0; i < img1Data.length; i += 4) {
const r1 = img1Data[i];
const g1 = img1Data[i + 1];
const b1 = img1Data[i + 2];
const r2 = img2Data[i];
const g2 = img2Data[i + 1];
const b2 = img2Data[i + 2];
const dr = Math.abs(r1 - r2);
const dg = Math.abs(g1 - g2);
const db = Math.abs(b1 - b2);
// Threshold: if any channel differs by more than 25, mark as different
const isDiff = dr > 25 || dg > 25 || db > 25;
if (isDiff) {
// Red highlight for diff pixels
diffData[i] = 255;
diffData[i + 1] = 0;
diffData[i + 2] = 0;
diffData[i + 3] = 255;
diffCount++;
} else {
// Dimmed original for unchanged pixels
const gray = Math.round(0.299 * r1 + 0.587 * g1 + 0.114 * b1);
diffData[i] = gray;
diffData[i + 1] = gray;
diffData[i + 2] = gray;
diffData[i + 3] = 128;
}
}
return diffCount;
}
export interface ComparisonResult {
name: string;
baseExists: boolean;
prExists: boolean;
diffPixels: number;
totalPixels: number;
changePercent: number;
diffImagePath: string | null;
baseImagePath: string | null;
prImagePath: string | null;
}
export function compareScreenshots(baseDir: string, prDir: string, outputDir: string): ComparisonResult[] {
mkdirSync(outputDir, { recursive: true });
// Collect all screenshot names from both directories
const baseFiles = existsSync(baseDir)
? new Set(readdirSync(baseDir).filter((f) => f.endsWith('.png')))
: new Set<string>();
const prFiles = existsSync(prDir) ? new Set(readdirSync(prDir).filter((f) => f.endsWith('.png'))) : new Set<string>();
const allNames = new Set([...baseFiles, ...prFiles]);
const results: ComparisonResult[] = [];
for (const fileName of [...allNames].toSorted()) {
const name = basename(fileName, '.png');
const basePath = join(baseDir, fileName);
const prPath = join(prDir, fileName);
const baseExists = baseFiles.has(fileName);
const prExists = prFiles.has(fileName);
if (!baseExists || !prExists) {
// New or removed page
results.push({
name,
baseExists,
prExists,
diffPixels: -1,
totalPixels: -1,
changePercent: 100,
diffImagePath: null,
baseImagePath: baseExists ? basePath : null,
prImagePath: prExists ? prPath : null,
});
continue;
}
// Load both PNGs
const basePng = PNG.sync.read(readFileSync(basePath));
const prPng = PNG.sync.read(readFileSync(prPath));
// Handle size mismatches by comparing the overlapping region
const width = Math.max(basePng.width, prPng.width);
const height = Math.max(basePng.height, prPng.height);
// Resize images to the same dimensions (pad with transparent)
const normalizedBase = normalizeImage(basePng, width, height);
const normalizedPr = normalizeImage(prPng, width, height);
const diffPng = new PNG({ width, height });
const totalPixels = width * height;
const diffPixels = pixelMatch(normalizedBase, normalizedPr, diffPng.data as unknown as Uint8Array);
const diffImagePath = join(outputDir, `${name}-diff.png`);
writeFileSync(diffImagePath, PNG.sync.write(diffPng));
results.push({
name,
baseExists,
prExists,
diffPixels,
totalPixels,
changePercent: totalPixels > 0 ? (diffPixels / totalPixels) * 100 : 0,
diffImagePath,
baseImagePath: basePath,
prImagePath: prPath,
});
}
return results;
}
function normalizeImage(png: PNG, targetWidth: number, targetHeight: number): Uint8Array {
if (png.width === targetWidth && png.height === targetHeight) {
return png.data as unknown as Uint8Array;
}
const data = new Uint8Array(targetWidth * targetHeight * 4);
for (let y = 0; y < targetHeight; y++) {
for (let x = 0; x < targetWidth; x++) {
const targetIdx = (y * targetWidth + x) * 4;
if (x < png.width && y < png.height) {
const sourceIdx = (y * png.width + x) * 4;
data[targetIdx] = png.data[sourceIdx];
data[targetIdx + 1] = png.data[sourceIdx + 1];
data[targetIdx + 2] = png.data[sourceIdx + 2];
data[targetIdx + 3] = png.data[sourceIdx + 3];
} else {
// Transparent padding
data[targetIdx + 3] = 0;
}
}
}
return data;
}
/** Generate a text-only markdown summary for the PR comment. */
export function generateMarkdownReport(results: ComparisonResult[]): string {
const changed = results.filter((r) => r.changePercent > 0.1);
const unchanged = results.filter((r) => r.changePercent <= 0.1);
if (changed.length === 0) {
return '## Visual Review\n\nNo visual changes detected in the affected pages.';
}
let md = '## Visual Review\n\n';
md += `Found **${changed.length}** page(s) with visual changes`;
if (unchanged.length > 0) {
md += ` (${unchanged.length} unchanged)`;
}
md += '.\n\n';
md += '| Page | Status | Change |\n';
md += '|------|--------|--------|\n';
for (const result of changed) {
if (result.baseExists && result.prExists) {
md += `| ${result.name} | Changed | ${result.changePercent.toFixed(1)}% |\n`;
} else if (result.prExists) {
md += `| ${result.name} | New | - |\n`;
} else {
md += `| ${result.name} | Removed | - |\n`;
}
}
md += '\n';
if (unchanged.length > 0) {
md += '<details>\n<summary>Unchanged pages</summary>\n\n';
for (const result of unchanged) {
md += `- ${result.name}\n`;
}
md += '\n</details>\n';
}
return md;
}
function imgTag(filePath: string | null, alt: string): string {
if (!filePath || !existsSync(filePath)) {
return `<div class="no-image">${alt} not available</div>`;
}
const data = readFileSync(filePath);
return `<img src="data:image/png;base64,${data.toString('base64')}" alt="${alt}" loading="lazy" />`;
}
/** Generate an HTML report with embedded base64 images for the artifact. */
export function generateHtmlReport(results: ComparisonResult[]): string {
const changed = results.filter((r) => r.changePercent > 0.1);
const unchanged = results.filter((r) => r.changePercent <= 0.1);
let html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visual Review</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: #0d1117; color: #e6edf3; padding: 32px; line-height: 1.5; }
.container { max-width: 1800px; margin: 0 auto; }
h1 { font-size: 24px; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 24px; }
.summary { color: #8b949e; margin-bottom: 32px; font-size: 16px; }
.scenario { margin-bottom: 40px; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
.scenario-header { background: #161b22; padding: 12px 16px; display: flex; align-items: center; gap: 12px; }
.scenario-header h2 { font-size: 16px; font-weight: 600; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.badge-changed { background: #da363380; color: #f85149; }
.badge-new { background: #1f6feb80; color: #58a6ff; }
.badge-removed { background: #6e767e80; color: #8b949e; }
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1px; background: #30363d; }
.grid-cell { background: #0d1117; }
.grid-label { text-align: center; padding: 8px; font-size: 13px; color: #8b949e; font-weight: 600;
background: #161b22; text-transform: uppercase; letter-spacing: 0.5px; }
.grid-cell img { width: 100%; display: block; }
.no-image { padding: 40px; text-align: center; color: #484f58; font-style: italic; }
.unchanged-section { margin-top: 32px; color: #8b949e; }
.unchanged-section summary { cursor: pointer; font-size: 14px; }
.unchanged-section ul { margin-top: 8px; padding-left: 24px; }
.unchanged-section li { font-size: 14px; margin: 4px 0; }
</style>
</head>
<body>
<div class="container">
<h1>Visual Review</h1>
`;
if (changed.length === 0) {
html += '<p class="summary">No visual changes detected in the affected pages.</p>';
} else {
html += `<p class="summary">Found <strong>${changed.length}</strong> page(s) with visual changes`;
if (unchanged.length > 0) {
html += ` (${unchanged.length} unchanged)`;
}
html += '.</p>\n';
for (const result of changed) {
html += '<div class="scenario">\n<div class="scenario-header">\n';
html += `<h2>${result.name}</h2>\n`;
if (!result.baseExists) {
html += '<span class="badge badge-new">New</span>\n';
html += '</div>\n';
html += `<div style="padding: 16px;">${imgTag(result.prImagePath, 'PR')}</div>\n`;
html += '</div>\n';
continue;
}
if (!result.prExists) {
html += '<span class="badge badge-removed">Removed</span>\n';
html += '</div>\n</div>\n';
continue;
}
html += `<span class="badge badge-changed">${result.changePercent.toFixed(1)}% changed</span>\n`;
html += '</div>\n';
html += '<div class="grid">\n';
html += `<div class="grid-cell"><div class="grid-label">Base</div>${imgTag(result.baseImagePath, 'Base')}</div>\n`;
html += `<div class="grid-cell"><div class="grid-label">PR</div>${imgTag(result.prImagePath, 'PR')}</div>\n`;
html += `<div class="grid-cell"><div class="grid-label">Diff</div>${imgTag(result.diffImagePath, 'Diff')}</div>\n`;
html += '</div>\n</div>\n';
}
}
if (unchanged.length > 0) {
html += '<div class="unchanged-section">\n<details>\n<summary>Unchanged pages</summary>\n<ul>\n';
for (const result of unchanged) {
html += `<li>${result.name}</li>\n`;
}
html += '</ul>\n</details>\n</div>\n';
}
html += '</div>\n</body>\n</html>';
return html;
}
// CLI usage
if (process.argv[1]?.endsWith('compare.ts') || process.argv[1]?.endsWith('compare.js')) {
const [baseDir, prDir, outputDir] = process.argv.slice(2);
if (!baseDir || !prDir || !outputDir) {
throw new Error('Usage: compare.ts <base-dir> <pr-dir> <output-dir>');
}
const resolvedOutputDir = resolve(outputDir);
const results = compareScreenshots(resolve(baseDir), resolve(prDir), resolvedOutputDir);
console.log('\nComparison Results:');
console.log('==================');
for (const r of results) {
const status = r.changePercent > 0.1 ? 'CHANGED' : 'unchanged';
console.log(` ${r.name}: ${status} (${r.changePercent.toFixed(1)}%)`);
}
const report = generateMarkdownReport(results);
const reportPath = join(resolvedOutputDir, 'report.md');
writeFileSync(reportPath, report);
console.log(`\nMarkdown report written to: ${reportPath}`);
const htmlReport = generateHtmlReport(results);
const htmlPath = join(resolvedOutputDir, 'visual-review.html');
writeFileSync(htmlPath, htmlReport);
console.log(`HTML report written to: ${htmlPath}`);
const jsonPath = join(resolvedOutputDir, 'results.json');
writeFileSync(jsonPath, JSON.stringify(results, null, 2));
console.log(`Results JSON written to: ${jsonPath}`);
}
+187
View File
@@ -0,0 +1,187 @@
/**
* Maps URL routes to screenshot scenario keys.
*
* Routes discovered by the dependency analyzer are matched against this map
* to determine which screenshot scenarios to run. Routes not in this map
* are skipped (they don't have a scenario defined yet).
*/
export interface ScenarioDefinition {
/** The URL path to navigate to */
url: string;
/** Human-readable name for the screenshot file */
name: string;
/** Which mock networks this scenario needs */
mocks: ('base' | 'timeline' | 'memory')[];
/** Optional: selector to wait for before screenshotting */
waitForSelector?: string;
/** Optional: time to wait after page load (ms) for animations to settle */
settleTime?: number;
}
/**
* Map from route paths (as output by analyze-deps) to scenario definitions.
* A single route might map to multiple scenarios (e.g., different states).
*/
export const PAGE_SCENARIOS: Record<string, ScenarioDefinition[]> = {
'/photos': [
{
url: '/photos',
name: 'photos-timeline',
mocks: ['base', 'timeline'],
waitForSelector: '[data-thumbnail-focus-container]',
settleTime: 500,
},
],
'/albums': [
{
url: '/albums',
name: 'albums-list',
mocks: ['base'],
settleTime: 300,
},
],
'/explore': [
{
url: '/explore',
name: 'explore',
mocks: ['base'],
settleTime: 300,
},
],
'/favorites': [
{
url: '/favorites',
name: 'favorites',
mocks: ['base', 'timeline'],
waitForSelector: '#asset-grid',
settleTime: 300,
},
],
'/archive': [
{
url: '/archive',
name: 'archive',
mocks: ['base', 'timeline'],
waitForSelector: '#asset-grid',
settleTime: 300,
},
],
'/trash': [
{
url: '/trash',
name: 'trash',
mocks: ['base', 'timeline'],
waitForSelector: '#asset-grid',
settleTime: 300,
},
],
'/people': [
{
url: '/people',
name: 'people',
mocks: ['base'],
settleTime: 300,
},
],
'/sharing': [
{
url: '/sharing',
name: 'sharing',
mocks: ['base'],
settleTime: 300,
},
],
'/search': [
{
url: '/search',
name: 'search',
mocks: ['base'],
settleTime: 300,
},
],
'/memory': [
{
url: '/memory',
name: 'memory',
mocks: ['base', 'memory'],
settleTime: 500,
},
],
'/user-settings': [
{
url: '/user-settings',
name: 'user-settings',
mocks: ['base'],
settleTime: 300,
},
],
'/map': [
{
url: '/map',
name: 'map',
mocks: ['base'],
settleTime: 500,
},
],
'/admin': [
{
url: '/admin',
name: 'admin-dashboard',
mocks: ['base'],
settleTime: 300,
},
],
'/admin/system-settings': [
{
url: '/admin/system-settings',
name: 'admin-system-settings',
mocks: ['base'],
settleTime: 300,
},
],
'/admin/users': [
{
url: '/admin/users',
name: 'admin-users',
mocks: ['base'],
settleTime: 300,
},
],
'/auth/login': [
{
url: '/auth/login',
name: 'login',
mocks: [],
settleTime: 300,
},
],
'/': [
{
url: '/',
name: 'landing',
mocks: [],
settleTime: 300,
},
],
};
/** Given a list of routes from the analyzer, return the matching scenarios. */
export function getScenariosForRoutes(routes: string[]): ScenarioDefinition[] {
const scenarios: ScenarioDefinition[] = [];
const seen = new Set<string>();
for (const route of routes) {
const defs = PAGE_SCENARIOS[route];
if (defs) {
for (const def of defs) {
if (!seen.has(def.name)) {
seen.add(def.name);
scenarios.push(def);
}
}
}
}
return scenarios;
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Playwright script to capture screenshots for visual diff scenarios.
*
* Usage:
* npx playwright test --config e2e/playwright.screenshot.config.ts
*
* Environment variables:
* SCREENSHOT_SCENARIOS - JSON array of scenario names to run (from page-map.ts)
* If not set, runs all scenarios.
* SCREENSHOT_OUTPUT_DIR - Directory to save screenshots to. Defaults to e2e/screenshots-output.
*/
import { faker } from '@faker-js/faker';
import type { MemoryResponseDto } from '@immich/sdk';
import { test } from '@playwright/test';
import { mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { generateMemoriesFromTimeline } from 'src/ui/generators/memory';
import {
createDefaultTimelineConfig,
generateTimelineData,
type TimelineAssetConfig,
type TimelineData,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupMemoryMockApiRoutes } from 'src/ui/mock-network/memory-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
import { PAGE_SCENARIOS, type ScenarioDefinition } from './page-map';
const OUTPUT_DIR = process.env.SCREENSHOT_OUTPUT_DIR || resolve(import.meta.dirname, '../../../screenshots-output');
const SCENARIO_FILTER: string[] | null = process.env.SCREENSHOT_SCENARIOS
? JSON.parse(process.env.SCREENSHOT_SCENARIOS)
: null;
// Collect scenarios to run
const allScenarios: ScenarioDefinition[] = [];
for (const defs of Object.values(PAGE_SCENARIOS)) {
for (const def of defs) {
if (!SCENARIO_FILTER || SCENARIO_FILTER.includes(def.name)) {
allScenarios.push(def);
}
}
}
// Use a fixed seed so screenshots are deterministic across runs
faker.seed(42);
let adminUserId: string;
let timelineData: TimelineData;
let timelineAssets: TimelineAssetConfig[];
let memories: MemoryResponseDto[];
test.beforeAll(async () => {
adminUserId = faker.string.uuid();
timelineData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
timelineAssets = [];
for (const timeBucket of timelineData.buckets.values()) {
timelineAssets.push(...timeBucket);
}
memories = generateMemoriesFromTimeline(
timelineAssets,
adminUserId,
[
{ year: 2024, assetCount: 3 },
{ year: 2023, assetCount: 2 },
],
42,
);
mkdirSync(OUTPUT_DIR, { recursive: true });
});
for (const scenario of allScenarios) {
test(`Screenshot: ${scenario.name}`, async ({ context, page }) => {
// Set up mocks based on scenario requirements
if (scenario.mocks.includes('base')) {
await setupBaseMockApiRoutes(context, adminUserId);
}
if (scenario.mocks.includes('timeline')) {
const testContext = new TimelineTestContext();
testContext.adminId = adminUserId;
await setupTimelineMockApiRoutes(
context,
timelineData,
{
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
},
testContext,
);
}
if (scenario.mocks.includes('memory')) {
await setupMemoryMockApiRoutes(context, memories, {
memoryDeletions: [],
assetRemovals: new Map(),
});
}
// Navigate to the page. Use networkidle so SvelteKit hydrates and API
// calls complete, but fall back to domcontentloaded if it times out
// (e.g. a persistent connection the catch-all mock didn't cover).
try {
await page.goto(scenario.url, { waitUntil: 'networkidle', timeout: 15_000 });
} catch {
console.warn(`networkidle timed out for ${scenario.name}, falling back to current state`);
// Page has already navigated, just continue with what we have
}
// Wait for specific selector if specified
if (scenario.waitForSelector) {
try {
await page.waitForSelector(scenario.waitForSelector, { timeout: 15_000 });
} catch {
console.warn(`Selector ${scenario.waitForSelector} not found for ${scenario.name}, continuing...`);
}
}
// Wait for loading spinners to disappear
await page
.waitForFunction(() => document.querySelectorAll('[data-testid="loading-spinner"]').length === 0, {
timeout: 10_000,
})
.catch(() => {});
// Wait for animations/transitions to settle
await page.waitForTimeout(scenario.settleTime ?? 500);
// Take the screenshot
await page.screenshot({
path: resolve(OUTPUT_DIR, `${scenario.name}.png`),
fullPage: false,
});
});
}
@@ -10,9 +10,7 @@ describe('/admin/database-backups', () => {
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({
onboarding: false,
});
admin = await utils.adminSetup();
await utils.resetBackups(admin.accessToken);
});
@@ -96,9 +94,7 @@ describe('/admin/database-backups', () => {
({ status, body }) => status === 200 && !body.maintenanceMode,
);
admin = await utils.adminSetup({
onboarding: false,
});
admin = await utils.adminSetup();
});
it.sequential('should not work when the server is configured', async () => {
+3 -8
View File
@@ -524,19 +524,14 @@ describe('/albums', () => {
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
});
it('should be able to update as an editor', async () => {
it('should not be able to update as an editor', async () => {
const { status, body } = await request(app)
.patch(`/albums/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ albumName: 'New album name' });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
id: user1Albums[0].id,
albumName: 'New album name',
}),
);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
});
});
+56 -5
View File
@@ -1,6 +1,7 @@
import {
AssetMediaResponseDto,
AssetMediaStatus,
AssetResponseDto,
AssetTypeEnum,
AssetVisibility,
getAssetInfo,
@@ -18,7 +19,7 @@ import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, tempDir, testAssetDir, utils } from 'src/utils';
import { app, asBearerAuth, tempDir, TEN_TIMES, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
@@ -94,8 +95,8 @@ describe('/asset', () => {
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken, {
isFavorite: true,
fileCreatedAt: yesterday.toUTC().toISO(),
fileModifiedAt: yesterday.toUTC().toISO(),
fileCreatedAt: yesterday.toISO(),
fileModifiedAt: yesterday.toISO(),
assetData: { filename: 'example.mp4' },
}),
utils.createAsset(user1.accessToken),
@@ -379,12 +380,62 @@ describe('/asset', () => {
});
});
describe('GET /assets/random', () => {
beforeAll(async () => {
await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
await utils.waitForQueueFinish(admin.accessToken, 'thumbnailGeneration');
});
it.each(TEN_TIMES)('should return 1 random assets', async () => {
const { status, body } = await request(app)
.get('/assets/random')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(1);
expect(assets[0].ownerId).toBe(user1.userId);
});
it.each(TEN_TIMES)('should return 2 random assets', async () => {
const { status, body } = await request(app)
.get('/assets/random?count=2')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(2);
for (const asset of assets) {
expect(asset.ownerId).toBe(user1.userId);
}
});
it.skip('should return 1 asset if there are 10 assets in the database but user 2 only has 1', async () => {
const { status, body } = await request(app)
.get('/assets/random')
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user2Assets[0].id })]);
});
});
describe('PUT /assets/:id', () => {
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/assets/${user2Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({});
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
+7 -7
View File
@@ -110,7 +110,7 @@ describe('/libraries', () => {
});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
});
it('should not create an external library with duplicate exclusion patterns', async () => {
@@ -125,7 +125,7 @@ describe('/libraries', () => {
});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
});
});
@@ -157,7 +157,7 @@ describe('/libraries', () => {
.send({ name: '' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[name] Too small: expected string to have >=1 characters']));
expect(body).toEqual(errorDto.badRequest(['name should not be empty']));
});
it('should change the import paths', async () => {
@@ -181,7 +181,7 @@ describe('/libraries', () => {
.send({ importPaths: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array items must not be empty']));
expect(body).toEqual(errorDto.badRequest(['each value in importPaths should not be empty']));
});
it('should reject duplicate import paths', async () => {
@@ -191,7 +191,7 @@ describe('/libraries', () => {
.send({ importPaths: ['/path', '/path'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
});
it('should change the exclusion pattern', async () => {
@@ -215,7 +215,7 @@ describe('/libraries', () => {
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
});
it('should reject an empty exclusion pattern', async () => {
@@ -225,7 +225,7 @@ describe('/libraries', () => {
.send({ exclusionPatterns: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array items must not be empty']));
expect(body).toEqual(errorDto.badRequest(['each value in exclusionPatterns should not be empty']));
});
});
+4 -4
View File
@@ -109,7 +109,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lon=123')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN']));
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
});
it('should throw an error if a lat is not a number', async () => {
@@ -117,7 +117,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=abc&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN']));
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
});
it('should throw an error if a lat is out of range', async () => {
@@ -125,7 +125,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=91&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[lat] Too big: expected number to be <=90']));
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
});
it('should throw an error if a lon is not provided', async () => {
@@ -133,7 +133,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=75')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[lon] Invalid input: expected number, received NaN']));
expect(body).toEqual(errorDto.badRequest(['lon must be a number between -180 and 180']));
});
const reverseGeocodeTestCases = [
+3 -22
View File
@@ -101,7 +101,7 @@ describe(`/oauth`, () => {
it(`should throw an error if a redirect uri is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/authorize').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[redirectUri] Invalid input: expected string, received undefined']));
expect(body).toEqual(errorDto.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
});
it('should return a redirect uri', async () => {
@@ -123,13 +123,13 @@ describe(`/oauth`, () => {
it(`should throw an error if a url is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/callback').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[url] Invalid input: expected string, received undefined']));
expect(body).toEqual(errorDto.badRequest(['url must be a string', 'url should not be empty']));
});
it(`should throw an error if the url is empty`, async () => {
const { status, body } = await request(app).post('/oauth/callback').send({ url: '' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[url] Too small: expected string to have >=1 characters']));
expect(body).toEqual(errorDto.badRequest(['url should not be empty']));
});
it(`should throw an error if the state is not provided`, async () => {
@@ -380,23 +380,4 @@ describe(`/oauth`, () => {
});
});
});
describe('idTokenClaims', () => {
it('should use claims from the ID token if IDP includes them', async () => {
await setupOAuth(admin.accessToken, {
enabled: true,
clientId: OAuthClient.DEFAULT,
clientSecret: OAuthClient.DEFAULT,
});
const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS);
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(201);
expect(body).toMatchObject({
accessToken: expect.any(String),
name: 'ID Token User',
userEmail: 'oauth-id-token-claims@immich.app',
userId: expect.any(String),
});
});
});
});
@@ -243,21 +243,9 @@ describe('/shared-links', () => {
});
it('should get data for correct password protected link', async () => {
const response = await request(app)
.post('/shared-links/login')
.send({ password: 'foo' })
.query({ key: linkWithPassword.key });
expect(response.status).toBe(201);
const cookies = response.get('Set-Cookie') ?? [];
expect(cookies).toHaveLength(1);
expect(cookies[0]).toContain('immich_shared_link_token');
const { status, body } = await request(app)
.get('/shared-links/me')
.query({ key: linkWithPassword.key })
.set('Cookie', cookies);
.query({ key: linkWithPassword.key, password: 'foo' });
expect(status).toBe(200);
expect(body).toEqual(
@@ -450,16 +438,6 @@ describe('/shared-links', () => {
expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
});
it('should reject guests removing assets from an individual shared link', async () => {
const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAssets.id}/assets`)
.query({ key: linkWithAssets.key })
.send({ assetIds: [asset1.id] });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should remove assets from a shared link (individual)', async () => {
const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAssets.id}/assets`)
+2 -2
View File
@@ -309,7 +309,7 @@ describe('/tags', () => {
.get(`/tags/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should get tag details', async () => {
@@ -427,7 +427,7 @@ describe('/tags', () => {
.delete(`/tags/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should delete a tag', async () => {
@@ -287,8 +287,7 @@ describe('/admin/users', () => {
it('should delete user', async () => {
const { status, body } = await request(app)
.delete(`/admin/users/${userToDelete.userId}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({});
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
+2 -6
View File
@@ -178,9 +178,7 @@ describe('/users', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['[download.archiveSize] Invalid input: expected int, received number']),
);
expect(body).toEqual(errorDto.badRequest(['download.archiveSize must be an integer number']));
});
it('should update download archive size', async () => {
@@ -206,9 +204,7 @@ describe('/users', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['[download.includeEmbeddedVideos] Invalid input: expected boolean, received number']),
);
expect(body).toEqual(errorDto.badRequest(['download.includeEmbeddedVideos must be a boolean value']));
});
it('should update download include embedded videos', async () => {
+2 -40
View File
@@ -1,7 +1,6 @@
import { LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { testAssetDir, utils } from 'src/utils';
import { test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe('Album', () => {
let admin: LoginResponseDto;
@@ -23,41 +22,4 @@ test.describe('Album', () => {
await page.reload();
await page.getByRole('button', { name: 'Select photos' }).waitFor();
});
test('should keep map view open after viewing an asset from the map and going back', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
const imagePath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
const mapAsset = await utils.createAsset(admin.accessToken, {
assetData: {
bytes: readFileSync(imagePath),
filename: 'thompson-springs.jpg',
},
});
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const mapAlbum = await utils.createAlbum(admin.accessToken, {
albumName: 'Map Test Album',
assetIds: [mapAsset.id],
});
await page.goto(`/albums/${mapAlbum.id}`);
const mapButton = page.getByRole('button', { name: 'Map' });
await expect(mapButton).toBeVisible();
await mapButton.click();
const mapModal = page.getByRole('dialog');
await expect(mapModal).toBeVisible();
const mapMarker = mapModal.getByRole('img', { name: /Map marker/i }).first();
await expect(mapMarker).toBeVisible();
await mapMarker.click();
await page.waitForSelector('#immich-asset-viewer');
await page.getByRole('button', { name: 'Go back' }).click();
await expect(page.locator('#immich-asset-viewer')).not.toBeVisible();
await expect(mapModal).toBeVisible();
});
});
@@ -0,0 +1,66 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, Page, test } from '@playwright/test';
import { utils } from 'src/utils';
async function ensureDetailPanelVisible(page: Page) {
await page.waitForSelector('#immich-asset-viewer');
const isVisible = await page.locator('#detail-panel').isVisible();
if (!isVisible) {
await page.keyboard.press('i');
await page.waitForSelector('#detail-panel');
}
}
test.describe('Asset Viewer stack', () => {
let admin: LoginResponseDto;
let assetOne: AssetMediaResponseDto;
let assetTwo: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
await utils.updateMyPreferences(admin.accessToken, { tags: { enabled: true } });
assetOne = await utils.createAsset(admin.accessToken);
assetTwo = await utils.createAsset(admin.accessToken);
await utils.createStack(admin.accessToken, [assetOne.id, assetTwo.id]);
const tags = await utils.upsertTags(admin.accessToken, ['test/1', 'test/2']);
const tagOne = tags.find((tag) => tag.value === 'test/1')!;
const tagTwo = tags.find((tag) => tag.value === 'test/2')!;
await utils.tagAssets(admin.accessToken, tagOne.id, [assetOne.id]);
await utils.tagAssets(admin.accessToken, tagTwo.id, [assetTwo.id]);
});
test('stack slideshow is visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await expect(stackAssets.first()).toBeVisible();
await expect(stackAssets.nth(1)).toBeVisible();
});
test('tags of primary asset are visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
await ensureDetailPanelVisible(page);
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/1');
});
test('tags of second asset are visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
await ensureDetailPanelVisible(page);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await stackAssets.nth(1).click();
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/2');
});
});
-51
View File
@@ -1,51 +0,0 @@
import { AssetMediaResponseDto, LoginResponseDto, updateAssets } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import crypto from 'node:crypto';
import { asBearerAuth, utils } from 'src/utils';
test.describe('Duplicates Utility', () => {
let admin: LoginResponseDto;
let firstAsset: AssetMediaResponseDto;
let secondAsset: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test.beforeEach(async ({ context }) => {
[firstAsset, secondAsset] = await Promise.all([
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-a' }),
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-b' }),
]);
await updateAssets(
{
assetBulkUpdateDto: {
ids: [firstAsset.id, secondAsset.id],
duplicateId: crypto.randomUUID(),
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
await utils.setAuthCookies(context, admin.accessToken);
});
test('navigates with arrow keys between duplicate preview assets', async ({ page }) => {
await page.goto('/utilities/duplicates');
await page.getByRole('button', { name: 'View' }).first().click();
await page.waitForSelector('#immich-asset-viewer');
const getViewedAssetId = () => new URL(page.url()).pathname.split('/').at(-1) ?? '';
const initialAssetId = getViewedAssetId();
expect([firstAsset.id, secondAsset.id]).toContain(initialAssetId);
await page.keyboard.press('ArrowRight');
await expect.poll(getViewedAssetId).not.toBe(initialAssetId);
await page.keyboard.press('ArrowLeft');
await expect.poll(getViewedAssetId).toBe(initialAssetId);
});
});
+21 -45
View File
@@ -1,13 +1,14 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import type { Socket } from 'socket.io-client';
import { Page, expect, test } from '@playwright/test';
import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
let rawAsset: AssetMediaResponseDto;
let websocket: Socket;
test.beforeAll(async () => {
utils.initSdk();
@@ -15,11 +16,6 @@ test.describe('Photo Viewer', () => {
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
websocket = await utils.connectWebsocket(admin.accessToken);
});
test.afterAll(() => {
utils.disconnectWebsocket(websocket);
});
test.beforeEach(async ({ context, page }) => {
@@ -30,51 +26,31 @@ test.describe('Photo Viewer', () => {
test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const originalResponse = page.waitForResponse((response) => response.url().includes('/original'));
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1);
await originalResponse;
const original = page.getByTestId('original').filter({ visible: true });
await expect(original).toHaveAttribute('src', /original/);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
});
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize'));
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1);
await fullsizeResponse;
const original = page.getByTestId('original').filter({ visible: true });
await expect(original).toHaveAttribute('src', /fullsize/);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
});
test('right-click targets the img element', async ({ page }) => {
test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const box = await preview.boundingBox();
const tagAtCenter = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName, {
x: box!.x + box!.width / 2,
y: box!.y + box!.height / 2,
});
expect(tagAtCenter).toBe('IMG');
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const initialSrc = await imageLocator(page).getAttribute('src');
await utils.replaceAsset(admin.accessToken, asset.id);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
});
});
-24
View File
@@ -12,18 +12,15 @@ import { asBearerAuth, utils } from 'src/utils';
test.describe('Shared Links', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
let asset2: AssetMediaResponseDto;
let album: AlbumResponseDto;
let sharedLink: SharedLinkResponseDto;
let sharedLinkPassword: SharedLinkResponseDto;
let individualSharedLink: SharedLinkResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
asset2 = await utils.createAsset(admin.accessToken);
album = await createAlbum(
{
createAlbumDto: {
@@ -42,10 +39,6 @@ test.describe('Shared Links', () => {
albumId: album.id,
password: 'test-password',
});
individualSharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id, asset2.id],
});
});
test('download from a shared link', async ({ page }) => {
@@ -116,21 +109,4 @@ test.describe('Shared Links', () => {
await page.waitForURL('/photos');
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
});
test('owner can remove assets from an individual shared link', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/share/${individualSharedLink.key}`);
await page.locator(`[data-asset="${asset.id}"]`).waitFor();
await expect(page.locator(`[data-asset]`)).toHaveCount(2);
await page.locator(`[data-asset="${asset.id}"]`).hover();
await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click();
await page.getByRole('button', { name: 'Remove from shared link' }).click();
await page.getByRole('button', { name: 'Remove', exact: true }).click();
await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0);
await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1);
});
});
+22 -2
View File
@@ -1,5 +1,5 @@
import { BrowserContext } from '@playwright/test';
import { playwrightHost } from 'src/../playwright.config';
import { playwrightHost } from 'playwright.config';
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
await context.addCookies([
@@ -10,6 +10,27 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
path: '/',
},
]);
// Block socket.io connections — these are persistent WebSocket connections
// that prevent networkidle from resolving since there's no real server.
await context.route('**/api/socket.io**', async (route) => {
return route.abort('connectionrefused');
});
// Catch-all for any /api/ endpoint not explicitly mocked below.
// Registered FIRST so specific routes (registered after) take priority
// (Playwright checks routes in reverse registration order).
// Without this, unmocked API calls hit the static preview server which
// either hangs or returns HTML, preventing networkidle and causing timeouts.
await context.route('**/api/**', async (route) => {
const method = route.request().method();
return route.fulfill({
status: 200,
contentType: 'application/json',
json: method === 'GET' ? [] : {},
});
});
await context.route('**/api/users/me', async (route) => {
return route.fulfill({
status: 200,
@@ -173,7 +194,6 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
'.mpeg',
'.mpg',
'.mts',
'.ts',
'.vob',
'.webm',
'.wmv',
@@ -1,167 +0,0 @@
import { faker } from '@faker-js/faker';
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { randomPreview, randomThumbnail } from 'src/ui/generators/timeline';
export type MockStack = {
id: string;
primaryAssetId: string;
assets: AssetResponseDto[];
brokenAssetIds: Set<string>;
assetMap: Map<string, AssetResponseDto>;
};
export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
const assetId = faker.string.uuid();
const now = new Date().toISOString();
return {
id: assetId,
deviceAssetId: `device-${assetId}`,
ownerId,
owner: {
id: ownerId,
email: 'admin@immich.cloud',
name: 'Admin',
profileImagePath: '',
profileChangedAt: now,
avatarColor: 'blue' as never,
},
libraryId: `library-${ownerId}`,
deviceId: `device-${ownerId}`,
type: AssetTypeEnum.Image,
originalPath: `/original/${assetId}.jpg`,
originalFileName: `${assetId}.jpg`,
originalMimeType: 'image/jpeg',
thumbhash: null,
fileCreatedAt: now,
fileModifiedAt: now,
localDateTime: now,
updatedAt: now,
createdAt: now,
isFavorite: false,
isArchived: false,
isTrashed: false,
visibility: AssetVisibility.Timeline,
duration: '0:00:00.00000',
exifInfo: {
make: null,
model: null,
exifImageWidth: 3000,
exifImageHeight: 4000,
fileSizeInByte: null,
orientation: null,
dateTimeOriginal: now,
modifyDate: null,
timeZone: null,
lensModel: null,
fNumber: null,
focalLength: null,
iso: null,
exposureTime: null,
latitude: null,
longitude: null,
city: null,
country: null,
state: null,
description: null,
},
livePhotoVideoId: null,
tags: [],
people: [],
unassignedFaces: [],
stack: undefined,
isOffline: false,
hasMetadata: true,
duplicateId: null,
resized: true,
checksum: faker.string.alphanumeric({ length: 28 }),
width: 3000,
height: 4000,
isEdited: false,
};
};
export const createMockStack = (
primaryAssetDto: AssetResponseDto,
additionalAssets: AssetResponseDto[],
brokenAssetIds?: Set<string>,
): MockStack => {
const stackId = faker.string.uuid();
const allAssets = [primaryAssetDto, ...additionalAssets];
const resolvedBrokenIds = brokenAssetIds ?? new Set(additionalAssets.map((a) => a.id));
const assetMap = new Map(allAssets.map((a) => [a.id, a]));
primaryAssetDto.stack = {
id: stackId,
assetCount: allAssets.length,
primaryAssetId: primaryAssetDto.id,
};
return {
id: stackId,
primaryAssetId: primaryAssetDto.id,
assets: allAssets,
brokenAssetIds: resolvedBrokenIds,
assetMap,
};
};
export const setupBrokenAssetMockApiRoutes = async (context: BrowserContext, mockStack: MockStack) => {
await context.route('**/api/stacks/*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const stackResponse: StackResponseDto = {
id: mockStack.id,
primaryAssetId: mockStack.primaryAssetId,
assets: mockStack.assets,
};
return route.fulfill({
status: 200,
contentType: 'application/json',
json: stackResponse,
});
});
await context.route('**/api/assets/*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const url = new URL(request.url());
const segments = url.pathname.split('/');
const assetId = segments.at(-1);
if (assetId && mockStack.assetMap.has(assetId)) {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: mockStack.assetMap.get(assetId),
});
}
return route.fallback();
});
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
if (!route.request().serviceWorker()) {
return route.continue();
}
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern);
if (!match?.groups || !mockStack.assetMap.has(match.groups.assetId)) {
return route.fallback();
}
if (mockStack.brokenAssetIds.has(match.groups.assetId)) {
return route.fulfill({ status: 404 });
}
const asset = mockStack.assetMap.get(match.groups.assetId)!;
const ratio = (asset.exifInfo?.exifImageWidth ?? 3000) / (asset.exifInfo?.exifImageHeight ?? 4000);
const body =
match.groups.size === 'preview'
? await randomPreview(match.groups.assetId, ratio)
: await randomThumbnail(match.groups.assetId, ratio);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body,
});
});
};
@@ -1,127 +0,0 @@
import { BrowserContext } from '@playwright/test';
import { randomThumbnail } from 'src/ui/generators/timeline';
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
const MINIMAL_MP4_BASE64 =
'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAr9tZGF0AAACoAYF//+c' +
'3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDEyNSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMg' +
'LSBDb3B5bGVmdCAyMDAzLTIwMTIgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwg' +
'LSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMg' +
'bWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5n' +
'ZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEg' +
'ZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJl' +
'YWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJh' +
'eV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2Fk' +
'YXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtl' +
'eWludD0yNTAga2V5aW50X21pbj0yNCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9v' +
'a2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBt' +
'YXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAAA9liIQAV/0TAAYdeBTX' +
'zg8AAALvbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAACoAAQAAAQAAAAAAAAAAAAAAAAEAAAAA' +
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA' +
'Ahl0cmFrAAAAXHRraGQAAAAPAAAAAAAAAAAAAAABAAAAAAAAACoAAAAAAAAAAAAAAAAAAAAAAAEAAAAA' +
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAgAAAAIAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAA' +
'AAEAAAAqAAAAAAABAAAAAAGRbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAwAAAAAgBVxAAAAAAA' +
'LWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABPG1pbmYAAAAUdm1oZAAA' +
'AAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAPxzdGJsAAAAmHN0' +
'c2QAAAAAAAAAAQAAAIhhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAgACABIAAAASAAAAAAAAAAB' +
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAMmF2Y0MBZAAK/+EAGWdkAAqs' +
'2V+WXAWyAAADAAIAAAMAYB4kSywBAAZo6+PLIsAAAAAYc3R0cwAAAAAAAAABAAAAAQAAAgAAAAAcc3Rz' +
'YwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACtwAAAAEAAAAUc3RjbwAAAAAAAAABAAAA' +
'MAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWls' +
'c3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNTQuNjMuMTA0';
export const MINIMAL_MP4_BUFFER = Buffer.from(MINIMAL_MP4_BASE64, 'base64');
export type MockPerson = {
id: string;
name: string;
birthDate: string | null;
isHidden: boolean;
thumbnailPath: string;
updatedAt: string;
};
export const createMockPeople = (count: number): MockPerson[] => {
const names = [
'Alice Johnson',
'Bob Smith',
'Charlie Brown',
'Diana Prince',
'Eve Adams',
'Frank Castle',
'Grace Lee',
'Hank Pym',
'Iris West',
'Jack Ryan',
];
return Array.from({ length: count }, (_, index) => ({
id: `person-${index}`,
name: names[index % names.length],
birthDate: null,
isHidden: false,
thumbnailPath: `/upload/thumbs/person-${index}.jpeg`,
updatedAt: '2025-01-01T00:00:00.000Z',
}));
};
export type FaceCreateCapture = {
requests: Array<{
assetId: string;
personId: string;
x: number;
y: number;
width: number;
height: number;
imageWidth: number;
imageHeight: number;
}>;
};
export const setupFaceEditorMockApiRoutes = async (
context: BrowserContext,
mockPeople: MockPerson[],
faceCreateCapture: FaceCreateCapture,
) => {
await context.route('**/api/people?*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
hasNextPage: false,
hidden: 0,
people: mockPeople,
total: mockPeople.length,
},
});
});
await context.route('**/api/faces', async (route, request) => {
if (request.method() !== 'POST') {
return route.fallback();
}
const body = request.postDataJSON();
faceCreateCapture.requests.push(body);
return route.fulfill({
status: 201,
contentType: 'text/plain',
body: 'OK',
});
});
await context.route('**/api/people/*/thumbnail', async (route) => {
if (!route.request().serviceWorker()) {
return route.continue();
}
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail('person-thumb', 1),
});
});
};
-55
View File
@@ -1,55 +0,0 @@
import { faker } from '@faker-js/faker';
import type { AssetOcrResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
export type MockOcrBox = {
text: string;
x1: number;
y1: number;
x2: number;
y2: number;
x3: number;
y3: number;
x4: number;
y4: number;
};
export const createMockOcrData = (assetId: string, boxes: MockOcrBox[]): AssetOcrResponseDto[] => {
return boxes.map((box) => ({
id: faker.string.uuid(),
assetId,
x1: box.x1,
y1: box.y1,
x2: box.x2,
y2: box.y2,
x3: box.x3,
y3: box.y3,
x4: box.x4,
y4: box.y4,
boxScore: 0.95,
textScore: 0.9,
text: box.text,
}));
};
export const setupOcrMockApiRoutes = async (
context: BrowserContext,
ocrDataByAssetId: Map<string, AssetOcrResponseDto[]>,
) => {
await context.route('**/assets/*/ocr', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const url = new URL(request.url());
const segments = url.pathname.split('/');
const assetIdIndex = segments.indexOf('assets') + 1;
const assetId = segments[assetIdIndex];
const ocrData = ocrDataByAssetId.get(assetId) ?? [];
return route.fulfill({
status: 200,
contentType: 'application/json',
json: ocrData,
});
});
};
@@ -12,7 +12,6 @@ import {
TimelineData,
} from 'src/ui/generators/timeline';
import { sleep } from 'src/ui/specs/timeline/utils';
import { MINIMAL_MP4_BUFFER } from './face-editor-network';
export class TimelineTestContext {
slowBucket = false;
@@ -136,14 +135,6 @@ export const setupTimelineMockApiRoutes = async (
return route.continue();
});
await context.route('**/api/assets/*/video/playback*', async (route) => {
return route.fulfill({
status: 200,
headers: { 'content-type': 'video/mp4' },
body: MINIMAL_MP4_BUFFER,
});
});
await context.route('**/api/albums/**', async (route, request) => {
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
if (albumsMatch) {
@@ -1,86 +0,0 @@
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('broken-asset responsiveness', () => {
const fixture = setupAssetViewerFixture(889);
let mockStack: MockStack;
test.beforeAll(async () => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const brokenAssets = [
createMockStackAsset(fixture.adminUserId),
createMockStackAsset(fixture.adminUserId),
createMockStackAsset(fixture.adminUserId),
];
mockStack = createMockStack(primaryAssetDto, brokenAssets);
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
});
test('broken asset in stack strip hides icon at small size', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
await expect(brokenAssets.first()).toBeVisible();
await expect(brokenAssets).toHaveCount(mockStack.brokenAssetIds.size);
for (const brokenAsset of await brokenAssets.all()) {
await expect(brokenAsset.locator('svg')).not.toBeVisible();
}
});
test('broken asset in stack strip uses text-xs class', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
await expect(brokenAssets.first()).toBeVisible();
for (const brokenAsset of await brokenAssets.all()) {
const messageSpan = brokenAsset.locator('span');
await expect(messageSpan).toHaveClass(/text-xs/);
}
});
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
await context.route(
(url) =>
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) ||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
async (route) => {
return route.fulfill({ status: 404 });
},
);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await page.waitForSelector('#immich-asset-viewer');
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first();
await expect(viewerBrokenAsset).toBeVisible();
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
const messageSpan = viewerBrokenAsset.locator('span');
await expect(messageSpan).toHaveClass(/text-base/);
});
});
@@ -1,285 +0,0 @@
import { expect, Page, test } from '@playwright/test';
import { SeededRandom, selectRandom, TimelineAssetConfig } from 'src/ui/generators/timeline';
import {
createMockPeople,
FaceCreateCapture,
MockPerson,
setupFaceEditorMockApiRoutes,
} from 'src/ui/mock-network/face-editor-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
const waitForSelectorTransition = async (page: Page) => {
await page.waitForFunction(
() => {
const selector = document.querySelector('#face-selector') as HTMLElement | null;
if (!selector) {
return false;
}
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
},
undefined,
{ timeout: 1000, polling: 50 },
);
};
const openFaceEditor = async (page: Page, asset: TimelineAssetConfig) => {
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.keyboard.press('i');
await page.locator('#detail-panel').waitFor({ state: 'visible' });
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await waitForSelectorTransition(page);
};
test.describe.configure({ mode: 'parallel' });
test.describe('face-editor', () => {
const fixture = setupAssetViewerFixture(777);
const rng = new SeededRandom(777);
let mockPeople: MockPerson[];
let faceCreateCapture: FaceCreateCapture;
test.beforeAll(async () => {
mockPeople = createMockPeople(8);
});
test.beforeEach(async ({ context }) => {
faceCreateCapture = { requests: [] };
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
});
type ScreenRect = { top: number; left: number; width: number; height: number };
const getFaceBoxRect = async (page: Page): Promise<ScreenRect> => {
const dataEl = page.locator('#face-editor-data');
await expect(dataEl).toHaveAttribute('data-face-left', /^-?\d+/);
await expect(dataEl).toHaveAttribute('data-face-top', /^-?\d+/);
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
await expect(dataEl).toHaveAttribute('data-face-height', /^[1-9]/);
const canvasBox = await page.locator('#face-editor').boundingBox();
if (!canvasBox) {
throw new Error('Canvas element not found');
}
const left = Number(await dataEl.getAttribute('data-face-left'));
const top = Number(await dataEl.getAttribute('data-face-top'));
const width = Number(await dataEl.getAttribute('data-face-width'));
const height = Number(await dataEl.getAttribute('data-face-height'));
return {
top: canvasBox.y + top,
left: canvasBox.x + left,
width,
height,
};
};
const getSelectorRect = async (page: Page): Promise<ScreenRect> => {
const box = await page.locator('#face-selector').boundingBox();
if (!box) {
throw new Error('Face selector element not found');
}
return { top: box.y, left: box.x, width: box.width, height: box.height };
};
const computeOverlapArea = (a: ScreenRect, b: ScreenRect): number => {
const overlapX = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left));
const overlapY = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top));
return overlapX * overlapY;
};
const dragFaceBox = async (page: Page, deltaX: number, deltaY: number) => {
const faceBox = await getFaceBoxRect(page);
const centerX = faceBox.left + faceBox.width / 2;
const centerY = faceBox.top + faceBox.height / 2;
await page.mouse.move(centerX, centerY);
await page.mouse.down();
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
await page.mouse.up();
await page.waitForTimeout(300);
};
test('Face editor opens with person list', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await expect(page.locator('#face-selector')).toBeVisible();
await expect(page.locator('#face-editor')).toBeVisible();
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
}
});
test('Search filters people by name', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const searchInput = page.locator('#face-selector input');
await searchInput.fill('Alice');
await expect(page.locator('#face-selector').getByText('Alice Johnson')).toBeVisible();
await expect(page.locator('#face-selector').getByText('Bob Smith')).toBeHidden();
await searchInput.clear();
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
}
});
test('Search with no results shows empty message', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const searchInput = page.locator('#face-selector input');
await searchInput.fill('Nonexistent Person XYZ');
for (const person of mockPeople) {
await expect(page.locator('#face-selector').getByText(person.name)).toBeHidden();
}
});
test('Selecting a person shows confirmation dialog', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const personToTag = mockPeople[0];
await page.locator('#face-selector').getByText(personToTag.name).click();
await expect(page.getByRole('dialog')).toBeVisible();
});
test('Confirming tag calls createFace API and closes editor', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const personToTag = mockPeople[0];
await page.locator('#face-selector').getByText(personToTag.name).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: /confirm/i }).click();
await expect(page.locator('#face-selector')).toBeHidden();
await expect(page.locator('#face-editor')).toBeHidden();
expect(faceCreateCapture.requests).toHaveLength(1);
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
});
test('Cancel button closes face editor', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await expect(page.locator('#face-selector')).toBeVisible();
await expect(page.locator('#face-editor')).toBeVisible();
await page.getByRole('button', { name: /cancel/i }).click();
await expect(page.locator('#face-selector')).toBeHidden();
await expect(page.locator('#face-editor')).toBeHidden();
});
test('Selector does not overlap face box on initial open', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box down', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 0, 150);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box right', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 200, 0);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box to top-left corner', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, -300, -300);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector repositions without overlap after dragging face box to bottom-right', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, 300, 300);
const faceBox = await getFaceBoxRect(page);
const selectorBox = await getSelectorRect(page);
const overlap = computeOverlapArea(faceBox, selectorBox);
expect(overlap).toBe(0);
});
test('Selector stays within viewport bounds', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const viewportSize = page.viewportSize()!;
const selectorBox = await getSelectorRect(page);
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
});
test('Selector stays within viewport after dragging to edge', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
await dragFaceBox(page, -400, -400);
const viewportSize = page.viewportSize()!;
const selectorBox = await getSelectorRect(page);
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
});
test('Face box is draggable on the canvas', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const beforeDrag = await getFaceBoxRect(page);
await dragFaceBox(page, 100, 50);
const afterDrag = await getFaceBoxRect(page);
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
});
});
@@ -1,300 +0,0 @@
import type { AssetOcrResponseDto, AssetResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { createMockOcrData, setupOcrMockApiRoutes } from 'src/ui/mock-network/ocr-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
const PRIMARY_OCR_BOXES = [
{ text: 'Hello World', x1: 0.1, y1: 0.1, x2: 0.4, y2: 0.1, x3: 0.4, y3: 0.15, x4: 0.1, y4: 0.15 },
{ text: 'Immich Photo', x1: 0.2, y1: 0.3, x2: 0.6, y2: 0.3, x3: 0.6, y3: 0.36, x4: 0.2, y4: 0.36 },
];
const SECONDARY_OCR_BOXES = [
{ text: 'Second Asset Text', x1: 0.15, y1: 0.2, x2: 0.55, y2: 0.2, x3: 0.55, y3: 0.26, x4: 0.15, y4: 0.26 },
];
test.describe('OCR bounding boxes', () => {
const fixture = setupAssetViewerFixture(920);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR bounding boxes appear when clicking OCR button', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
await expect(ocrBoxes).toHaveCount(2);
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
await expect(ocrBoxes.nth(1)).toContainText('Immich Photo');
});
test('OCR bounding boxes toggle off on second click', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await ocrButton.click();
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]').first()).toBeVisible();
await ocrButton.click();
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]')).toHaveCount(0);
});
});
test.describe('OCR with stacked assets', () => {
const fixture = setupAssetViewerFixture(921);
let mockStack: MockStack;
let primaryAssetDto: AssetResponseDto;
let secondAssetDto: AssetResponseDto;
test.beforeAll(async () => {
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
secondAssetDto = createMockStackAsset(fixture.adminUserId);
secondAssetDto.originalFileName = 'second-ocr-asset.jpg';
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
[secondAssetDto.id, createMockOcrData(secondAssetDto.id, SECONDARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('different OCR boxes shown for different stacked assets', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
await expect(ocrBoxes).toHaveCount(2);
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
const stackThumbnails = page.locator('#stack-slideshow [data-asset]');
await expect(stackThumbnails).toHaveCount(2);
await stackThumbnails.nth(1).click();
// refreshOcr() clears showOverlay when switching assets, so re-enable it
await expect(ocrBoxes).toHaveCount(0);
await expect(ocrButton).toBeVisible();
await ocrButton.click();
await expect(ocrBoxes).toHaveCount(1);
await expect(ocrBoxes.first()).toContainText('Second Asset Text');
});
});
test.describe('OCR boxes and zoom', () => {
const fixture = setupAssetViewerFixture(922);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR boxes scale with zoom', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const initialBox = await ocrBox.boundingBox();
expect(initialBox).toBeTruthy();
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -3);
await expect(async () => {
const zoomedBox = await ocrBox.boundingBox();
expect(zoomedBox).toBeTruthy();
expect(zoomedBox!.width).toBeGreaterThan(initialBox!.width);
expect(zoomedBox!.height).toBeGreaterThan(initialBox!.height);
}).toPass({ timeout: 2000 });
});
});
test.describe('OCR text interaction', () => {
const fixture = setupAssetViewerFixture(923);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR text box has data-overlay-interactive attribute', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
await expect(ocrBox).toHaveAttribute('data-overlay-interactive');
});
test('OCR text box receives focus on click', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
await ocrBox.click();
await expect(ocrBox).toBeFocused();
});
test('dragging on OCR text box does not trigger image pan', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
const initialTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
const box = await ocrBox.boundingBox();
expect(box).toBeTruthy();
const centerX = box!.x + box!.width / 2;
const centerY = box!.y + box!.height / 2;
await page.mouse.move(centerX, centerY);
await page.mouse.down();
await page.mouse.move(centerX + 50, centerY + 30, { steps: 5 });
await page.mouse.up();
const afterTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(afterTransform).toBe(initialTransform);
});
test('split touch gesture across zoom container does not trigger zoom', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
const initialTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
const viewerContent = page.locator('[data-viewer-content]');
const viewerBox = await viewerContent.boundingBox();
expect(viewerBox).toBeTruthy();
// Dispatch a synthetic split gesture: one touch inside the viewer, one outside
await page.evaluate(
({ viewerCenterX, viewerCenterY, outsideY }) => {
const viewer = document.querySelector('[data-viewer-content]');
if (!viewer) {
return;
}
const createTouch = (id: number, x: number, y: number) => {
return new Touch({
identifier: id,
target: viewer,
clientX: x,
clientY: y,
});
};
const insideTouch = createTouch(0, viewerCenterX, viewerCenterY);
const outsideTouch = createTouch(1, viewerCenterX, outsideY);
const touchStartEvent = new TouchEvent('touchstart', {
touches: [insideTouch, outsideTouch],
targetTouches: [insideTouch],
changedTouches: [insideTouch, outsideTouch],
bubbles: true,
cancelable: true,
});
const touchMoveEvent = new TouchEvent('touchmove', {
touches: [createTouch(0, viewerCenterX, viewerCenterY - 30), createTouch(1, viewerCenterX, outsideY + 30)],
targetTouches: [createTouch(0, viewerCenterX, viewerCenterY - 30)],
changedTouches: [
createTouch(0, viewerCenterX, viewerCenterY - 30),
createTouch(1, viewerCenterX, outsideY + 30),
],
bubbles: true,
cancelable: true,
});
const touchEndEvent = new TouchEvent('touchend', {
touches: [],
targetTouches: [],
changedTouches: [insideTouch, outsideTouch],
bubbles: true,
cancelable: true,
});
viewer.dispatchEvent(touchStartEvent);
viewer.dispatchEvent(touchMoveEvent);
viewer.dispatchEvent(touchEndEvent);
},
{
viewerCenterX: viewerBox!.x + viewerBox!.width / 2,
viewerCenterY: viewerBox!.y + viewerBox!.height / 2,
outsideY: 10, // near the top of the page, outside the viewer
},
);
const afterTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(afterTransform).toBe(initialTransform);
});
});
@@ -1,84 +0,0 @@
import { faker } from '@faker-js/faker';
import type { AssetResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { assetViewerUtils } from '../timeline/utils';
import { enableTagsPreference, ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer stack', () => {
const fixture = setupAssetViewerFixture(888);
let mockStack: MockStack;
let primaryAssetDto: AssetResponseDto;
let secondAssetDto: AssetResponseDto;
test.beforeAll(async () => {
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
primaryAssetDto.tags = [
{
id: faker.string.uuid(),
name: '1',
value: 'test/1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
secondAssetDto = createMockStackAsset(fixture.adminUserId);
secondAssetDto.tags = [
{
id: faker.string.uuid(),
name: '2',
value: 'test/2',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
});
test('stack slideshow is visible', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const stackAssets = stackSlideshow.locator('[data-asset]');
await expect(stackAssets).toHaveCount(mockStack.assets.length);
});
test('tags of primary asset are visible', async ({ context, page }) => {
await enableTagsPreference(context);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await ensureDetailPanelVisible(page);
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/1');
});
test('tags of second asset are visible', async ({ context, page }) => {
await enableTagsPreference(context);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await ensureDetailPanelVisible(page);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await stackAssets.nth(1).click();
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/2');
});
});
-116
View File
@@ -1,116 +0,0 @@
import { faker } from '@faker-js/faker';
import type { AssetResponseDto } from '@immich/sdk';
import { BrowserContext, Page, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
SeededRandom,
selectRandom,
TimelineAssetConfig,
TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
import { utils } from 'src/utils';
export type AssetViewerTestFixture = {
adminUserId: string;
timelineRestData: TimelineData;
assets: TimelineAssetConfig[];
testContext: TimelineTestContext;
changes: Changes;
primaryAsset: TimelineAssetConfig;
primaryAssetDto: AssetResponseDto;
};
export function setupAssetViewerFixture(seed: number): AssetViewerTestFixture {
const rng = new SeededRandom(seed);
const testContext = new TimelineTestContext();
const fixture: AssetViewerTestFixture = {
adminUserId: undefined!,
timelineRestData: undefined!,
assets: [],
testContext,
changes: {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
},
primaryAsset: undefined!,
primaryAssetDto: undefined!,
};
test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
utils.initSdk();
fixture.adminUserId = faker.string.uuid();
testContext.adminId = fixture.adminUserId;
fixture.timelineRestData = generateTimelineData({
...createDefaultTimelineConfig(),
ownerId: fixture.adminUserId,
});
for (const timeBucket of fixture.timelineRestData.buckets.values()) {
fixture.assets.push(...timeBucket);
}
fixture.primaryAsset = selectRandom(
fixture.assets.filter((a) => a.isImage),
rng,
);
fixture.primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, fixture.adminUserId);
await setupTimelineMockApiRoutes(context, fixture.timelineRestData, fixture.changes, fixture.testContext);
});
test.afterEach(() => {
fixture.testContext.slowBucket = false;
fixture.changes.albumAdditions = [];
fixture.changes.assetDeletions = [];
fixture.changes.assetArchivals = [];
fixture.changes.assetFavorites = [];
});
return fixture;
}
export async function ensureDetailPanelVisible(page: Page) {
await page.waitForSelector('#immich-asset-viewer');
const isVisible = await page.locator('#detail-panel').isVisible();
if (!isVisible) {
await page.keyboard.press('i');
await page.waitForSelector('#detail-panel');
}
}
export async function enableTagsPreference(context: BrowserContext) {
await context.route('**/users/me/preferences', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
albums: { defaultAssetOrder: 'desc' },
folders: { enabled: false, sidebarWeb: false },
memories: { enabled: true, duration: 5 },
people: { enabled: true, sidebarWeb: false },
sharedLinks: { enabled: true, sidebarWeb: false },
ratings: { enabled: false },
tags: { enabled: true, sidebarWeb: false },
emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true },
download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false },
purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' },
cast: { gCastEnabled: false },
},
});
});
}
@@ -6,7 +6,6 @@ import {
generateTimelineData,
TimelineAssetConfig,
TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
@@ -31,10 +30,6 @@ test.describe('search gallery-viewer', () => {
};
test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
@@ -49,10 +44,7 @@ test.describe('search gallery-viewer', () => {
await context.route('**/api/search/metadata', async (route, request) => {
if (request.method() === 'POST') {
const searchAssets = assets
.slice(0, 5)
.filter((asset) => !changes.assetDeletions.includes(asset.id))
.map((asset) => toAssetResponseDto(asset));
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
return route.fulfill({
status: 200,
contentType: 'application/json',
+3 -4
View File
@@ -62,7 +62,7 @@ export const thumbnailUtils = {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"]`);
},
selectButton(page: Page, assetId: string) {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button[role="checkbox"]`);
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
},
selectedAsset(page: Page) {
return page.locator('[data-thumbnail-focus-container][data-selected]');
@@ -215,9 +215,8 @@ export const pageUtils = {
await page.getByText('Confirm').click();
},
async selectDay(page: Page, day: string) {
const section = page.getByTitle(day).locator('xpath=ancestor::section[@data-group]');
await section.hover();
await section.locator('.w-8').click();
await page.getByTitle(day).hover();
await page.locator('[data-group] .w-8').click();
},
async pauseTestDebug() {
console.log('NOTE: pausing test indefinately for debug');

Some files were not shown because too many files have changed in this diff Show More