mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
merge main
This commit is contained in:
@@ -4,3 +4,4 @@ node_modules/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/dist
|
||||
.env
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
24.11.0
|
||||
24.14.0
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
name: immich-e2e
|
||||
|
||||
services:
|
||||
immich-app-base:
|
||||
extends:
|
||||
file: ../docker/docker-compose.dev.yml
|
||||
service: immich-app-base
|
||||
|
||||
immich-init:
|
||||
extends:
|
||||
file: ../docker/docker-compose.dev.yml
|
||||
service: immich-init
|
||||
container_name: immich-e2e-init
|
||||
|
||||
immich-server:
|
||||
extends:
|
||||
file: ../docker/docker-compose.dev.yml
|
||||
service: immich-server
|
||||
container_name: immich-e2e-server
|
||||
ports: !reset []
|
||||
env_file: !reset []
|
||||
environment:
|
||||
DB_HOSTNAME: database
|
||||
DB_USERNAME: postgres
|
||||
DB_PASSWORD: postgres
|
||||
DB_DATABASE_NAME: immich
|
||||
IMMICH_MACHINE_LEARNING_ENABLED: 'false'
|
||||
IMMICH_TELEMETRY_INCLUDE: all
|
||||
IMMICH_ENV: testing
|
||||
IMMICH_PORT: '2285'
|
||||
IMMICH_IGNORE_MOUNT_CHECK_ERRORS: 'true'
|
||||
volumes:
|
||||
- ./test-assets:/test-assets
|
||||
depends_on:
|
||||
immich-init:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
immich-web:
|
||||
extends:
|
||||
file: ../docker/docker-compose.dev.yml
|
||||
service: immich-web
|
||||
container_name: immich-e2e-web
|
||||
ports: !override
|
||||
- 2285:3000
|
||||
environment:
|
||||
IMMICH_SERVER_URL: http://immich-server:2285/
|
||||
depends_on:
|
||||
immich-init:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
extends:
|
||||
file: ../docker/docker-compose.dev.yml
|
||||
service: redis
|
||||
container_name: immich-e2e-redis
|
||||
|
||||
database:
|
||||
extends:
|
||||
file: ../docker/docker-compose.dev.yml
|
||||
service: database
|
||||
container_name: immich-e2e-postgres
|
||||
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||
env_file: !reset []
|
||||
ports: !override
|
||||
- 5435:5432
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: immich
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
|
||||
interval: 1s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
model_cache:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
pnpm_cache:
|
||||
pnpm_store_server:
|
||||
pnpm_store_web:
|
||||
server_node_modules:
|
||||
web_node_modules:
|
||||
github_node_modules:
|
||||
cli_node_modules:
|
||||
docs_node_modules:
|
||||
e2e_node_modules:
|
||||
sdk_node_modules:
|
||||
app_node_modules:
|
||||
sveltekit:
|
||||
coverage:
|
||||
+26
-13
@@ -1,31 +1,39 @@
|
||||
name: immich-e2e
|
||||
|
||||
services:
|
||||
e2e-auth-server:
|
||||
container_name: immich-e2e-auth-server
|
||||
build:
|
||||
context: ../e2e-auth-server
|
||||
ports:
|
||||
- 2286:2286
|
||||
|
||||
immich-server:
|
||||
container_name: immich-e2e-server
|
||||
image: immich-server:latest
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: server/Dockerfile
|
||||
cache_from:
|
||||
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-amd64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
|
||||
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-arm64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
|
||||
args:
|
||||
- BUILD_ID=1234567890
|
||||
- BUILD_IMAGE=e2e
|
||||
- BUILD_SOURCE_REF=e2e
|
||||
- BUILD_SOURCE_COMMIT=e2eeeeeeeeeeeeeeeeee
|
||||
environment:
|
||||
- DB_HOSTNAME=database
|
||||
- DB_USERNAME=postgres
|
||||
- DB_PASSWORD=postgres
|
||||
- DB_DATABASE_NAME=immich
|
||||
- IMMICH_MACHINE_LEARNING_ENABLED=false
|
||||
- IMMICH_TELEMETRY_INCLUDE=all
|
||||
- IMMICH_ENV=testing
|
||||
- IMMICH_PORT=2285
|
||||
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
|
||||
DB_HOSTNAME: database
|
||||
DB_USERNAME: postgres
|
||||
DB_PASSWORD: postgres
|
||||
DB_DATABASE_NAME: immich
|
||||
IMMICH_MACHINE_LEARNING_ENABLED: 'false'
|
||||
IMMICH_TELEMETRY_INCLUDE: all
|
||||
IMMICH_ENV: testing
|
||||
IMMICH_PORT: '2285'
|
||||
IMMICH_IGNORE_MOUNT_CHECK_ERRORS: 'true'
|
||||
volumes:
|
||||
- ./test-assets:/test-assets
|
||||
extra_hosts:
|
||||
- 'auth-server:host-gateway'
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
@@ -35,10 +43,14 @@ services:
|
||||
- 2285:2285
|
||||
|
||||
redis:
|
||||
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
|
||||
container_name: immich-e2e-redis
|
||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
database:
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
|
||||
container_name: immich-e2e-postgres
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
|
||||
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
@@ -46,6 +58,7 @@ services:
|
||||
POSTGRES_DB: immich
|
||||
ports:
|
||||
- 5435:5432
|
||||
shm_size: 128mb
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
|
||||
interval: 1s
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
[tasks.install]
|
||||
run = "pnpm install --filter immich-e2e --frozen-lockfile"
|
||||
|
||||
[tasks.test]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "vitest --run"
|
||||
|
||||
[tasks."test-web"]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "playwright test"
|
||||
|
||||
[tasks.format]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "prettier --check ."
|
||||
|
||||
[tasks."format-fix"]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "prettier --write ."
|
||||
|
||||
[tasks.lint]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "eslint \"src/**/*.ts\" --max-warnings 0"
|
||||
|
||||
[tasks."lint-fix"]
|
||||
run = { task = "lint --fix" }
|
||||
|
||||
[tasks.check]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "tsc --noEmit"
|
||||
+28
-24
@@ -1,60 +1,64 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.2.3",
|
||||
"version": "2.6.3",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"test:web": "npx playwright test",
|
||||
"start:web": "npx playwright test --ui",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"test:maintenance": "vitest --run --config vitest.maintenance.config.ts",
|
||||
"test:web": "pnpm exec playwright test --project=web",
|
||||
"test:web:maintenance": "pnpm exec playwright test --project=maintenance",
|
||||
"test:web:ui": "pnpm exec playwright test --project=ui",
|
||||
"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 .",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"lint:fix": "pnpm run lint --fix",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@immich/cli": "file:../cli",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@immich/cli": "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": "^22.18.13",
|
||||
"@types/oidc-provider": "^9.0.0",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.14.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^60.0.0",
|
||||
"exiftool-vendored": "^31.1.0",
|
||||
"globals": "^16.0.0",
|
||||
"jose": "^5.6.3",
|
||||
"eslint-plugin-unicorn": "^63.0.0",
|
||||
"exiftool-vendored": "^35.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"luxon": "^3.4.4",
|
||||
"oidc-provider": "^9.0.0",
|
||||
"pg": "^8.11.3",
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"sharp": "^0.34.4",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"structured-headers": "^2.0.2",
|
||||
"supertest": "^7.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"utimes": "^5.2.1",
|
||||
"vitest": "^3.0.0"
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.11.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"structured-headers": "^2.0.2"
|
||||
"node": "24.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
+45
-39
@@ -1,54 +1,56 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { defineConfig, devices, PlaywrightTestConfig } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import { cpus } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './src/web/specs',
|
||||
dotenv.config({ quiet: true, path: resolve(import.meta.dirname, '.env') });
|
||||
|
||||
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
|
||||
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
|
||||
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
|
||||
export const playwriteSlowMo = Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
|
||||
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
|
||||
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: './src/specs/server',
|
||||
testMatch: /.*\.e2e-spec\.ts/,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 4 : 0,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:2285',
|
||||
baseURL: playwriteBaseUrl,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
launchOptions: {
|
||||
slowMo: playwriteSlowMo,
|
||||
},
|
||||
},
|
||||
|
||||
testMatch: /.*\.e2e-spec\.ts/,
|
||||
workers: process.env.CI ? 4 : Math.round(cpus().length * 0.75),
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
name: 'web',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
testDir: './src/specs/web',
|
||||
workers: 1,
|
||||
},
|
||||
{
|
||||
name: 'ui',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
testDir: './src/ui/specs',
|
||||
fullyParallel: true,
|
||||
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||
},
|
||||
{
|
||||
name: 'maintenance',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
testDir: './src/specs/maintenance/web',
|
||||
workers: 1,
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// },
|
||||
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
@@ -59,4 +61,8 @@ export default defineConfig({
|
||||
stderr: 'pipe',
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
if (playwrightDisableWebserver) {
|
||||
delete config.webServer;
|
||||
}
|
||||
export default defineConfig(config);
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
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,6 +2,8 @@ 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 = {
|
||||
|
||||
@@ -26,6 +26,5 @@ export const makeRandomImage = () => {
|
||||
if (!value) {
|
||||
throw new Error('Ran out of random asset data');
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,12 @@ export const errorDto = {
|
||||
message: 'Authentication required',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
unauthorizedWithMessage: (message: string) => ({
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message,
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
forbidden: {
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
@@ -37,10 +43,10 @@ export const errorDto = {
|
||||
message: 'Invalid share key',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidSharePassword: {
|
||||
passwordRequired: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid password',
|
||||
message: 'Password required',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
badRequest: (message: any = null) => ({
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { exportJWK, generateKeyPair } from 'jose';
|
||||
import Provider from 'oidc-provider';
|
||||
|
||||
export enum OAuthClient {
|
||||
DEFAULT = 'client-default',
|
||||
RS256_TOKENS = 'client-RS256-tokens',
|
||||
RS256_PROFILE = 'client-RS256-profile',
|
||||
}
|
||||
|
||||
export enum OAuthUser {
|
||||
NO_EMAIL = 'no-email',
|
||||
NO_NAME = 'no-name',
|
||||
WITH_QUOTA = 'with-quota',
|
||||
WITH_USERNAME = 'with-username',
|
||||
WITH_ROLE = 'with-role',
|
||||
}
|
||||
|
||||
const claims = [
|
||||
{ sub: OAuthUser.NO_EMAIL },
|
||||
{
|
||||
sub: OAuthUser.NO_NAME,
|
||||
email: 'oauth-no-name@immich.app',
|
||||
email_verified: true,
|
||||
},
|
||||
{
|
||||
sub: OAuthUser.WITH_USERNAME,
|
||||
email: 'oauth-with-username@immich.app',
|
||||
email_verified: true,
|
||||
immich_username: 'user-username',
|
||||
},
|
||||
{
|
||||
sub: OAuthUser.WITH_QUOTA,
|
||||
email: 'oauth-with-quota@immich.app',
|
||||
email_verified: true,
|
||||
preferred_username: 'user-quota',
|
||||
immich_quota: 25,
|
||||
},
|
||||
{
|
||||
sub: OAuthUser.WITH_ROLE,
|
||||
email: 'oauth-with-role@immich.app',
|
||||
email_verified: true,
|
||||
immich_role: 'admin',
|
||||
},
|
||||
];
|
||||
|
||||
const withDefaultClaims = (sub: string) => ({
|
||||
sub,
|
||||
email: `${sub}@immich.app`,
|
||||
name: 'OAuth User',
|
||||
given_name: `OAuth`,
|
||||
family_name: 'User',
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
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 port = 2286;
|
||||
const host = '0.0.0.0';
|
||||
const oidc = new Provider(`http://${host}:${port}`, {
|
||||
renderError: async (ctx, out, error) => {
|
||||
console.error(out);
|
||||
console.error(error);
|
||||
ctx.body = 'Internal Server Error';
|
||||
},
|
||||
findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }),
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
claims: {
|
||||
openid: ['sub'],
|
||||
email: ['email', 'email_verified'],
|
||||
profile: [
|
||||
'name',
|
||||
'given_name',
|
||||
'family_name',
|
||||
'preferred_username',
|
||||
'immich_quota',
|
||||
'immich_username',
|
||||
'immich_role',
|
||||
],
|
||||
},
|
||||
features: {
|
||||
jwtUserinfo: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
cookies: {
|
||||
names: {
|
||||
session: 'oidc.session',
|
||||
interaction: 'oidc.interaction',
|
||||
resume: 'oidc.resume',
|
||||
state: 'oidc.state',
|
||||
},
|
||||
},
|
||||
pkce: {
|
||||
required: () => false,
|
||||
},
|
||||
jwks: { keys: [await exportJWK(privateKey)] },
|
||||
clients: [
|
||||
{
|
||||
client_id: OAuthClient.DEFAULT,
|
||||
client_secret: OAuthClient.DEFAULT,
|
||||
redirect_uris: redirectUris,
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
},
|
||||
{
|
||||
client_id: OAuthClient.RS256_TOKENS,
|
||||
client_secret: OAuthClient.RS256_TOKENS,
|
||||
redirect_uris: redirectUris,
|
||||
grant_types: ['authorization_code'],
|
||||
id_token_signed_response_alg: 'RS256',
|
||||
jwks: { keys: [await exportJWK(publicKey)] },
|
||||
},
|
||||
{
|
||||
client_id: OAuthClient.RS256_PROFILE,
|
||||
client_secret: OAuthClient.RS256_PROFILE,
|
||||
redirect_uris: redirectUris,
|
||||
grant_types: ['authorization_code'],
|
||||
userinfo_signed_response_alg: 'RS256',
|
||||
jwks: { keys: [await exportJWK(publicKey)] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const onStart = () => console.log(`[auth-server] http://${host}:${port}/.well-known/openid-configuration`);
|
||||
const app = oidc.listen(port, host, onStart);
|
||||
return () => app.close();
|
||||
};
|
||||
|
||||
export default setup;
|
||||
@@ -0,0 +1,354 @@
|
||||
import { LoginResponseDto, ManualJobName } from '@immich/sdk';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/admin/database-backups', () => {
|
||||
let cookie: string | undefined;
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup({
|
||||
onboarding: false,
|
||||
});
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
describe('GET /', async () => {
|
||||
it('should succeed and be empty', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/database-backups')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
backups: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should contain a created backup', async () => {
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.BackupDatabase,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'backupDatabase');
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/database-backups')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
return body;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
backups: [
|
||||
expect.objectContaining({
|
||||
filename: expect.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/),
|
||||
filesize: expect.any(Number),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /', async () => {
|
||||
it('should delete backup', async () => {
|
||||
const filename = await utils.createBackup(admin.accessToken);
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete(`/admin/database-backups`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ backups: [filename] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
const { status: listStatus, body } = await request(app)
|
||||
.get('/admin/database-backups')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(listStatus).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
backups: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// => action: restore database flow
|
||||
|
||||
describe.sequential('POST /start-restore', () => {
|
||||
afterAll(async () => {
|
||||
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({ action: 'end' });
|
||||
await utils.poll(
|
||||
() => request(app).get('/server/config'),
|
||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||
);
|
||||
|
||||
admin = await utils.adminSetup({
|
||||
onboarding: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.sequential('should not work when the server is configured', async () => {
|
||||
const { status, body } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('The server already has an admin'));
|
||||
});
|
||||
|
||||
it.sequential('should enter maintenance mode in "database restore mode"', async () => {
|
||||
await utils.resetDatabase(); // reset database before running this test
|
||||
|
||||
const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body).toEqual({
|
||||
active: true,
|
||||
action: 'select_database_restore',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// => action: restore database
|
||||
|
||||
describe.sequential('POST /backups/restore', () => {
|
||||
beforeAll(async () => {
|
||||
await utils.disconnectDatabase();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await utils.connectDatabase();
|
||||
});
|
||||
|
||||
it.sequential('should restore a backup', { timeout: 60_000 }, async () => {
|
||||
let filename = await utils.createBackup(admin.accessToken);
|
||||
|
||||
// work-around until test is running on released version
|
||||
await utils.move(
|
||||
`/data/backups/${filename}`,
|
||||
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||
);
|
||||
filename = 'immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz';
|
||||
|
||||
const { status } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
action: 'restore_database',
|
||||
restoreBackupFilename: filename,
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 60_000,
|
||||
},
|
||||
)
|
||||
.toBeFalsy();
|
||||
});
|
||||
|
||||
it.sequential('fail to restore a corrupted backup', { timeout: 60_000 }, async () => {
|
||||
await utils.prepareTestBackup('corrupted');
|
||||
|
||||
const { status, headers } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
action: 'restore_database',
|
||||
restoreBackupFilename: 'development-corrupted.sql.gz',
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
return body;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
error: 'Something went wrong, see logs!',
|
||||
}),
|
||||
);
|
||||
|
||||
const { status: status2, body: body2 } = await request(app)
|
||||
.get('/admin/maintenance/status')
|
||||
.set('cookie', cookie!)
|
||||
.send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body2).toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
error: expect.stringContaining('IM CORRUPTED'),
|
||||
}),
|
||||
);
|
||||
|
||||
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||
action: 'end',
|
||||
});
|
||||
|
||||
await utils.poll(
|
||||
() => request(app).get('/server/config'),
|
||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('rollback to restore point if backup is missing admin', { timeout: 60_000 }, async () => {
|
||||
await utils.prepareTestBackup('empty');
|
||||
|
||||
const { status, headers } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
action: 'restore_database',
|
||||
restoreBackupFilename: 'development-empty.sql.gz',
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
return body;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 30_000,
|
||||
},
|
||||
)
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
error: 'Something went wrong, see logs!',
|
||||
}),
|
||||
);
|
||||
|
||||
const { status: status2, body: body2 } = await request(app)
|
||||
.get('/admin/maintenance/status')
|
||||
.set('cookie', cookie!)
|
||||
.send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body2).toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
error: expect.stringContaining('Server health check failed, no admin exists.'),
|
||||
}),
|
||||
);
|
||||
|
||||
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||
action: 'end',
|
||||
});
|
||||
|
||||
await utils.poll(
|
||||
() => request(app).get('/server/config'),
|
||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/admin/maintenance', () => {
|
||||
let cookie: string | undefined;
|
||||
let admin: LoginResponseDto;
|
||||
let nonAdmin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
// => outside of maintenance mode
|
||||
|
||||
describe('GET ~/server/config', async () => {
|
||||
it('should indicate we are out of maintenance mode', async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
expect(body.maintenanceMode).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /status', async () => {
|
||||
it('to always indicate we are not in maintenance mode', async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
active: false,
|
||||
action: 'end',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /login', async () => {
|
||||
it('should not work out of maintenance mode', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not in maintenance mode'));
|
||||
});
|
||||
});
|
||||
|
||||
// => enter maintenance mode
|
||||
|
||||
describe.sequential('POST /', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance').send({
|
||||
active: false,
|
||||
action: 'end',
|
||||
});
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should only work for admins', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
|
||||
.send({ action: 'end' });
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.forbidden);
|
||||
});
|
||||
|
||||
it('should be a no-op if try to exit maintenance mode', async () => {
|
||||
const { status } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ action: 'end' });
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
|
||||
it('should enter maintenance mode', async () => {
|
||||
const { status, headers } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
action: 'start',
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
expect(cookie).toEqual(
|
||||
expect.stringMatching(/^immich_maintenance_token=[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$/),
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// => in maintenance mode
|
||||
|
||||
describe.sequential('in maintenance mode', () => {
|
||||
describe('GET ~/server/config', async () => {
|
||||
it('should indicate we are in maintenance mode', async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
expect(body.maintenanceMode).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /status', async () => {
|
||||
it('to indicate we are in maintenance mode', async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
active: true,
|
||||
action: 'start',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /login', async () => {
|
||||
it('should fail without cookie or token in body', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorizedWithMessage('Missing JWT Token'));
|
||||
});
|
||||
|
||||
it('should succeed with cookie', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance/login').set('cookie', cookie!).send({});
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
username: 'Immich Admin',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should succeed with token', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/admin/maintenance/login')
|
||||
.send({
|
||||
token: cookie!.split('=')[1].trim(),
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
username: 'Immich Admin',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', async () => {
|
||||
it('should be a no-op if try to enter maintenance mode', async () => {
|
||||
const { status } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('cookie', cookie!)
|
||||
.send({ action: 'start' });
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// => exit maintenance mode
|
||||
|
||||
describe.sequential('POST /', () => {
|
||||
it('should exit maintenance mode', async () => {
|
||||
const { status } = await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||
action: 'end',
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Database Backups', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test('restore a backup from settings', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
const filename = await utils.createBackup(admin.accessToken);
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
// work-around until test is running on released version
|
||||
await utils.move(
|
||||
`/data/backups/${filename}`,
|
||||
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||
);
|
||||
|
||||
await page.goto('/admin/maintenance?isOpen=backups');
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await page.waitForURL('/admin/maintenance**', { timeout: 60_000 });
|
||||
});
|
||||
|
||||
test('handle backup restore failure', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
await utils.prepareTestBackup('corrupted');
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/admin/maintenance?isOpen=backups');
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await expect(page.getByText('IM CORRUPTED')).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||
await page.waitForURL('/admin/maintenance**');
|
||||
});
|
||||
|
||||
test('rollback to restore point if backup is missing admin', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
await utils.prepareTestBackup('empty');
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/admin/maintenance?isOpen=backups');
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await expect(page.getByText('Server health check failed, no admin exists.')).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||
await page.waitForURL('/admin/maintenance**');
|
||||
});
|
||||
|
||||
test('restore a backup from onboarding', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
const filename = await utils.createBackup(admin.accessToken);
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
// work-around until test is running on released version
|
||||
await utils.move(
|
||||
`/data/backups/${filename}`,
|
||||
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||
);
|
||||
|
||||
await utils.resetDatabase();
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Restore from backup' }).click();
|
||||
|
||||
try {
|
||||
await page.waitForURL('/maintenance**');
|
||||
} catch {
|
||||
// when chained with the rest of the tests
|
||||
// this navigation may fail..? not sure why...
|
||||
await page.goto('/maintenance');
|
||||
await page.waitForURL('/maintenance**');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await page.waitForURL('/photos', { timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Maintenance', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test('enter and exit maintenance mode', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/admin/maintenance');
|
||||
await page.getByRole('button', { name: 'Switch to maintenance mode' }).click();
|
||||
|
||||
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||
await page.waitForURL('**/admin/maintenance*', { timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
|
||||
const setCookie = await utils.enterMaintenance(admin.accessToken);
|
||||
const cookie = setCookie
|
||||
?.map((cookie) => cookie.split(';')[0].split('='))
|
||||
?.find(([name]) => name === 'immich_maintenance_token');
|
||||
|
||||
expect(cookie).toBeTruthy();
|
||||
|
||||
await expect(async () => {
|
||||
await page.goto('/');
|
||||
await page.waitForURL('**/maintenance?**', {
|
||||
timeout: 1000,
|
||||
});
|
||||
}).toPass({ timeout: 10_000 });
|
||||
|
||||
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toHaveCount(0);
|
||||
|
||||
await page.goto(`/maintenance?${new URLSearchParams({ token: cookie![1] })}`);
|
||||
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||
await page.waitForURL('**/auth/login');
|
||||
});
|
||||
});
|
||||
@@ -524,14 +524,19 @@ describe('/albums', () => {
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||
});
|
||||
|
||||
it('should not be able to update as an editor', async () => {
|
||||
it('should 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(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
id: user1Albums[0].id,
|
||||
albumName: 'New album name',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,7 +253,8 @@ describe('/asset', () => {
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.id).toEqual(facesAsset.id);
|
||||
expect(body.people).toMatchObject(expectedFaces);
|
||||
const sortedPeople = body.people.toSorted((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
expect(sortedPeople).toMatchObject(expectedFaces);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -473,6 +474,7 @@ describe('/asset', () => {
|
||||
id: user1Assets[0].id,
|
||||
exifInfo: expect.objectContaining({
|
||||
dateTimeOriginal: '2023-11-20T01:11:00+00:00',
|
||||
timeZone: 'UTC-7',
|
||||
}),
|
||||
});
|
||||
expect(status).toEqual(200);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { JobCommand, JobName, LoginResponseDto, updateConfig } from '@immich/sdk';
|
||||
import { LoginResponseDto, QueueCommand, QueueName, updateConfig } from '@immich/sdk';
|
||||
import { cpSync, rmSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename } from 'node:path';
|
||||
@@ -17,28 +17,28 @@ describe('/jobs', () => {
|
||||
|
||||
describe('PUT /jobs', () => {
|
||||
afterEach(async () => {
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.FaceDetection, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.FaceDetection, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.SmartSearch, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.SmartSearch, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.DuplicateDetection, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.DuplicateDetection, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
@@ -59,8 +59,8 @@ describe('/jobs', () => {
|
||||
it('should queue metadata extraction for missing assets', async () => {
|
||||
const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Pause,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||
command: QueueCommand.Pause,
|
||||
force: false,
|
||||
});
|
||||
|
||||
@@ -77,20 +77,20 @@ describe('/jobs', () => {
|
||||
expect(asset.exifInfo?.make).toBeNull();
|
||||
}
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Empty,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||
command: QueueCommand.Empty,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Start,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||
command: QueueCommand.Start,
|
||||
force: false,
|
||||
});
|
||||
|
||||
@@ -124,8 +124,8 @@ describe('/jobs', () => {
|
||||
|
||||
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, path);
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Start,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||
command: QueueCommand.Start,
|
||||
force: false,
|
||||
});
|
||||
|
||||
@@ -144,8 +144,8 @@ describe('/jobs', () => {
|
||||
it('should queue thumbnail extraction for assets missing thumbs', async () => {
|
||||
const path = `${testAssetDir}/albums/nature/tanners_ridge.jpg`;
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
||||
command: JobCommand.Pause,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||
command: QueueCommand.Pause,
|
||||
force: false,
|
||||
});
|
||||
|
||||
@@ -153,32 +153,32 @@ describe('/jobs', () => {
|
||||
assetData: { bytes: await readFile(path), filename: basename(path) },
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||
|
||||
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
|
||||
expect(assetBefore.thumbhash).toBeNull();
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
||||
command: JobCommand.Empty,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||
command: QueueCommand.Empty,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
||||
command: JobCommand.Start,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||
command: QueueCommand.Start,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||
|
||||
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
|
||||
expect(assetAfter.thumbhash).not.toBeNull();
|
||||
@@ -193,26 +193,26 @@ describe('/jobs', () => {
|
||||
assetData: { bytes: await readFile(path), filename: basename(path) },
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||
|
||||
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
|
||||
|
||||
cpSync(`${testAssetDir}/albums/nature/notocactus_minimus.jpg`, path);
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
// This runs the missing thumbnail job
|
||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
||||
command: JobCommand.Start,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||
command: QueueCommand.Start,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||
|
||||
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
|
||||
|
||||
+1
-1
@@ -1006,7 +1006,7 @@ describe('/libraries', () => {
|
||||
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
|
||||
it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OAuthClient, OAuthUser } from '@immich/e2e-auth-server';
|
||||
import {
|
||||
LoginResponseDto,
|
||||
SystemConfigOAuthDto,
|
||||
@@ -8,13 +9,12 @@ import {
|
||||
} from '@immich/sdk';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { OAuthClient, OAuthUser } from 'src/setup/auth-server';
|
||||
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const authServer = {
|
||||
internal: 'http://auth-server:2286',
|
||||
internal: 'http://e2e-auth-server:2286',
|
||||
external: 'http://127.0.0.1:2286',
|
||||
};
|
||||
|
||||
@@ -380,4 +380,23 @@ 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),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -136,6 +136,7 @@ describe('/server', () => {
|
||||
externalDomain: '',
|
||||
publicUsers: true,
|
||||
isOnboarded: false,
|
||||
maintenanceMode: false,
|
||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
});
|
||||
+23
-27
@@ -20,7 +20,6 @@ describe('/shared-links', () => {
|
||||
let user1: LoginResponseDto;
|
||||
let user2: LoginResponseDto;
|
||||
let album: AlbumResponseDto;
|
||||
let metadataAlbum: AlbumResponseDto;
|
||||
let deletedAlbum: AlbumResponseDto;
|
||||
let linkWithDeletedAlbum: SharedLinkResponseDto;
|
||||
let linkWithPassword: SharedLinkResponseDto;
|
||||
@@ -41,18 +40,9 @@ describe('/shared-links', () => {
|
||||
|
||||
[asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]);
|
||||
|
||||
[album, deletedAlbum, metadataAlbum] = await Promise.all([
|
||||
[album, deletedAlbum] = await Promise.all([
|
||||
createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
|
||||
createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
|
||||
createAlbum(
|
||||
{
|
||||
createAlbumDto: {
|
||||
albumName: 'metadata album',
|
||||
assetIds: [asset1.id],
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(user1.accessToken) },
|
||||
),
|
||||
]);
|
||||
|
||||
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
|
||||
@@ -75,14 +65,14 @@ describe('/shared-links', () => {
|
||||
password: 'foo',
|
||||
}),
|
||||
utils.createSharedLink(user1.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: metadataAlbum.id,
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [asset1.id],
|
||||
showMetadata: true,
|
||||
slug: 'metadata-album',
|
||||
slug: 'metadata-slug',
|
||||
}),
|
||||
utils.createSharedLink(user1.accessToken, {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: metadataAlbum.id,
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [asset1.id],
|
||||
showMetadata: false,
|
||||
}),
|
||||
]);
|
||||
@@ -95,9 +85,7 @@ describe('/shared-links', () => {
|
||||
const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`);
|
||||
expect(resp.status).toBe(200);
|
||||
expect(resp.header['content-type']).toContain('text/html');
|
||||
expect(resp.text).toContain(
|
||||
`<meta name="description" content="${metadataAlbum.assets.length} shared photos & videos" />`,
|
||||
);
|
||||
expect(resp.text).toContain(`<meta name="description" content="1 shared photos & videos" />`);
|
||||
});
|
||||
|
||||
it('should have correct asset count in meta tag for empty album', async () => {
|
||||
@@ -144,9 +132,7 @@ describe('/shared-links', () => {
|
||||
const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`);
|
||||
expect(resp.status).toBe(200);
|
||||
expect(resp.header['content-type']).toContain('text/html');
|
||||
expect(resp.text).toContain(
|
||||
`<meta name="description" content="${metadataAlbum.assets.length} shared photos & videos" />`,
|
||||
);
|
||||
expect(resp.text).toContain(`<meta name="description" content="1 shared photos & videos" />`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,7 +239,7 @@ describe('/shared-links', () => {
|
||||
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithPassword.key });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.invalidSharePassword);
|
||||
expect(body).toEqual(errorDto.passwordRequired);
|
||||
});
|
||||
|
||||
it('should get data for correct password protected link', async () => {
|
||||
@@ -271,12 +257,12 @@ describe('/shared-links', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return metadata for album shared link', async () => {
|
||||
it('should return metadata for individual shared link', async () => {
|
||||
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.assets).toHaveLength(0);
|
||||
expect(body.album).toBeDefined();
|
||||
expect(body.assets).toHaveLength(1);
|
||||
expect(body.album).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('should not return metadata for album shared link without metadata', async () => {
|
||||
@@ -284,7 +270,7 @@ describe('/shared-links', () => {
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.assets).toHaveLength(1);
|
||||
expect(body.album).toBeDefined();
|
||||
expect(body.album).not.toBeDefined();
|
||||
|
||||
const asset = body.assets[0];
|
||||
expect(asset).not.toHaveProperty('exifInfo');
|
||||
@@ -452,6 +438,16 @@ 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
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
JobName,
|
||||
LoginResponseDto,
|
||||
QueueName,
|
||||
createStack,
|
||||
deleteUserAdmin,
|
||||
getMyUser,
|
||||
@@ -328,7 +328,7 @@ describe('/admin/users', () => {
|
||||
{ headers: asBearerAuth(user.accessToken) },
|
||||
);
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.BackgroundTask);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.BackgroundTask);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/admin/users/${user.userId}`)
|
||||
@@ -0,0 +1,63 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { testAssetDir, utils } from 'src/utils';
|
||||
|
||||
test.describe('Album', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test(`doesn't delete album after canceling add assets`, async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/albums');
|
||||
await page.getByRole('button', { name: 'Create album' }).click();
|
||||
await page.getByRole('button', { name: 'Select photos' }).click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
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,51 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe('Photo Viewer', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
let rawAsset: AssetMediaResponseDto;
|
||||
let websocket: Socket;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
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 }) => {
|
||||
// before each test, login as user
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
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 page.mouse.wheel(0, -1);
|
||||
|
||||
await originalResponse;
|
||||
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
await expect(original).toHaveAttribute('src', /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 page.mouse.wheel(0, -1);
|
||||
|
||||
await fullsizeResponse;
|
||||
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
||||
});
|
||||
|
||||
test('right-click targets the img element', 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');
|
||||
});
|
||||
|
||||
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 initialSrc = await preview.getAttribute('src');
|
||||
|
||||
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
|
||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||
await websocketEvent;
|
||||
|
||||
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
||||
});
|
||||
});
|
||||
+25
-2
@@ -12,15 +12,18 @@ 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: {
|
||||
@@ -39,14 +42,17 @@ 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 }) => {
|
||||
await page.goto(`/share/${sharedLink.key}`);
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
await page.locator(`[data-asset-id="${asset.id}"]`).hover();
|
||||
await page.waitForSelector('[data-group] svg');
|
||||
await page.getByRole('checkbox').click();
|
||||
await page.waitForSelector(`[data-asset-id="${asset.id}"] [role="checkbox"]`);
|
||||
await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
|
||||
});
|
||||
|
||||
@@ -110,4 +116,21 @@ 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);
|
||||
});
|
||||
});
|
||||
+18
-10
@@ -52,14 +52,18 @@ test.describe('User Administration', () => {
|
||||
|
||||
await page.goto(`/admin/users/${user.userId}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Edit user' }).click();
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||
await page.getByText('Admin User').click();
|
||||
await page.getByLabel('Admin User').click();
|
||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
expect(updated.isAdmin).toBe(true);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
return userAdmin.isAdmin;
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('revoke admin access', async ({ context, page }) => {
|
||||
@@ -77,13 +81,17 @@ test.describe('User Administration', () => {
|
||||
|
||||
await page.goto(`/admin/users/${user.userId}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Edit user' }).click();
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||
await page.getByText('Admin User').click();
|
||||
await page.getByLabel('Admin User').click();
|
||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
expect(updated.isAdmin).toBe(false);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
return userAdmin.isAdmin;
|
||||
})
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { generateMemoriesFromTimeline, generateMemory } from './memory/model-objects';
|
||||
export type { MemoryConfig, MemoryYearConfig } from './memory/model-objects';
|
||||
@@ -0,0 +1,84 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { MemoryType, type MemoryResponseDto, type OnThisDayDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline/rest-response';
|
||||
import type { MockTimelineAsset } from 'src/ui/generators/timeline/timeline-config';
|
||||
import { SeededRandom, selectRandomMultiple } from 'src/ui/generators/timeline/utils';
|
||||
|
||||
export type MemoryConfig = {
|
||||
id?: string;
|
||||
ownerId: string;
|
||||
year: number;
|
||||
memoryAt: string;
|
||||
isSaved?: boolean;
|
||||
};
|
||||
|
||||
export type MemoryYearConfig = {
|
||||
year: number;
|
||||
assetCount: number;
|
||||
};
|
||||
|
||||
export function generateMemory(config: MemoryConfig, assets: MockTimelineAsset[]): MemoryResponseDto {
|
||||
const now = new Date().toISOString();
|
||||
const memoryId = config.id ?? faker.string.uuid();
|
||||
|
||||
return {
|
||||
id: memoryId,
|
||||
assets: assets.map((asset) => toAssetResponseDto(asset)),
|
||||
data: { year: config.year } as OnThisDayDto,
|
||||
memoryAt: config.memoryAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isSaved: config.isSaved ?? false,
|
||||
ownerId: config.ownerId,
|
||||
type: MemoryType.OnThisDay,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateMemoriesFromTimeline(
|
||||
timelineAssets: MockTimelineAsset[],
|
||||
ownerId: string,
|
||||
memoryConfigs: MemoryYearConfig[],
|
||||
seed: number = 42,
|
||||
): MemoryResponseDto[] {
|
||||
const rng = new SeededRandom(seed);
|
||||
const memories: MemoryResponseDto[] = [];
|
||||
const usedAssetIds = new Set<string>();
|
||||
|
||||
for (const config of memoryConfigs) {
|
||||
const yearAssets = timelineAssets.filter((asset) => {
|
||||
const assetYear = DateTime.fromISO(asset.fileCreatedAt).year;
|
||||
return assetYear === config.year && !usedAssetIds.has(asset.id);
|
||||
});
|
||||
|
||||
if (yearAssets.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const countToSelect = Math.min(config.assetCount, yearAssets.length);
|
||||
const selectedAssets = selectRandomMultiple(yearAssets, countToSelect, rng);
|
||||
|
||||
for (const asset of selectedAssets) {
|
||||
usedAssetIds.add(asset.id);
|
||||
}
|
||||
|
||||
selectedAssets.sort(
|
||||
(a, b) => DateTime.fromISO(b.fileCreatedAt).diff(DateTime.fromISO(a.fileCreatedAt)).milliseconds,
|
||||
);
|
||||
|
||||
const memoryAt = DateTime.now().set({ year: config.year }).toISO()!;
|
||||
|
||||
memories.push(
|
||||
generateMemory(
|
||||
{
|
||||
ownerId,
|
||||
year: config.year,
|
||||
memoryAt,
|
||||
},
|
||||
selectedAssets,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return memories;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export { generateTimelineData } from './timeline/model-objects';
|
||||
|
||||
export { createDefaultTimelineConfig, validateTimelineConfig } from './timeline/timeline-config';
|
||||
|
||||
export type {
|
||||
MockAlbum,
|
||||
MonthSpec,
|
||||
SerializedTimelineData,
|
||||
MockTimelineAsset as TimelineAssetConfig,
|
||||
TimelineConfig,
|
||||
MockTimelineData as TimelineData,
|
||||
} from './timeline/timeline-config';
|
||||
|
||||
export {
|
||||
getAlbum,
|
||||
getAsset,
|
||||
getTimeBucket,
|
||||
getTimeBuckets,
|
||||
toAssetResponseDto,
|
||||
toColumnarFormat,
|
||||
} from './timeline/rest-response';
|
||||
|
||||
export type { Changes } from './timeline/rest-response';
|
||||
|
||||
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
|
||||
|
||||
export {
|
||||
SeededRandom,
|
||||
getMockAsset,
|
||||
parseTimeBucketKey,
|
||||
selectRandom,
|
||||
selectRandomDays,
|
||||
selectRandomMultiple,
|
||||
} from './timeline/utils';
|
||||
|
||||
export { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './timeline/distribution-patterns';
|
||||
export type { DayPattern, MonthDistribution } from './timeline/distribution-patterns';
|
||||
@@ -0,0 +1,183 @@
|
||||
import { generateConsecutiveDays, generateDayAssets } from 'src/ui/generators/timeline/model-objects';
|
||||
import { SeededRandom, selectRandomDays } from 'src/ui/generators/timeline/utils';
|
||||
import type { MockTimelineAsset } from './timeline-config';
|
||||
import { GENERATION_CONSTANTS } from './timeline-config';
|
||||
|
||||
type AssetDistributionStrategy = (rng: SeededRandom) => number;
|
||||
|
||||
type DayDistributionStrategy = (
|
||||
year: number,
|
||||
month: number,
|
||||
daysInMonth: number,
|
||||
totalAssets: number,
|
||||
ownerId: string,
|
||||
rng: SeededRandom,
|
||||
) => MockTimelineAsset[];
|
||||
|
||||
/**
|
||||
* Strategies for determining total asset count per month
|
||||
*/
|
||||
export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStrategy | null> = {
|
||||
empty: null, // Special case - handled separately
|
||||
sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets
|
||||
medium: (rng) => rng.nextInt(15, 31), // 15-30 assets
|
||||
dense: (rng) => rng.nextInt(50, 81), // 50-80 assets
|
||||
'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets
|
||||
};
|
||||
|
||||
/**
|
||||
* Strategies for distributing assets across days within a month
|
||||
*/
|
||||
export const DAY_DISTRIBUTION: Record<DayPattern, DayDistributionStrategy> = {
|
||||
'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// All assets on one day in the middle of the month
|
||||
const day = Math.floor(daysInMonth / 2);
|
||||
return generateDayAssets(year, month, day, totalAssets, ownerId, rng);
|
||||
},
|
||||
|
||||
'consecutive-large': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// 3-5 consecutive days with evenly distributed assets
|
||||
const numDays = Math.min(5, Math.floor(totalAssets / 15));
|
||||
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
|
||||
return generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
|
||||
},
|
||||
|
||||
'consecutive-small': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Multiple consecutive days with 1-3 assets each (side-by-side layout)
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const numDays = Math.min(totalAssets, Math.floor(daysInMonth / 2));
|
||||
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
|
||||
let assetIndex = 0;
|
||||
|
||||
for (let i = 0; i < numDays && assetIndex < totalAssets; i++) {
|
||||
const dayAssets = Math.min(3, rng.nextInt(1, 4));
|
||||
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
|
||||
// Create a new RNG for this day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, startDay + i, actualAssets, ownerId, dayRng));
|
||||
assetIndex += actualAssets;
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
alternating: (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Alternate between large (15-25) and small (1-3) days
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
let day = 1;
|
||||
let isLarge = true;
|
||||
let assetIndex = 0;
|
||||
|
||||
while (assetIndex < totalAssets && day <= daysInMonth) {
|
||||
const dayAssets = isLarge ? Math.min(25, rng.nextInt(15, 26)) : rng.nextInt(1, 4);
|
||||
|
||||
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
|
||||
// Create a new RNG for this day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, day, actualAssets, ownerId, dayRng));
|
||||
assetIndex += actualAssets;
|
||||
|
||||
day += isLarge ? 1 : 1; // Could add gaps here
|
||||
isLarge = !isLarge;
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
'sparse-scattered': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Spread assets across random days with gaps
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const numDays = Math.min(totalAssets, Math.floor(daysInMonth * GENERATION_CONSTANTS.SPARSE_DAY_COVERAGE));
|
||||
const daysWithPhotos = selectRandomDays(daysInMonth, numDays, rng);
|
||||
let assetIndex = 0;
|
||||
|
||||
for (let i = 0; i < daysWithPhotos.length && assetIndex < totalAssets; i++) {
|
||||
const dayAssets =
|
||||
Math.floor(totalAssets / numDays) + (i === daysWithPhotos.length - 1 ? totalAssets % numDays : 0);
|
||||
// Create a new RNG for this day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, daysWithPhotos[i], dayAssets, ownerId, dayRng));
|
||||
assetIndex += dayAssets;
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
'start-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Most assets in first week
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const firstWeekAssets = Math.floor(totalAssets * 0.7);
|
||||
const remainingAssets = totalAssets - firstWeekAssets;
|
||||
|
||||
// First 7 days
|
||||
assets.push(...generateConsecutiveDays(year, month, 1, 7, firstWeekAssets, ownerId, rng));
|
||||
|
||||
// Remaining scattered
|
||||
if (remainingAssets > 0) {
|
||||
const midDay = Math.floor(daysInMonth / 2);
|
||||
// Create a new RNG for the remaining assets
|
||||
const remainingRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, midDay, remainingAssets, ownerId, remainingRng));
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
'end-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Most assets in last week
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const lastWeekAssets = Math.floor(totalAssets * 0.7);
|
||||
const remainingAssets = totalAssets - lastWeekAssets;
|
||||
|
||||
// Remaining at start
|
||||
if (remainingAssets > 0) {
|
||||
// Create a new RNG for the start assets
|
||||
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, 2, remainingAssets, ownerId, startRng));
|
||||
}
|
||||
|
||||
// Last 7 days
|
||||
const startDay = daysInMonth - 6;
|
||||
assets.push(...generateConsecutiveDays(year, month, startDay, 7, lastWeekAssets, ownerId, rng));
|
||||
return assets;
|
||||
},
|
||||
|
||||
'mid-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Most assets in middle of month
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const midAssets = Math.floor(totalAssets * 0.7);
|
||||
const sideAssets = Math.floor((totalAssets - midAssets) / 2);
|
||||
|
||||
// Start
|
||||
if (sideAssets > 0) {
|
||||
// Create a new RNG for the start assets
|
||||
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, 2, sideAssets, ownerId, startRng));
|
||||
}
|
||||
|
||||
// Middle
|
||||
const midStart = Math.floor(daysInMonth / 2) - 3;
|
||||
assets.push(...generateConsecutiveDays(year, month, midStart, 7, midAssets, ownerId, rng));
|
||||
|
||||
// End
|
||||
const endAssets = totalAssets - midAssets - sideAssets;
|
||||
if (endAssets > 0) {
|
||||
// Create a new RNG for the end assets
|
||||
const endRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, daysInMonth - 1, endAssets, ownerId, endRng));
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
};
|
||||
export type MonthDistribution =
|
||||
| 'empty' // 0 assets
|
||||
| 'sparse' // 3-8 assets
|
||||
| 'medium' // 15-30 assets
|
||||
| 'dense' // 50-80 assets
|
||||
| 'very-dense'; // 80-150 assets
|
||||
|
||||
export type DayPattern =
|
||||
| 'single-day' // All images in one day
|
||||
| 'consecutive-large' // Multiple days with 15-25 images each
|
||||
| 'consecutive-small' // Multiple days with 1-3 images each (side-by-side)
|
||||
| 'alternating' // Alternating large/small days
|
||||
| 'sparse-scattered' // Few images scattered across month
|
||||
| 'start-heavy' // Most images at start of month
|
||||
| 'end-heavy' // Most images at end of month
|
||||
| 'mid-heavy'; // Most images in middle of month
|
||||
@@ -0,0 +1,111 @@
|
||||
import sharp from 'sharp';
|
||||
import { SeededRandom } from 'src/ui/generators/timeline/utils';
|
||||
|
||||
export const randomThumbnail = async (seed: string, ratio: number) => {
|
||||
const height = 235;
|
||||
const width = Math.round(height * ratio);
|
||||
return randomImageFromString(seed, { width, height });
|
||||
};
|
||||
|
||||
export const randomPreview = async (seed: string, ratio: number) => {
|
||||
const height = 500;
|
||||
const width = Math.round(height * ratio);
|
||||
return randomImageFromString(seed, { width, height });
|
||||
};
|
||||
|
||||
export const randomImageFromString = async (
|
||||
seed: string = '',
|
||||
{ width = 100, height = 100 }: { width: number; height: number },
|
||||
) => {
|
||||
// Convert string to number for seeding
|
||||
let seedNumber = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
seedNumber = (seedNumber << 5) - seedNumber + (seed.codePointAt(i) ?? 0);
|
||||
seedNumber = seedNumber & seedNumber; // Convert to 32bit integer
|
||||
}
|
||||
return randomImage(new SeededRandom(Math.abs(seedNumber)), { width, height });
|
||||
};
|
||||
|
||||
export const randomImage = async (rng: SeededRandom, { width, height }: { width: number; height: number }) => {
|
||||
const r1 = rng.nextInt(0, 256);
|
||||
const g1 = rng.nextInt(0, 256);
|
||||
const b1 = rng.nextInt(0, 256);
|
||||
const r2 = rng.nextInt(0, 256);
|
||||
const g2 = rng.nextInt(0, 256);
|
||||
const b2 = rng.nextInt(0, 256);
|
||||
const patternType = rng.nextInt(0, 5);
|
||||
|
||||
let svgPattern = '';
|
||||
|
||||
switch (patternType) {
|
||||
case 0: {
|
||||
// Solid color
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
<rect x="0" y="0" width="${width}" height="${height}" fill="rgb(${r1},${g1},${b1})"/>
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 1: {
|
||||
// Horizontal stripes
|
||||
const stripeHeight = 10;
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
${Array.from(
|
||||
{ length: height / stripeHeight },
|
||||
(_, i) =>
|
||||
`<rect x="0" y="${i * stripeHeight}" width="${width}" height="${stripeHeight}"
|
||||
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
|
||||
).join('')}
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 2: {
|
||||
// Vertical stripes
|
||||
const stripeWidth = 10;
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
${Array.from(
|
||||
{ length: width / stripeWidth },
|
||||
(_, i) =>
|
||||
`<rect x="${i * stripeWidth}" y="0" width="${stripeWidth}" height="${height}"
|
||||
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
|
||||
).join('')}
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 3: {
|
||||
// Checkerboard
|
||||
const squareSize = 10;
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
${Array.from({ length: height / squareSize }, (_, row) =>
|
||||
Array.from({ length: width / squareSize }, (_, col) => {
|
||||
const isEven = (row + col) % 2 === 0;
|
||||
return `<rect x="${col * squareSize}" y="${row * squareSize}"
|
||||
width="${squareSize}" height="${squareSize}"
|
||||
fill="rgb(${isEven ? r1 : r2},${isEven ? g1 : g2},${isEven ? b1 : b2})"/>`;
|
||||
}).join(''),
|
||||
).join('')}
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 4: {
|
||||
// Diagonal stripes
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
<defs>
|
||||
<pattern id="diagonal" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<rect x="0" y="0" width="10" height="20" fill="rgb(${r1},${g1},${b1})"/>
|
||||
<rect x="10" y="0" width="10" height="20" fill="rgb(${r2},${g2},${b2})"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="${width}" height="${height}" fill="url(#diagonal)" transform="rotate(45 50 50)"/>
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const svgBuffer = Buffer.from(svgPattern);
|
||||
const jpegData = await sharp(svgBuffer).jpeg({ quality: 50 }).toBuffer();
|
||||
return jpegData;
|
||||
};
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Generator functions for timeline model objects
|
||||
*/
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { SeededRandom } from 'src/ui/generators/timeline/utils';
|
||||
import type { DayPattern, MonthDistribution } from './distribution-patterns';
|
||||
import { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './distribution-patterns';
|
||||
import type { MockTimelineAsset, MockTimelineData, SerializedTimelineData, TimelineConfig } from './timeline-config';
|
||||
import { ASPECT_RATIO_WEIGHTS, GENERATION_CONSTANTS, validateTimelineConfig } from './timeline-config';
|
||||
|
||||
/**
|
||||
* Generate a random aspect ratio based on weighted probabilities
|
||||
*/
|
||||
export function generateAspectRatio(rng: SeededRandom): string {
|
||||
const random = rng.next();
|
||||
let cumulative = 0;
|
||||
|
||||
for (const [ratio, weight] of Object.entries(ASPECT_RATIO_WEIGHTS)) {
|
||||
cumulative += weight;
|
||||
if (random < cumulative) {
|
||||
return ratio;
|
||||
}
|
||||
}
|
||||
return '16:9'; // Default fallback
|
||||
}
|
||||
|
||||
export function generateThumbhash(rng: SeededRandom): string {
|
||||
return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function generateDuration(rng: SeededRandom): string {
|
||||
return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
export function generateUUID(): string {
|
||||
return faker.string.uuid();
|
||||
}
|
||||
|
||||
export function generateAsset(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
ownerId: string,
|
||||
rng: SeededRandom,
|
||||
): MockTimelineAsset {
|
||||
const from = DateTime.fromObject({ year, month, day }).setZone('UTC');
|
||||
const to = from.endOf('day');
|
||||
const date = faker.date.between({ from: from.toJSDate(), to: to.toJSDate() });
|
||||
const isVideo = rng.next() < GENERATION_CONSTANTS.VIDEO_PROBABILITY;
|
||||
|
||||
const assetId = generateUUID();
|
||||
const hasGPS = rng.next() < GENERATION_CONSTANTS.GPS_PERCENTAGE;
|
||||
|
||||
const ratio = generateAspectRatio(rng);
|
||||
|
||||
const asset: MockTimelineAsset = {
|
||||
id: assetId,
|
||||
ownerId,
|
||||
ratio: Number.parseFloat(ratio.split(':')[0]) / Number.parseFloat(ratio.split(':')[1]),
|
||||
thumbhash: generateThumbhash(rng),
|
||||
localDateTime: date.toISOString(),
|
||||
fileCreatedAt: date.toISOString(),
|
||||
isFavorite: rng.next() < GENERATION_CONSTANTS.FAVORITE_PROBABILITY,
|
||||
isTrashed: false,
|
||||
isVideo,
|
||||
isImage: !isVideo,
|
||||
duration: isVideo ? generateDuration(rng) : null,
|
||||
projectionType: null,
|
||||
livePhotoVideoId: null,
|
||||
city: hasGPS ? faker.location.city() : null,
|
||||
country: hasGPS ? faker.location.country() : null,
|
||||
people: null,
|
||||
latitude: hasGPS ? faker.location.latitude() : null,
|
||||
longitude: hasGPS ? faker.location.longitude() : null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
stack: null,
|
||||
fileSizeInByte: faker.number.int({ min: 510, max: 5_000_000 }),
|
||||
checksum: faker.string.alphanumeric({ length: 5 }),
|
||||
};
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate assets for a specific day
|
||||
*/
|
||||
export function generateDayAssets(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
assetCount: number,
|
||||
ownerId: string,
|
||||
rng: SeededRandom,
|
||||
): MockTimelineAsset[] {
|
||||
return Array.from({ length: assetCount }, () => generateAsset(year, month, day, ownerId, rng));
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute assets evenly across consecutive days
|
||||
*
|
||||
* @returns Array of generated timeline assets
|
||||
*/
|
||||
export function generateConsecutiveDays(
|
||||
year: number,
|
||||
month: number,
|
||||
startDay: number,
|
||||
numDays: number,
|
||||
totalAssets: number,
|
||||
ownerId: string,
|
||||
rng: SeededRandom,
|
||||
): MockTimelineAsset[] {
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const assetsPerDay = Math.floor(totalAssets / numDays);
|
||||
|
||||
for (let i = 0; i < numDays; i++) {
|
||||
const dayAssets =
|
||||
i === numDays - 1
|
||||
? totalAssets - assetsPerDay * (numDays - 1) // Remainder on last day
|
||||
: assetsPerDay;
|
||||
// Create a new RNG with a different seed for each day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000) + i * 100);
|
||||
assets.push(...generateDayAssets(year, month, startDay + i, dayAssets, ownerId, dayRng));
|
||||
}
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate assets for a month with specified distribution pattern
|
||||
*/
|
||||
export function generateMonthAssets(
|
||||
year: number,
|
||||
month: number,
|
||||
ownerId: string,
|
||||
distribution: MonthDistribution = 'medium',
|
||||
pattern: DayPattern = 'consecutive-large',
|
||||
rng: SeededRandom,
|
||||
): MockTimelineAsset[] {
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
|
||||
if (distribution === 'empty') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const distributionStrategy = ASSET_DISTRIBUTION[distribution];
|
||||
if (!distributionStrategy) {
|
||||
console.warn(`Unknown distribution: ${distribution}, defaulting to medium`);
|
||||
return [];
|
||||
}
|
||||
const totalAssets = distributionStrategy(rng);
|
||||
|
||||
const dayStrategy = DAY_DISTRIBUTION[pattern];
|
||||
if (!dayStrategy) {
|
||||
console.warn(`Unknown pattern: ${pattern}, defaulting to consecutive-large`);
|
||||
// Fallback to consecutive-large pattern
|
||||
const numDays = Math.min(5, Math.floor(totalAssets / 15));
|
||||
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
|
||||
const assets = generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
|
||||
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
|
||||
return assets;
|
||||
}
|
||||
|
||||
const assets = dayStrategy(year, month, daysInMonth, totalAssets, ownerId, rng);
|
||||
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main generator function for timeline data
|
||||
*/
|
||||
export function generateTimelineData(config: TimelineConfig): MockTimelineData {
|
||||
validateTimelineConfig(config);
|
||||
|
||||
const buckets = new Map<string, MockTimelineAsset[]>();
|
||||
const monthStats: Record<string, { count: number; distribution: MonthDistribution; pattern: DayPattern }> = {};
|
||||
|
||||
const globalRng = new SeededRandom(config.seed || GENERATION_CONSTANTS.DEFAULT_SEED);
|
||||
faker.seed(globalRng.nextInt(0, 1_000_000));
|
||||
for (const monthConfig of config.months) {
|
||||
const { year, month, distribution, pattern } = monthConfig;
|
||||
|
||||
const monthSeed = globalRng.nextInt(0, 1_000_000);
|
||||
const monthRng = new SeededRandom(monthSeed);
|
||||
|
||||
const monthAssets = generateMonthAssets(
|
||||
year,
|
||||
month,
|
||||
config.ownerId || generateUUID(),
|
||||
distribution,
|
||||
pattern,
|
||||
monthRng,
|
||||
);
|
||||
|
||||
if (monthAssets.length > 0) {
|
||||
const monthKey = `${year}-${month.toString().padStart(2, '0')}`;
|
||||
monthStats[monthKey] = {
|
||||
count: monthAssets.length,
|
||||
distribution,
|
||||
pattern,
|
||||
};
|
||||
|
||||
// Create bucket key (YYYY-MM-01)
|
||||
const bucketKey = `${year}-${month.toString().padStart(2, '0')}-01`;
|
||||
buckets.set(bucketKey, monthAssets);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a mock album from random assets
|
||||
const allAssets = [...buckets.values()].flat();
|
||||
|
||||
// Select 10-30 random assets for the album (or all assets if less than 10)
|
||||
const albumSize = Math.min(allAssets.length, globalRng.nextInt(10, 31));
|
||||
const selectedAssetConfigs: MockTimelineAsset[] = [];
|
||||
const usedIndices = new Set<number>();
|
||||
|
||||
while (selectedAssetConfigs.length < albumSize && usedIndices.size < allAssets.length) {
|
||||
const randomIndex = globalRng.nextInt(0, allAssets.length);
|
||||
if (!usedIndices.has(randomIndex)) {
|
||||
usedIndices.add(randomIndex);
|
||||
selectedAssetConfigs.push(allAssets[randomIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort selected assets by date (newest first)
|
||||
selectedAssetConfigs.sort(
|
||||
(a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds,
|
||||
);
|
||||
|
||||
const selectedAssets = selectedAssetConfigs.map((asset) => asset.id);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const album = {
|
||||
id: generateUUID(),
|
||||
albumName: 'Test Album',
|
||||
description: 'A mock album for testing',
|
||||
assetIds: selectedAssets,
|
||||
thumbnailAssetId: selectedAssets.length > 0 ? selectedAssets[0] : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Write to file if configured
|
||||
if (config.writeToFile) {
|
||||
const outputPath = config.outputPath || '/tmp/timeline-data.json';
|
||||
|
||||
// Convert Map to object for serialization
|
||||
const serializedData: SerializedTimelineData = {
|
||||
buckets: Object.fromEntries(buckets),
|
||||
album,
|
||||
};
|
||||
|
||||
try {
|
||||
writeFileSync(outputPath, JSON.stringify(serializedData, null, 2));
|
||||
console.log(`Timeline data written to ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to write timeline data to ${outputPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { buckets, album };
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* REST API output functions for converting timeline data to API response formats
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
AssetVisibility,
|
||||
UserAvatarColor,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
type ExifResponseDto,
|
||||
type TimeBucketAssetResponseDto,
|
||||
type TimeBucketsResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { signupDto } from 'src/fixtures';
|
||||
import { parseTimeBucketKey } from 'src/ui/generators/timeline/utils';
|
||||
import type { MockTimelineAsset, MockTimelineData } from './timeline-config';
|
||||
|
||||
/**
|
||||
* Convert timeline/asset models to columnar format (parallel arrays)
|
||||
*/
|
||||
export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetResponseDto {
|
||||
const result: TimeBucketAssetResponseDto = {
|
||||
id: [],
|
||||
ownerId: [],
|
||||
ratio: [],
|
||||
thumbhash: [],
|
||||
fileCreatedAt: [],
|
||||
localOffsetHours: [],
|
||||
isFavorite: [],
|
||||
isTrashed: [],
|
||||
isImage: [],
|
||||
duration: [],
|
||||
projectionType: [],
|
||||
livePhotoVideoId: [],
|
||||
city: [],
|
||||
country: [],
|
||||
visibility: [],
|
||||
};
|
||||
|
||||
for (const asset of assets) {
|
||||
result.id.push(asset.id);
|
||||
result.ownerId.push(asset.ownerId);
|
||||
result.ratio.push(asset.ratio);
|
||||
result.thumbhash.push(asset.thumbhash);
|
||||
result.fileCreatedAt.push(asset.fileCreatedAt);
|
||||
result.localOffsetHours.push(0); // Assuming UTC for mocks
|
||||
result.isFavorite.push(asset.isFavorite);
|
||||
result.isTrashed.push(asset.isTrashed);
|
||||
result.isImage.push(asset.isImage);
|
||||
result.duration.push(asset.duration);
|
||||
result.projectionType.push(asset.projectionType);
|
||||
result.livePhotoVideoId.push(asset.livePhotoVideoId);
|
||||
result.city.push(asset.city);
|
||||
result.country.push(asset.country);
|
||||
result.visibility.push(asset.visibility);
|
||||
}
|
||||
|
||||
if (assets.some((a) => a.latitude !== null || a.longitude !== null)) {
|
||||
result.latitude = assets.map((a) => a.latitude);
|
||||
result.longitude = assets.map((a) => a.longitude);
|
||||
}
|
||||
|
||||
result.stack = assets.map(() => null);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a single bucket from timeline data (mimics getTimeBucket API)
|
||||
* Automatically handles both ISO timestamp and simple month formats
|
||||
* Returns data in columnar format matching the actual API
|
||||
* When albumId is provided, only returns assets from that album
|
||||
*/
|
||||
export function getTimeBucket(
|
||||
timelineData: MockTimelineData,
|
||||
timeBucket: string,
|
||||
isTrashed: boolean | undefined,
|
||||
isArchived: boolean | undefined,
|
||||
isFavorite: boolean | undefined,
|
||||
albumId: string | undefined,
|
||||
changes: Changes,
|
||||
): TimeBucketAssetResponseDto {
|
||||
const bucketKey = parseTimeBucketKey(timeBucket);
|
||||
let assets = timelineData.buckets.get(bucketKey);
|
||||
|
||||
if (!assets) {
|
||||
return toColumnarFormat([]);
|
||||
}
|
||||
|
||||
// Create sets for quick lookups
|
||||
const deletedAssetIds = new Set(changes.assetDeletions);
|
||||
const archivedAssetIds = new Set(changes.assetArchivals);
|
||||
const favoritedAssetIds = new Set(changes.assetFavorites);
|
||||
|
||||
// Filter assets based on trashed/archived status
|
||||
assets = assets.filter((asset) =>
|
||||
shouldIncludeAsset(asset, isTrashed, isArchived, isFavorite, deletedAssetIds, archivedAssetIds, favoritedAssetIds),
|
||||
);
|
||||
|
||||
// Filter to only include assets from the specified album
|
||||
if (albumId) {
|
||||
const album = timelineData.album;
|
||||
if (!album || album.id !== albumId) {
|
||||
return toColumnarFormat([]);
|
||||
}
|
||||
|
||||
// Create a Set for faster lookup
|
||||
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
|
||||
assets = assets.filter((asset) => albumAssetIds.has(asset.id));
|
||||
}
|
||||
|
||||
// Override properties for assets in changes arrays
|
||||
const assetsWithOverrides = assets.map((asset) => {
|
||||
if (deletedAssetIds.has(asset.id) || archivedAssetIds.has(asset.id) || favoritedAssetIds.has(asset.id)) {
|
||||
return {
|
||||
...asset,
|
||||
isFavorite: favoritedAssetIds.has(asset.id) ? true : asset.isFavorite,
|
||||
isTrashed: deletedAssetIds.has(asset.id) ? true : asset.isTrashed,
|
||||
visibility: archivedAssetIds.has(asset.id) ? AssetVisibility.Archive : asset.visibility,
|
||||
};
|
||||
}
|
||||
return asset;
|
||||
});
|
||||
|
||||
return toColumnarFormat(assetsWithOverrides);
|
||||
}
|
||||
|
||||
export type Changes = {
|
||||
// ids of assets that are newly added to the album
|
||||
albumAdditions: string[];
|
||||
// ids of assets that are newly deleted
|
||||
assetDeletions: string[];
|
||||
// ids of assets that are newly archived
|
||||
assetArchivals: string[];
|
||||
// ids of assets that are newly favorited
|
||||
assetFavorites: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to determine if an asset should be included based on filter criteria
|
||||
* @param asset - The asset to check
|
||||
* @param isTrashed - Filter for trashed status (undefined means no filter)
|
||||
* @param isArchived - Filter for archived status (undefined means no filter)
|
||||
* @param isFavorite - Filter for favorite status (undefined means no filter)
|
||||
* @param deletedAssetIds - Set of IDs for assets that have been deleted
|
||||
* @param archivedAssetIds - Set of IDs for assets that have been archived
|
||||
* @param favoritedAssetIds - Set of IDs for assets that have been favorited
|
||||
* @returns true if the asset matches all filter criteria
|
||||
*/
|
||||
function shouldIncludeAsset(
|
||||
asset: MockTimelineAsset,
|
||||
isTrashed: boolean | undefined,
|
||||
isArchived: boolean | undefined,
|
||||
isFavorite: boolean | undefined,
|
||||
deletedAssetIds: Set<string>,
|
||||
archivedAssetIds: Set<string>,
|
||||
favoritedAssetIds: Set<string>,
|
||||
): boolean {
|
||||
// Determine actual status (property or in changes)
|
||||
const actuallyTrashed = asset.isTrashed || deletedAssetIds.has(asset.id);
|
||||
const actuallyArchived = asset.visibility === 'archive' || archivedAssetIds.has(asset.id);
|
||||
const actuallyFavorited = asset.isFavorite || favoritedAssetIds.has(asset.id);
|
||||
|
||||
// Apply filters
|
||||
if (isTrashed !== undefined && actuallyTrashed !== isTrashed) {
|
||||
return false;
|
||||
}
|
||||
if (isArchived !== undefined && actuallyArchived !== isArchived) {
|
||||
return false;
|
||||
}
|
||||
if (isFavorite !== undefined && actuallyFavorited !== isFavorite) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Get summary for all buckets (mimics getTimeBuckets API)
|
||||
* When albumId is provided, only includes buckets that contain assets from that album
|
||||
*/
|
||||
export function getTimeBuckets(
|
||||
timelineData: MockTimelineData,
|
||||
isTrashed: boolean | undefined,
|
||||
isArchived: boolean | undefined,
|
||||
isFavorite: boolean | undefined,
|
||||
albumId: string | undefined,
|
||||
changes: Changes,
|
||||
): TimeBucketsResponseDto[] {
|
||||
const summary: TimeBucketsResponseDto[] = [];
|
||||
|
||||
// Create sets for quick lookups
|
||||
const deletedAssetIds = new Set(changes.assetDeletions);
|
||||
const archivedAssetIds = new Set(changes.assetArchivals);
|
||||
const favoritedAssetIds = new Set(changes.assetFavorites);
|
||||
|
||||
// If no albumId is specified, return summary for all assets
|
||||
if (albumId) {
|
||||
// Filter to only include buckets with assets from the specified album
|
||||
const album = timelineData.album;
|
||||
if (!album || album.id !== albumId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create a Set for faster lookup
|
||||
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
|
||||
for (const removed of changes.assetDeletions) {
|
||||
albumAssetIds.delete(removed);
|
||||
}
|
||||
for (const [bucketKey, assets] of timelineData.buckets) {
|
||||
// Count how many assets in this bucket are in the album and match trashed/archived filters
|
||||
const albumAssetsInBucket = assets.filter((asset) => {
|
||||
// Must be in the album
|
||||
if (!albumAssetIds.has(asset.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return shouldIncludeAsset(
|
||||
asset,
|
||||
isTrashed,
|
||||
isArchived,
|
||||
isFavorite,
|
||||
deletedAssetIds,
|
||||
archivedAssetIds,
|
||||
favoritedAssetIds,
|
||||
);
|
||||
});
|
||||
|
||||
if (albumAssetsInBucket.length > 0) {
|
||||
summary.push({
|
||||
timeBucket: bucketKey,
|
||||
count: albumAssetsInBucket.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const [bucketKey, assets] of timelineData.buckets) {
|
||||
// Filter assets based on trashed/archived status
|
||||
const filteredAssets = assets.filter((asset) =>
|
||||
shouldIncludeAsset(
|
||||
asset,
|
||||
isTrashed,
|
||||
isArchived,
|
||||
isFavorite,
|
||||
deletedAssetIds,
|
||||
archivedAssetIds,
|
||||
favoritedAssetIds,
|
||||
),
|
||||
);
|
||||
|
||||
if (filteredAssets.length > 0) {
|
||||
summary.push({
|
||||
timeBucket: bucketKey,
|
||||
count: filteredAssets.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort summary by date (newest first) using luxon
|
||||
summary.sort((a, b) => {
|
||||
const dateA = DateTime.fromISO(a.timeBucket);
|
||||
const dateB = DateTime.fromISO(b.timeBucket);
|
||||
return dateB.diff(dateA).milliseconds;
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
const createDefaultOwner = (ownerId: string) => {
|
||||
const defaultOwner: UserResponseDto = {
|
||||
id: ownerId,
|
||||
email: signupDto.admin.email,
|
||||
name: signupDto.admin.name,
|
||||
profileImagePath: '',
|
||||
profileChangedAt: new Date().toISOString(),
|
||||
avatarColor: UserAvatarColor.Blue,
|
||||
};
|
||||
return defaultOwner;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
||||
* This matches the response from GET /api/assets/:id
|
||||
*/
|
||||
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Default owner if not provided
|
||||
const defaultOwner = createDefaultOwner(asset.ownerId);
|
||||
|
||||
const exifInfo: ExifResponseDto = {
|
||||
make: null,
|
||||
model: null,
|
||||
exifImageWidth: asset.ratio > 1 ? 4000 : 3000,
|
||||
exifImageHeight: asset.ratio > 1 ? Math.round(4000 / asset.ratio) : Math.round(3000 * asset.ratio),
|
||||
fileSizeInByte: asset.fileSizeInByte,
|
||||
orientation: '1',
|
||||
dateTimeOriginal: asset.fileCreatedAt,
|
||||
modifyDate: asset.fileCreatedAt,
|
||||
timeZone: asset.latitude === null ? null : 'UTC',
|
||||
lensModel: null,
|
||||
fNumber: null,
|
||||
focalLength: null,
|
||||
iso: null,
|
||||
exposureTime: null,
|
||||
latitude: asset.latitude,
|
||||
longitude: asset.longitude,
|
||||
city: asset.city,
|
||||
country: asset.country,
|
||||
state: null,
|
||||
description: null,
|
||||
};
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
deviceAssetId: `device-${asset.id}`,
|
||||
ownerId: asset.ownerId,
|
||||
owner: owner || defaultOwner,
|
||||
libraryId: `library-${asset.ownerId}`,
|
||||
deviceId: `device-${asset.ownerId}`,
|
||||
type: asset.isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
|
||||
originalPath: `/original/${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
|
||||
originalFileName: `${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
|
||||
originalMimeType: asset.isVideo ? 'video/mp4' : 'image/jpeg',
|
||||
thumbhash: asset.thumbhash,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileCreatedAt,
|
||||
localDateTime: asset.localDateTime,
|
||||
updatedAt: now,
|
||||
createdAt: asset.fileCreatedAt,
|
||||
isFavorite: asset.isFavorite,
|
||||
isArchived: false,
|
||||
isTrashed: asset.isTrashed,
|
||||
visibility: asset.visibility,
|
||||
duration: asset.duration || '0:00:00.00000',
|
||||
exifInfo,
|
||||
livePhotoVideoId: asset.livePhotoVideoId,
|
||||
tags: [],
|
||||
people: [],
|
||||
unassignedFaces: [],
|
||||
stack: asset.stack,
|
||||
isOffline: false,
|
||||
hasMetadata: true,
|
||||
duplicateId: null,
|
||||
resized: true,
|
||||
checksum: asset.checksum,
|
||||
width: exifInfo.exifImageWidth ?? 1,
|
||||
height: exifInfo.exifImageHeight ?? 1,
|
||||
isEdited: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single asset by ID from timeline data
|
||||
* This matches the response from GET /api/assets/:id
|
||||
*/
|
||||
export function getAsset(
|
||||
timelineData: MockTimelineData,
|
||||
assetId: string,
|
||||
owner?: UserResponseDto,
|
||||
): AssetResponseDto | undefined {
|
||||
// Search through all buckets for the asset
|
||||
const buckets = [...timelineData.buckets.values()];
|
||||
for (const assets of buckets) {
|
||||
const asset = assets.find((a) => a.id === assetId);
|
||||
if (asset) {
|
||||
return toAssetResponseDto(asset, owner);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mock album from timeline data
|
||||
* This matches the response from GET /api/albums/:id
|
||||
*/
|
||||
export function getAlbum(
|
||||
timelineData: MockTimelineData,
|
||||
ownerId: string,
|
||||
albumId: string | undefined,
|
||||
changes: Changes,
|
||||
): AlbumResponseDto | undefined {
|
||||
if (!timelineData.album) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If albumId is provided and doesn't match, return undefined
|
||||
if (albumId && albumId !== timelineData.album.id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const album = timelineData.album;
|
||||
const albumOwner = createDefaultOwner(ownerId);
|
||||
|
||||
// Get the actual asset objects from the timeline data
|
||||
const albumAssets: AssetResponseDto[] = [];
|
||||
const allAssets = [...timelineData.buckets.values()].flat();
|
||||
|
||||
for (const assetId of album.assetIds) {
|
||||
const assetConfig = allAssets.find((a) => a.id === assetId);
|
||||
if (assetConfig) {
|
||||
albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
|
||||
}
|
||||
}
|
||||
for (const assetId of changes.albumAdditions ?? []) {
|
||||
const assetConfig = allAssets.find((a) => a.id === assetId);
|
||||
if (assetConfig) {
|
||||
albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
|
||||
}
|
||||
}
|
||||
|
||||
albumAssets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
|
||||
|
||||
// For a basic mock album, we don't include any albumUsers (shared users)
|
||||
// The owner is represented by the owner field, not in albumUsers
|
||||
const response: AlbumResponseDto = {
|
||||
id: album.id,
|
||||
albumName: album.albumName,
|
||||
description: album.description,
|
||||
albumThumbnailAssetId: album.thumbnailAssetId,
|
||||
createdAt: album.createdAt,
|
||||
updatedAt: album.updatedAt,
|
||||
ownerId: albumOwner.id,
|
||||
owner: albumOwner,
|
||||
albumUsers: [], // Empty array for non-shared album
|
||||
shared: false,
|
||||
hasSharedLink: false,
|
||||
isActivityEnabled: true,
|
||||
assetCount: albumAssets.length,
|
||||
assets: albumAssets,
|
||||
startDate: albumAssets.length > 0 ? albumAssets.at(-1)?.fileCreatedAt : undefined,
|
||||
endDate: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
|
||||
lastModifiedAssetTimestamp: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { AssetVisibility } from '@immich/sdk';
|
||||
import { DayPattern, MonthDistribution } from 'src/ui/generators/timeline/distribution-patterns';
|
||||
|
||||
// Constants for generation parameters
|
||||
export const GENERATION_CONSTANTS = {
|
||||
VIDEO_PROBABILITY: 0.15, // 15% of assets are videos
|
||||
GPS_PERCENTAGE: 0.7, // 70% of assets have GPS data
|
||||
FAVORITE_PROBABILITY: 0.1, // 10% of assets are favorited
|
||||
MIN_VIDEO_DURATION_SECONDS: 5,
|
||||
MAX_VIDEO_DURATION_SECONDS: 300,
|
||||
DEFAULT_SEED: 12_345,
|
||||
DEFAULT_OWNER_ID: 'user-1',
|
||||
MAX_SELECT_ATTEMPTS: 10,
|
||||
SPARSE_DAY_COVERAGE: 0.4, // 40% of days have photos in sparse pattern
|
||||
} as const;
|
||||
|
||||
// Aspect ratio distribution weights (must sum to 1)
|
||||
export const ASPECT_RATIO_WEIGHTS = {
|
||||
'4:3': 0.35, // 35% 4:3 landscape
|
||||
'3:2': 0.25, // 25% 3:2 landscape
|
||||
'16:9': 0.2, // 20% 16:9 landscape
|
||||
'2:3': 0.1, // 10% 2:3 portrait
|
||||
'1:1': 0.09, // 9% 1:1 square
|
||||
'3:1': 0.01, // 1% 3:1 panorama
|
||||
} as const;
|
||||
|
||||
export type AspectRatio = {
|
||||
width: number;
|
||||
height: number;
|
||||
ratio: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// Mock configuration for asset generation - will be transformed to API response formats
|
||||
export type MockTimelineAsset = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
ratio: number;
|
||||
thumbhash: string | null;
|
||||
localDateTime: string;
|
||||
fileCreatedAt: string;
|
||||
isFavorite: boolean;
|
||||
isTrashed: boolean;
|
||||
isVideo: boolean;
|
||||
isImage: boolean;
|
||||
duration: string | null;
|
||||
projectionType: string | null;
|
||||
livePhotoVideoId: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
people: string[] | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
visibility: AssetVisibility;
|
||||
stack: null;
|
||||
checksum: string;
|
||||
fileSizeInByte: number;
|
||||
};
|
||||
|
||||
export type MonthSpec = {
|
||||
year: number;
|
||||
month: number; // 1-12
|
||||
distribution: MonthDistribution;
|
||||
pattern: DayPattern;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for timeline data generation
|
||||
*/
|
||||
export type TimelineConfig = {
|
||||
ownerId?: string;
|
||||
months: MonthSpec[];
|
||||
seed?: number;
|
||||
writeToFile?: boolean;
|
||||
outputPath?: string;
|
||||
};
|
||||
|
||||
export type MockAlbum = {
|
||||
id: string;
|
||||
albumName: string;
|
||||
description: string;
|
||||
assetIds: string[]; // IDs of assets in the album
|
||||
thumbnailAssetId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MockTimelineData = {
|
||||
buckets: Map<string, MockTimelineAsset[]>;
|
||||
album: MockAlbum; // Mock album created from random assets
|
||||
};
|
||||
|
||||
export type SerializedTimelineData = {
|
||||
buckets: Record<string, MockTimelineAsset[]>;
|
||||
album: MockAlbum;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a TimelineConfig object to ensure all values are within expected ranges
|
||||
*/
|
||||
export function validateTimelineConfig(config: TimelineConfig): void {
|
||||
if (!config.months || config.months.length === 0) {
|
||||
throw new Error('TimelineConfig must contain at least one month');
|
||||
}
|
||||
|
||||
const seenMonths = new Set<string>();
|
||||
|
||||
for (const month of config.months) {
|
||||
if (month.month < 1 || month.month > 12) {
|
||||
throw new Error(`Invalid month: ${month.month}. Must be between 1 and 12`);
|
||||
}
|
||||
|
||||
if (month.year < 1900 || month.year > 2100) {
|
||||
throw new Error(`Invalid year: ${month.year}. Must be between 1900 and 2100`);
|
||||
}
|
||||
|
||||
const monthKey = `${month.year}-${month.month}`;
|
||||
if (seenMonths.has(monthKey)) {
|
||||
throw new Error(`Duplicate month found: ${monthKey}`);
|
||||
}
|
||||
seenMonths.add(monthKey);
|
||||
|
||||
// Validate distribution if provided
|
||||
if (month.distribution && !['empty', 'sparse', 'medium', 'dense', 'very-dense'].includes(month.distribution)) {
|
||||
throw new Error(
|
||||
`Invalid distribution: ${month.distribution}. Must be one of: empty, sparse, medium, dense, very-dense`,
|
||||
);
|
||||
}
|
||||
|
||||
const validPatterns = [
|
||||
'single-day',
|
||||
'consecutive-large',
|
||||
'consecutive-small',
|
||||
'alternating',
|
||||
'sparse-scattered',
|
||||
'start-heavy',
|
||||
'end-heavy',
|
||||
'mid-heavy',
|
||||
];
|
||||
if (month.pattern && !validPatterns.includes(month.pattern)) {
|
||||
throw new Error(`Invalid pattern: ${month.pattern}. Must be one of: ${validPatterns.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate seed if provided
|
||||
if (config.seed !== undefined && (config.seed < 0 || !Number.isInteger(config.seed))) {
|
||||
throw new Error('Seed must be a non-negative integer');
|
||||
}
|
||||
|
||||
// Validate ownerId if provided
|
||||
if (config.ownerId !== undefined && config.ownerId.trim() === '') {
|
||||
throw new Error('Owner ID cannot be an empty string');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default timeline configuration
|
||||
*/
|
||||
export function createDefaultTimelineConfig(): TimelineConfig {
|
||||
const months: MonthSpec[] = [
|
||||
// 2024 - Mix of patterns
|
||||
{ year: 2024, month: 12, distribution: 'very-dense', pattern: 'alternating' },
|
||||
{ year: 2024, month: 11, distribution: 'dense', pattern: 'consecutive-large' },
|
||||
{ year: 2024, month: 10, distribution: 'medium', pattern: 'mid-heavy' },
|
||||
{ year: 2024, month: 9, distribution: 'sparse', pattern: 'consecutive-small' },
|
||||
{ year: 2024, month: 8, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2024, month: 7, distribution: 'dense', pattern: 'start-heavy' },
|
||||
{ year: 2024, month: 6, distribution: 'medium', pattern: 'sparse-scattered' },
|
||||
{ year: 2024, month: 5, distribution: 'sparse', pattern: 'single-day' },
|
||||
{ year: 2024, month: 4, distribution: 'very-dense', pattern: 'consecutive-large' },
|
||||
{ year: 2024, month: 3, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2024, month: 2, distribution: 'medium', pattern: 'end-heavy' },
|
||||
{ year: 2024, month: 1, distribution: 'dense', pattern: 'alternating' },
|
||||
|
||||
// 2023 - Testing year boundaries and more patterns
|
||||
{ year: 2023, month: 12, distribution: 'very-dense', pattern: 'end-heavy' },
|
||||
{ year: 2023, month: 11, distribution: 'sparse', pattern: 'consecutive-small' },
|
||||
{ year: 2023, month: 10, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2023, month: 9, distribution: 'medium', pattern: 'alternating' },
|
||||
{ year: 2023, month: 8, distribution: 'dense', pattern: 'mid-heavy' },
|
||||
{ year: 2023, month: 7, distribution: 'sparse', pattern: 'sparse-scattered' },
|
||||
{ year: 2023, month: 6, distribution: 'medium', pattern: 'consecutive-large' },
|
||||
{ year: 2023, month: 5, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2023, month: 4, distribution: 'sparse', pattern: 'single-day' },
|
||||
{ year: 2023, month: 3, distribution: 'dense', pattern: 'start-heavy' },
|
||||
{ year: 2023, month: 2, distribution: 'medium', pattern: 'alternating' },
|
||||
{ year: 2023, month: 1, distribution: 'very-dense', pattern: 'consecutive-large' },
|
||||
];
|
||||
|
||||
for (let year = 2022; year >= 2000; year--) {
|
||||
for (let month = 12; month >= 1; month--) {
|
||||
months.push({ year, month, distribution: 'medium', pattern: 'sparse-scattered' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
months,
|
||||
seed: 42,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/ui/generators/timeline/timeline-config';
|
||||
|
||||
/**
|
||||
* Linear Congruential Generator for deterministic pseudo-random numbers
|
||||
*/
|
||||
export class SeededRandom {
|
||||
private seed: number;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next random number in range [0, 1)
|
||||
*/
|
||||
next(): number {
|
||||
// LCG parameters from Numerical Recipes
|
||||
this.seed = (this.seed * 1_664_525 + 1_013_904_223) % 2_147_483_647;
|
||||
return this.seed / 2_147_483_647;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random integer in range [min, max)
|
||||
*/
|
||||
nextInt(min: number, max: number): number {
|
||||
return Math.floor(this.next() * (max - min)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random boolean with given probability
|
||||
*/
|
||||
nextBoolean(probability = 0.5): boolean {
|
||||
return this.next() < probability;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select random days using seed variation to avoid collisions.
|
||||
*
|
||||
* @param daysInMonth - Total number of days in the month
|
||||
* @param numDays - Number of days to select
|
||||
* @param rng - Random number generator instance
|
||||
* @returns Array of selected day numbers, sorted in descending order
|
||||
*/
|
||||
export function selectRandomDays(daysInMonth: number, numDays: number, rng: SeededRandom): number[] {
|
||||
const selectedDays = new Set<number>();
|
||||
const maxAttempts = numDays * GENERATION_CONSTANTS.MAX_SELECT_ATTEMPTS; // Safety limit
|
||||
let attempts = 0;
|
||||
|
||||
while (selectedDays.size < numDays && attempts < maxAttempts) {
|
||||
const day = rng.nextInt(1, daysInMonth + 1);
|
||||
selectedDays.add(day);
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Fallback: if we couldn't select enough random days, fill with sequential days
|
||||
if (selectedDays.size < numDays) {
|
||||
for (let day = 1; day <= daysInMonth && selectedDays.size < numDays; day++) {
|
||||
selectedDays.add(day);
|
||||
}
|
||||
}
|
||||
|
||||
return [...selectedDays].toSorted((a, b) => b - a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select item from array using seeded random
|
||||
*/
|
||||
export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
|
||||
if (arr.length === 0) {
|
||||
throw new Error('Cannot select from empty array');
|
||||
}
|
||||
const index = rng.nextInt(0, arr.length);
|
||||
return arr[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select multiple random items from array using seeded random without duplicates
|
||||
*/
|
||||
export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRandom): T[] {
|
||||
if (arr.length === 0) {
|
||||
throw new Error('Cannot select from empty array');
|
||||
}
|
||||
if (count < 0) {
|
||||
throw new Error('Count must be non-negative');
|
||||
}
|
||||
if (count > arr.length) {
|
||||
throw new Error('Count cannot exceed array length');
|
||||
}
|
||||
|
||||
const result: T[] = [];
|
||||
const selectedIndices = new Set<number>();
|
||||
|
||||
while (result.length < count) {
|
||||
const index = rng.nextInt(0, arr.length);
|
||||
if (!selectedIndices.has(index)) {
|
||||
selectedIndices.add(index);
|
||||
result.push(arr[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse timeBucket parameter to extract year-month key
|
||||
* Handles both formats:
|
||||
* - ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
|
||||
* - Simple format: "2024-12-01" -> "2024-12-01"
|
||||
*/
|
||||
export function parseTimeBucketKey(timeBucket: string): string {
|
||||
if (!timeBucket) {
|
||||
throw new Error('timeBucket parameter cannot be empty');
|
||||
}
|
||||
|
||||
const dt = DateTime.fromISO(timeBucket, { zone: 'utc' });
|
||||
|
||||
if (!dt.isValid) {
|
||||
// Fallback to regex if not a valid ISO string
|
||||
const match = timeBucket.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
return match ? match[1] : timeBucket;
|
||||
}
|
||||
|
||||
// Format as YYYY-MM-01 (first day of month)
|
||||
return `${dt.year}-${String(dt.month).padStart(2, '0')}-01`;
|
||||
}
|
||||
|
||||
export function getMockAsset(
|
||||
asset: MockTimelineAsset,
|
||||
sortedDescendingAssets: MockTimelineAsset[],
|
||||
direction: 'next' | 'previous',
|
||||
unit: 'day' | 'month' | 'year' = 'day',
|
||||
): MockTimelineAsset | null {
|
||||
const currentDateTime = DateTime.fromISO(asset.localDateTime, { zone: 'utc' });
|
||||
|
||||
const currentIndex = sortedDescendingAssets.findIndex((a) => a.id === asset.id);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const step = direction === 'next' ? 1 : -1;
|
||||
const startIndex = currentIndex + step;
|
||||
|
||||
if (direction === 'next' && currentIndex >= sortedDescendingAssets.length - 1) {
|
||||
return null;
|
||||
}
|
||||
if (direction === 'previous' && currentIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
|
||||
if (unit === 'day') {
|
||||
return !date1.startOf('day').equals(date2.startOf('day'));
|
||||
} else if (unit === 'month') {
|
||||
return date1.year !== date2.year || date1.month !== date2.month;
|
||||
} else {
|
||||
return date1.year !== date2.year;
|
||||
}
|
||||
};
|
||||
|
||||
if (direction === 'next') {
|
||||
// Search forward in array (backwards in time)
|
||||
for (let i = startIndex; i < sortedDescendingAssets.length; i++) {
|
||||
const nextAsset = sortedDescendingAssets[i];
|
||||
const nextDate = DateTime.fromISO(nextAsset.localDateTime, { zone: 'utc' });
|
||||
|
||||
if (isInDifferentPeriod(nextDate, currentDateTime)) {
|
||||
return nextAsset;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Search backward in array (forwards in time)
|
||||
for (let i = startIndex; i >= 0; i--) {
|
||||
const prevAsset = sortedDescendingAssets[i];
|
||||
const prevDate = DateTime.fromISO(prevAsset.localDateTime, { zone: 'utc' });
|
||||
|
||||
if (isInDifferentPeriod(prevDate, currentDateTime)) {
|
||||
return prevAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { playwrightHost } from 'playwright.config';
|
||||
|
||||
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'immich_is_authenticated',
|
||||
value: 'true',
|
||||
domain: playwrightHost,
|
||||
path: '/',
|
||||
},
|
||||
]);
|
||||
await context.route('**/api/users/me', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
id: adminUserId,
|
||||
email: 'admin@immich.cloud',
|
||||
name: 'Immich Admin',
|
||||
profileImagePath: '',
|
||||
avatarColor: 'orange',
|
||||
profileChangedAt: '2025-01-22T21:31:23.996Z',
|
||||
storageLabel: 'admin',
|
||||
shouldChangePassword: true,
|
||||
isAdmin: true,
|
||||
createdAt: '2025-01-22T21:31:23.996Z',
|
||||
deletedAt: null,
|
||||
updatedAt: '2025-11-14T00:00:00.369Z',
|
||||
oauthId: '',
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 20_849_000_159,
|
||||
status: 'active',
|
||||
license: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
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: false,
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/server/about', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
version: 'v2.2.3',
|
||||
versionUrl: 'https://github.com/immich-app/immich/releases/tag/v2.2.3',
|
||||
licensed: false,
|
||||
build: '1234567890',
|
||||
buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890',
|
||||
buildImage: 'e2e',
|
||||
buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server',
|
||||
repository: 'immich-app/immich',
|
||||
repositoryUrl: 'https://github.com/immich-app/immich',
|
||||
sourceRef: 'e2e',
|
||||
sourceCommit: 'e2eeeeeeeeeeeeeeeeee',
|
||||
sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee',
|
||||
nodejs: 'v22.18.0',
|
||||
exiftool: '13.41',
|
||||
ffmpeg: '7.1.1-6',
|
||||
libvips: '8.17.2',
|
||||
imagemagick: '7.1.2-2',
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/features', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
smartSearch: false,
|
||||
facialRecognition: false,
|
||||
duplicateDetection: false,
|
||||
map: true,
|
||||
reverseGeocoding: true,
|
||||
importFaces: false,
|
||||
sidecar: true,
|
||||
search: true,
|
||||
trash: true,
|
||||
oauth: false,
|
||||
oauthAutoLaunch: false,
|
||||
ocr: false,
|
||||
passwordLogin: true,
|
||||
configFile: false,
|
||||
email: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/config', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
loginPageMessage: '',
|
||||
trashDays: 30,
|
||||
userDeleteDelay: 7,
|
||||
oauthButtonText: 'Login with OAuth',
|
||||
isInitialized: true,
|
||||
isOnboarded: true,
|
||||
externalDomain: '',
|
||||
publicUsers: true,
|
||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
maintenanceMode: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/media-types', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
video: [
|
||||
'.3gp',
|
||||
'.3gpp',
|
||||
'.avi',
|
||||
'.flv',
|
||||
'.insv',
|
||||
'.m2t',
|
||||
'.m2ts',
|
||||
'.m4v',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp4',
|
||||
'.mpe',
|
||||
'.mpeg',
|
||||
'.mpg',
|
||||
'.mts',
|
||||
'.vob',
|
||||
'.webm',
|
||||
'.wmv',
|
||||
],
|
||||
image: [
|
||||
'.3fr',
|
||||
'.ari',
|
||||
'.arw',
|
||||
'.cap',
|
||||
'.cin',
|
||||
'.cr2',
|
||||
'.cr3',
|
||||
'.crw',
|
||||
'.dcr',
|
||||
'.dng',
|
||||
'.erf',
|
||||
'.fff',
|
||||
'.iiq',
|
||||
'.k25',
|
||||
'.kdc',
|
||||
'.mrw',
|
||||
'.nef',
|
||||
'.nrw',
|
||||
'.orf',
|
||||
'.ori',
|
||||
'.pef',
|
||||
'.psd',
|
||||
'.raf',
|
||||
'.raw',
|
||||
'.rw2',
|
||||
'.rwl',
|
||||
'.sr2',
|
||||
'.srf',
|
||||
'.srw',
|
||||
'.x3f',
|
||||
'.avif',
|
||||
'.gif',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
'.png',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.heic',
|
||||
'.heif',
|
||||
'.hif',
|
||||
'.insp',
|
||||
'.jp2',
|
||||
'.jpe',
|
||||
'.jxl',
|
||||
'.svg',
|
||||
'.tif',
|
||||
'.tiff',
|
||||
],
|
||||
sidecar: ['.xmp'],
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/notifications*', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [],
|
||||
});
|
||||
});
|
||||
await context.route('**/api/albums*', async (route, request) => {
|
||||
if (request.url().endsWith('albums?shared=true') || request.url().endsWith('albums')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [],
|
||||
});
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
await context.route('**/api/memories*', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [],
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/storage', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
diskSize: '100.0 GiB',
|
||||
diskUse: '74.4 GiB',
|
||||
diskAvailable: '25.6 GiB',
|
||||
diskSizeRaw: 107_374_182_400,
|
||||
diskUseRaw: 79_891_660_800,
|
||||
diskAvailableRaw: 27_482_521_600,
|
||||
diskUsagePercentage: 74.4,
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/version-history', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [
|
||||
{
|
||||
id: 'd1fbeadc-cb4f-4db3-8d19-8c6a921d5d8e',
|
||||
createdAt: '2025-11-15T20:14:01.935Z',
|
||||
version: '2.2.3',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
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: null,
|
||||
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,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
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),
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { MemoryResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
|
||||
export type MemoryChanges = {
|
||||
memoryDeletions: string[];
|
||||
assetRemovals: Map<string, string[]>;
|
||||
};
|
||||
|
||||
export const setupMemoryMockApiRoutes = async (
|
||||
context: BrowserContext,
|
||||
memories: MemoryResponseDto[],
|
||||
changes: MemoryChanges,
|
||||
) => {
|
||||
await context.route('**/api/memories*', async (route, request) => {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname === '/api/memories' && request.method() === 'GET') {
|
||||
const activeMemories = memories
|
||||
.filter((memory) => !changes.memoryDeletions.includes(memory.id))
|
||||
.map((memory) => {
|
||||
const removedAssets = changes.assetRemovals.get(memory.id) ?? [];
|
||||
return {
|
||||
...memory,
|
||||
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
|
||||
};
|
||||
})
|
||||
.filter((memory) => memory.assets.length > 0);
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: activeMemories,
|
||||
});
|
||||
}
|
||||
|
||||
const memoryMatch = pathname.match(/\/api\/memories\/([^/]+)$/);
|
||||
if (memoryMatch && request.method() === 'GET') {
|
||||
const memoryId = memoryMatch[1];
|
||||
const memory = memories.find((m) => m.id === memoryId);
|
||||
|
||||
if (!memory || changes.memoryDeletions.includes(memoryId)) {
|
||||
return route.fulfill({ status: 404 });
|
||||
}
|
||||
|
||||
const removedAssets = changes.assetRemovals.get(memoryId) ?? [];
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
...memory,
|
||||
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (/\/api\/memories\/([^/]+)$/.test(pathname) && request.method() === 'DELETE') {
|
||||
const memoryId = pathname.split('/').pop()!;
|
||||
changes.memoryDeletions.push(memoryId);
|
||||
return route.fulfill({ status: 204 });
|
||||
}
|
||||
|
||||
await route.fallback();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { AssetResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext, Page, Request, Route } from '@playwright/test';
|
||||
import { basename } from 'node:path';
|
||||
import {
|
||||
Changes,
|
||||
getAlbum,
|
||||
getAsset,
|
||||
getTimeBucket,
|
||||
getTimeBuckets,
|
||||
randomPreview,
|
||||
randomThumbnail,
|
||||
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;
|
||||
adminId = '';
|
||||
}
|
||||
|
||||
export const setupTimelineMockApiRoutes = async (
|
||||
context: BrowserContext,
|
||||
timelineRestData: TimelineData,
|
||||
changes: Changes,
|
||||
testContext: TimelineTestContext,
|
||||
) => {
|
||||
await context.route('**/api/timeline**', async (route, request) => {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
if (pathname === '/api/timeline/buckets') {
|
||||
const albumId = url.searchParams.get('albumId') || undefined;
|
||||
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
|
||||
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
|
||||
const isArchived = url.searchParams.get('visibility')
|
||||
? url.searchParams.get('visibility') === 'archive'
|
||||
: undefined;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: getTimeBuckets(timelineRestData, isTrashed, isArchived, isFavorite, albumId, changes),
|
||||
});
|
||||
} else if (pathname === '/api/timeline/bucket') {
|
||||
const timeBucket = url.searchParams.get('timeBucket');
|
||||
if (!timeBucket) {
|
||||
return route.continue();
|
||||
}
|
||||
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
|
||||
const isArchived = url.searchParams.get('visibility')
|
||||
? url.searchParams.get('visibility') === 'archive'
|
||||
: undefined;
|
||||
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
|
||||
const albumId = url.searchParams.get('albumId') || undefined;
|
||||
const assets = getTimeBucket(timelineRestData, timeBucket, isTrashed, isArchived, isFavorite, albumId, changes);
|
||||
if (testContext.slowBucket) {
|
||||
await sleep(5000);
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: assets,
|
||||
});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
const assetId = basename(pathname);
|
||||
let asset = getAsset(timelineRestData, assetId);
|
||||
if (changes.assetDeletions.includes(asset!.id)) {
|
||||
asset = {
|
||||
...asset,
|
||||
isTrashed: true,
|
||||
} as AssetResponseDto;
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: asset,
|
||||
});
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets', async (route, request) => {
|
||||
if (request.method() === 'DELETE') {
|
||||
return route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/ocr', async (route) => {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', json: [] });
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||
const match = request.url().match(pattern);
|
||||
if (!match?.groups) {
|
||||
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
|
||||
}
|
||||
|
||||
if (match.groups.size === 'preview') {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const asset = getAsset(timelineRestData, match.groups.assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
|
||||
body: await randomPreview(
|
||||
match.groups.assetId,
|
||||
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (match.groups.size === 'thumbnail') {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const asset = getAsset(timelineRestData, match.groups.assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: await randomThumbnail(
|
||||
match.groups.assetId,
|
||||
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
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) {
|
||||
const album = getAlbum(timelineRestData, testContext.adminId, albumsMatch.groups?.albumId, changes);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: album,
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await context.route('**/api/albums**', async (route, request) => {
|
||||
const allAlbums = request.url().match(/\/api\/albums\?assetId=(?<assetId>[^&]+)/);
|
||||
if (allAlbums) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [],
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
};
|
||||
|
||||
export const pageRoutePromise = async (
|
||||
page: Page,
|
||||
route: string,
|
||||
callback: (route: Route, request: Request) => Promise<void>,
|
||||
) => {
|
||||
let resolveRequest: ((value: unknown | PromiseLike<unknown>) => void) | undefined;
|
||||
const deleteRequest = new Promise((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
await page.route(route, async (route, request) => {
|
||||
await callback(route, request);
|
||||
const requestJson = request.postDataJSON();
|
||||
resolveRequest?.(requestJson);
|
||||
});
|
||||
return deleteRequest;
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
SeededRandom,
|
||||
selectRandom,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
} 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';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('asset-viewer', () => {
|
||||
const rng = new SeededRandom(529);
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const yearMonths: string[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
for (const yearMonth of timelineRestData.buckets.keys()) {
|
||||
const [year, month] = yearMonth.split('-');
|
||||
yearMonths.push(`${year}-${Number(month)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
});
|
||||
|
||||
test.describe('/photos/:id', () => {
|
||||
test('Navigate to next asset via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to previous asset via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate forward 5 times via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Navigate backward 5 times via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Navigate forward then backward via keyboard', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
// Navigate forward 3 times
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
}
|
||||
|
||||
// Navigate backward 3 times to return to original
|
||||
for (let i = 2; i >= 0; i--) {
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
}
|
||||
|
||||
// Verify we're back at the original asset
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
});
|
||||
|
||||
test('Verify no next button on last asset', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${lastAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
|
||||
|
||||
// Verify next button doesn't exist
|
||||
await expect(page.getByLabel('View next asset')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Verify no previous button on first asset', async ({ page }) => {
|
||||
const firstAsset = assets[0];
|
||||
await page.goto(`/photos/${firstAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, firstAsset);
|
||||
|
||||
// Verify previous button doesn't exist
|
||||
await expect(page.getByLabel('View previous asset')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Delete photo advances to next', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
});
|
||||
test('Delete photo advances to next (2x)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
||||
});
|
||||
test('Delete last photo advances to prev', async ({ page }) => {
|
||||
const asset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
});
|
||||
test('Delete last photo advances to prev (2x)', async ({ page }) => {
|
||||
const asset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
|
||||
});
|
||||
});
|
||||
test.describe('/trash/photos/:id', () => {
|
||||
test('Delete trashed photo advances to next', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
});
|
||||
test('Delete trashed photo advances to next 2x', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
||||
});
|
||||
test('Delete trashed photo advances to prev', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
||||
});
|
||||
test('Delete trashed photo advances to prev 2x', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { MemoryResponseDto } from '@immich/sdk';
|
||||
import { test } from '@playwright/test';
|
||||
import { generateMemoriesFromTimeline } from 'src/ui/generators/memory';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { MemoryChanges, setupMemoryMockApiRoutes } from 'src/ui/mock-network/memory-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
import { memoryAssetViewerUtils, memoryGalleryUtils, memoryViewerUtils } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
let memories: MemoryResponseDto[];
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
const memoryChanges: MemoryChanges = {
|
||||
memoryDeletions: [],
|
||||
assetRemovals: new Map(),
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
|
||||
timelineRestData = generateTimelineData({
|
||||
...createDefaultTimelineConfig(),
|
||||
ownerId: adminUserId,
|
||||
});
|
||||
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
|
||||
memories = generateMemoriesFromTimeline(
|
||||
assets,
|
||||
adminUserId,
|
||||
[
|
||||
{ year: 2024, assetCount: 3 },
|
||||
{ year: 2023, assetCount: 2 },
|
||||
{ year: 2022, assetCount: 4 },
|
||||
],
|
||||
42,
|
||||
);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
memoryChanges.memoryDeletions = [];
|
||||
memoryChanges.assetRemovals.clear();
|
||||
});
|
||||
|
||||
test.describe('Asset viewer navigation from gallery', () => {
|
||||
test('shows both prev/next buttons for middle asset within a memory', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const middleAsset = firstMemory.assets[1];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, middleAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, middleAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, middleAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
|
||||
test('shows next button when at last asset of first memory (next memory exists)', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!;
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirstMemory.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirstMemory);
|
||||
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
});
|
||||
|
||||
test('shows prev button when at first asset of last memory (prev memory exists)', async ({ page }) => {
|
||||
const lastMemory = memories.at(-1)!;
|
||||
const firstAssetOfLastMemory = lastMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfLastMemory.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfLastMemory);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
|
||||
test('can navigate from last asset of memory to first asset of next memory', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
|
||||
await memoryAssetViewerUtils.clickNextButton(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
|
||||
await memoryAssetViewerUtils.expectCurrentAssetId(page, firstAssetOfSecond.id);
|
||||
});
|
||||
|
||||
test('can navigate from first asset of memory to last asset of previous memory', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
|
||||
await memoryAssetViewerUtils.clickPreviousButton(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
});
|
||||
|
||||
test('hides prev button at very first asset (first memory, first asset, no prev memory)', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const veryFirstAsset = firstMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, veryFirstAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, veryFirstAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonNotVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
|
||||
test('hides next button at very last asset (last memory, last asset, no next memory)', async ({ page }) => {
|
||||
const lastMemory = memories.at(-1)!;
|
||||
const veryLastAsset = lastMemory.assets.at(-1)!;
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, veryLastAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, veryLastAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectNextButtonNotVisible(page);
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Keyboard navigation', () => {
|
||||
test('ArrowLeft navigates to previous asset across memory boundary', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
});
|
||||
|
||||
test('ArrowRight navigates to next asset across memory boundary', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => {
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
let memories: MemoryResponseDto[];
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
const memoryChanges: MemoryChanges = {
|
||||
memoryDeletions: [],
|
||||
assetRemovals: new Map(),
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
|
||||
timelineRestData = generateTimelineData({
|
||||
...createDefaultTimelineConfig(),
|
||||
ownerId: adminUserId,
|
||||
});
|
||||
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
|
||||
memories = generateMemoriesFromTimeline(
|
||||
assets,
|
||||
adminUserId,
|
||||
[
|
||||
{ year: 2024, assetCount: 2 },
|
||||
{ year: 2023, assetCount: 1 },
|
||||
{ year: 2022, assetCount: 2 },
|
||||
],
|
||||
123,
|
||||
);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
memoryChanges.memoryDeletions = [];
|
||||
memoryChanges.assetRemovals.clear();
|
||||
});
|
||||
|
||||
test('single asset memory shows both prev/next when surrounded by other memories', async ({ page }) => {
|
||||
const singleAssetMemory = memories[1];
|
||||
const singleAsset = singleAssetMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, singleAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, singleAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, singleAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { expect, Page } from '@playwright/test';
|
||||
|
||||
function getAssetIdFromUrl(url: URL): string | null {
|
||||
const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/);
|
||||
if (pathMatch) {
|
||||
return pathMatch[1];
|
||||
}
|
||||
return url.searchParams.get('id');
|
||||
}
|
||||
|
||||
export const memoryViewerUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#memory-viewer');
|
||||
},
|
||||
|
||||
async waitForMemoryLoad(page: Page) {
|
||||
await expect(this.locator(page)).toBeVisible();
|
||||
await expect(page.locator('#memory-viewer img').first()).toBeVisible();
|
||||
},
|
||||
|
||||
async openMemoryPage(page: Page) {
|
||||
await page.goto('/memory');
|
||||
await this.waitForMemoryLoad(page);
|
||||
},
|
||||
|
||||
async openMemoryPageWithAsset(page: Page, assetId: string) {
|
||||
await page.goto(`/memory?id=${assetId}`);
|
||||
await this.waitForMemoryLoad(page);
|
||||
},
|
||||
};
|
||||
|
||||
export const memoryGalleryUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#gallery-memory');
|
||||
},
|
||||
|
||||
thumbnailWithAssetId(page: Page, assetId: string) {
|
||||
return page.locator(`#gallery-memory [data-thumbnail-focus-container][data-asset="${assetId}"]`);
|
||||
},
|
||||
|
||||
async scrollToGallery(page: Page) {
|
||||
const showGalleryButton = page.getByLabel('Show gallery');
|
||||
if (await showGalleryButton.isVisible()) {
|
||||
await showGalleryButton.click();
|
||||
}
|
||||
await expect(this.locator(page)).toBeInViewport();
|
||||
},
|
||||
|
||||
async clickThumbnail(page: Page, assetId: string) {
|
||||
await this.scrollToGallery(page);
|
||||
await this.thumbnailWithAssetId(page, assetId).click();
|
||||
},
|
||||
|
||||
async getAllThumbnails(page: Page) {
|
||||
await this.scrollToGallery(page);
|
||||
return page.locator('#gallery-memory [data-thumbnail-focus-container]');
|
||||
},
|
||||
};
|
||||
|
||||
export const memoryAssetViewerUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#immich-asset-viewer');
|
||||
},
|
||||
|
||||
async waitForViewerOpen(page: Page) {
|
||||
await expect(this.locator(page)).toBeVisible();
|
||||
},
|
||||
|
||||
async waitForAssetLoad(page: Page, asset: AssetResponseDto) {
|
||||
const viewer = this.locator(page);
|
||||
const imgLocator = viewer.locator(`img[draggable="false"][src*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
|
||||
const videoLocator = viewer.locator(`video[poster*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
|
||||
|
||||
await imgLocator.or(videoLocator).waitFor({ timeout: 10_000 });
|
||||
},
|
||||
|
||||
nextButton(page: Page) {
|
||||
return page.getByLabel('View next asset');
|
||||
},
|
||||
|
||||
previousButton(page: Page) {
|
||||
return page.getByLabel('View previous asset');
|
||||
},
|
||||
|
||||
async expectNextButtonVisible(page: Page) {
|
||||
await expect(this.nextButton(page)).toBeVisible();
|
||||
},
|
||||
|
||||
async expectNextButtonNotVisible(page: Page) {
|
||||
await expect(this.nextButton(page)).toHaveCount(0);
|
||||
},
|
||||
|
||||
async expectPreviousButtonVisible(page: Page) {
|
||||
await expect(this.previousButton(page)).toBeVisible();
|
||||
},
|
||||
|
||||
async expectPreviousButtonNotVisible(page: Page) {
|
||||
await expect(this.previousButton(page)).toHaveCount(0);
|
||||
},
|
||||
|
||||
async clickNextButton(page: Page) {
|
||||
await this.nextButton(page).click();
|
||||
},
|
||||
|
||||
async clickPreviousButton(page: Page) {
|
||||
await this.previousButton(page).click();
|
||||
},
|
||||
|
||||
async closeViewer(page: Page) {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(this.locator(page)).not.toBeVisible();
|
||||
},
|
||||
|
||||
getCurrentAssetId(page: Page): string | null {
|
||||
const url = new URL(page.url());
|
||||
return getAssetIdFromUrl(url);
|
||||
},
|
||||
|
||||
async expectCurrentAssetId(page: Page, expectedAssetId: string) {
|
||||
await expect.poll(() => this.getCurrentAssetId(page)).toBe(expectedAssetId);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
} 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 { assetViewerUtils } from '../timeline/utils';
|
||||
|
||||
const buildSearchUrl = (assetId: string) => {
|
||||
const searchQuery = encodeURIComponent(JSON.stringify({ originalFileName: 'test' }));
|
||||
return `/search/photos/${assetId}?query=${searchQuery}`;
|
||||
};
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('search gallery-viewer', () => {
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
|
||||
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));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
albums: { total: 0, count: 0, items: [], facets: [] },
|
||||
assets: {
|
||||
total: searchAssets.length,
|
||||
count: searchAssets.length,
|
||||
items: searchAssets,
|
||||
facets: [],
|
||||
nextPage: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
});
|
||||
|
||||
test.describe('/search/photos/:id', () => {
|
||||
test('Deleting a photo advances to the next photo', async ({ page }) => {
|
||||
const asset = assets[0];
|
||||
await page.goto(buildSearchUrl(asset.id));
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[1]);
|
||||
});
|
||||
|
||||
test('Deleting two photos in a row advances to the next photo each time', async ({ page }) => {
|
||||
const asset = assets[0];
|
||||
await page.goto(buildSearchUrl(asset.id));
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[2]);
|
||||
});
|
||||
|
||||
test('Navigating backward then deleting advances to the next photo', async ({ page }) => {
|
||||
const asset = assets[1];
|
||||
await page.goto(buildSearchUrl(asset.id));
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[0]);
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[2]);
|
||||
});
|
||||
|
||||
test('Deleting the last photo advances to the previous photo', async ({ page }) => {
|
||||
const lastAsset = assets[4];
|
||||
await page.goto(buildSearchUrl(lastAsset.id));
|
||||
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
|
||||
await expect(page.getByLabel('View next asset')).toHaveCount(0);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[3]);
|
||||
await expect(page.getByLabel('View previous asset')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,859 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DateTime } from 'luxon';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
getAsset,
|
||||
getMockAsset,
|
||||
SeededRandom,
|
||||
selectRandom,
|
||||
selectRandomMultiple,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import {
|
||||
pageRoutePromise,
|
||||
setupTimelineMockApiRoutes,
|
||||
TimelineTestContext,
|
||||
} from 'src/ui/mock-network/timeline-network';
|
||||
import { utils } from 'src/utils';
|
||||
import { assetViewerUtils, padYearMonth, pageUtils, poll, thumbnailUtils, timelineUtils } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('Timeline', () => {
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const yearMonths: string[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
|
||||
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();
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
for (const yearMonth of timelineRestData.buckets.keys()) {
|
||||
const [year, month] = yearMonth.split('-');
|
||||
yearMonths.push(`${year}-${Number(month)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
});
|
||||
|
||||
test.describe('/photos', () => {
|
||||
test('Open /photos', async ({ page }) => {
|
||||
await page.goto(`/photos`);
|
||||
await page.waitForSelector('#asset-grid');
|
||||
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
|
||||
});
|
||||
test('Deep link to last photo', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
|
||||
await thumbnailUtils.expectInViewport(page, lastAsset.id);
|
||||
});
|
||||
const rng = new SeededRandom(529);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
test('Deep link to random asset ' + i, async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await pageUtils.deepLinkPhotosPage(page, asset.id);
|
||||
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
|
||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||
});
|
||||
}
|
||||
test('Open /photos, open asset-viewer, browser back', async ({ page }) => {
|
||||
const rng = new SeededRandom(22);
|
||||
const asset = selectRandom(assets, rng);
|
||||
await pageUtils.deepLinkPhotosPage(page, asset.id);
|
||||
const scrollTopBefore = await timelineUtils.getScrollTop(page);
|
||||
await thumbnailUtils.clickAssetId(page, asset.id);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.goBack();
|
||||
await timelineUtils.locator(page).waitFor();
|
||||
const scrollTopAfter = await timelineUtils.getScrollTop(page);
|
||||
expect(scrollTopAfter).toBe(scrollTopBefore);
|
||||
});
|
||||
test('Open /photos, open asset-viewer, next photo, browser back, back', async ({ page }) => {
|
||||
const rng = new SeededRandom(49);
|
||||
const asset = selectRandom(assets, rng);
|
||||
const assetIndex = assets.indexOf(asset);
|
||||
const nextAsset = assets[assetIndex + 1];
|
||||
await pageUtils.deepLinkPhotosPage(page, asset.id);
|
||||
const scrollTopBefore = await timelineUtils.getScrollTop(page);
|
||||
await thumbnailUtils.clickAssetId(page, asset.id);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, nextAsset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${nextAsset.id}`);
|
||||
await page.goBack();
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.goBack();
|
||||
await page.waitForURL('**/photos?at=*');
|
||||
const scrollTopAfter = await timelineUtils.getScrollTop(page);
|
||||
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThan(5);
|
||||
});
|
||||
test('Open /photos, open asset-viewer, next photo 15x, backwardsArrow', async ({ page }) => {
|
||||
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
|
||||
await thumbnailUtils.clickAssetId(page, assets[0].id);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[0]);
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[i]);
|
||||
}
|
||||
await page.getByLabel('Go back').click();
|
||||
await page.waitForURL('**/photos?at=*');
|
||||
await thumbnailUtils.expectInViewport(page, assets[15].id);
|
||||
await thumbnailUtils.expectBottomIsTimelineBottom(page, assets[15]!.id);
|
||||
});
|
||||
test('Open /photos, open asset-viewer, previous photo 15x, backwardsArrow', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
await thumbnailUtils.clickAssetId(page, lastAsset.id);
|
||||
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets.at(-1 - i)!);
|
||||
}
|
||||
await page.getByLabel('Go back').click();
|
||||
await page.waitForURL('**/photos?at=*');
|
||||
await thumbnailUtils.expectInViewport(page, assets.at(-1 - 15)!.id);
|
||||
await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(-1 - 15)!.id);
|
||||
});
|
||||
});
|
||||
test.describe('keyboard', () => {
|
||||
/**
|
||||
* This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
|
||||
* scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling
|
||||
* as necessary downwards), then the asset should always be at the lowest row of the grid.
|
||||
*/
|
||||
test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
|
||||
const rightKey = 'ArrowRight';
|
||||
const leftKey = 'ArrowLeft';
|
||||
for (let i = 1; i < 15; i++) {
|
||||
await page.keyboard.press(rightKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
for (let i = 15; i <= 20; i++) {
|
||||
await page.keyboard.press(rightKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
expect(await thumbnailUtils.expectBottomIsTimelineBottom(page, assets.at(i)!.id));
|
||||
}
|
||||
// now test previous asset
|
||||
for (let i = 19; i >= 15; i--) {
|
||||
await page.keyboard.press(leftKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
for (let i = 14; i > 0; i--) {
|
||||
await page.keyboard.press(leftKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
expect(await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(i)!.id));
|
||||
}
|
||||
});
|
||||
test('Next/previous asset - Tab/Shift+Tab', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
|
||||
const rightKey = 'Tab';
|
||||
const leftKey = 'Shift+Tab';
|
||||
for (let i = 1; i < 15; i++) {
|
||||
await page.keyboard.press(rightKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
for (let i = 15; i <= 20; i++) {
|
||||
await page.keyboard.press(rightKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
// now test previous asset
|
||||
for (let i = 19; i >= 15; i--) {
|
||||
await page.keyboard.press(leftKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
for (let i = 14; i > 0; i--) {
|
||||
await page.keyboard.press(leftKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
});
|
||||
test('Next/previous day - d, Shift+D', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
let asset = assets[0];
|
||||
await timelineUtils.locator(page).hover();
|
||||
await page.keyboard.press('d');
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('d');
|
||||
const next = getMockAsset(asset, assets, 'next', 'day')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
|
||||
asset = next;
|
||||
}
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('Shift+D');
|
||||
const previous = getMockAsset(asset, assets, 'previous', 'day')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
|
||||
asset = previous;
|
||||
}
|
||||
});
|
||||
test('Next/previous month - m, Shift+M', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
let asset = assets[0];
|
||||
await timelineUtils.locator(page).hover();
|
||||
await page.keyboard.press('m');
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('m');
|
||||
const next = getMockAsset(asset, assets, 'next', 'month')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
|
||||
asset = next;
|
||||
}
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('Shift+M');
|
||||
const previous = getMockAsset(asset, assets, 'previous', 'month')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
|
||||
asset = previous;
|
||||
}
|
||||
});
|
||||
test('Next/previous year - y, Shift+Y', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
let asset = assets[0];
|
||||
await timelineUtils.locator(page).hover();
|
||||
await page.keyboard.press('y');
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('y');
|
||||
const next = getMockAsset(asset, assets, 'next', 'year')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
|
||||
asset = next;
|
||||
}
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('Shift+Y');
|
||||
const previous = getMockAsset(asset, assets, 'previous', 'year')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
|
||||
asset = previous;
|
||||
}
|
||||
});
|
||||
test('Navigate to time - g', async ({ page }) => {
|
||||
const rng = new SeededRandom(4782);
|
||||
await pageUtils.openPhotosPage(page);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
test.describe('selection', () => {
|
||||
test('Select day, unselect day', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
|
||||
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
|
||||
});
|
||||
test('Select asset, click asset to select', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
|
||||
await thumbnailUtils.selectButton(page, assets[1].id).click();
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
|
||||
// no need to hover, once selection is active
|
||||
await thumbnailUtils.clickAssetId(page, assets[2].id);
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(2);
|
||||
});
|
||||
test('Select asset, click unselect asset', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
|
||||
await thumbnailUtils.selectButton(page, assets[1].id).click();
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
|
||||
await thumbnailUtils.clickAssetId(page, assets[1].id);
|
||||
// the hover uses a checked button too, so just move mouse away
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
|
||||
});
|
||||
test('Select asset, shift-hover candidates, shift-click end', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const asset = assets[0];
|
||||
await thumbnailUtils.withAssetId(page, asset.id).hover();
|
||||
await thumbnailUtils.selectButton(page, asset.id).click();
|
||||
await page.keyboard.down('Shift');
|
||||
await thumbnailUtils.withAssetId(page, assets[2].id).hover();
|
||||
await expect(
|
||||
thumbnailUtils.locator(page).locator('.absolute.top-0.h-full.w-full.bg-immich-primary.opacity-40'),
|
||||
).toHaveCount(3);
|
||||
await thumbnailUtils.selectButton(page, assets[2].id).click();
|
||||
await page.keyboard.up('Shift');
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(3);
|
||||
});
|
||||
test('Add multiple to selection - Select day, shift-click end', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[0].id).hover();
|
||||
await thumbnailUtils.selectButton(page, assets[0].id).click();
|
||||
await thumbnailUtils.clickAssetId(page, assets[2].id);
|
||||
await page.keyboard.down('Shift');
|
||||
await thumbnailUtils.clickAssetId(page, assets[4].id);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
|
||||
});
|
||||
});
|
||||
test.describe('scroll', () => {
|
||||
test('Open /photos, random click scrubber 20x', async ({ page }) => {
|
||||
test.slow();
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const rng = new SeededRandom(6637);
|
||||
const selectedMonths = selectRandomMultiple(yearMonths, 20, rng);
|
||||
for (const month of selectedMonths) {
|
||||
await page.locator(`[data-segment-year-month="${month}"]`).click({ force: true });
|
||||
const visibleMockAssetsYearMonths = await poll(page, async () => {
|
||||
const assetIds = await thumbnailUtils.getAllInViewport(
|
||||
page,
|
||||
(assetId: string) => getYearMonth(assets, assetId) === month,
|
||||
);
|
||||
const visibleMockAssetsYearMonths: string[] = [];
|
||||
for (const assetId of assetIds!) {
|
||||
const yearMonth = getYearMonth(assets, assetId);
|
||||
visibleMockAssetsYearMonths.push(yearMonth);
|
||||
if (yearMonth === month) {
|
||||
return [yearMonth];
|
||||
}
|
||||
}
|
||||
});
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(visibleMockAssetsYearMonths).toContain(month);
|
||||
}
|
||||
});
|
||||
test('Deep link to last photo, scroll up', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
|
||||
await timelineUtils.locator(page).hover();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await page.mouse.wheel(0, -100);
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
|
||||
await thumbnailUtils.expectInViewport(page, '14e5901f-fd7f-40c0-b186-4d7e7fc67968');
|
||||
});
|
||||
test('Deep link to first bucket, scroll down', async ({ page }) => {
|
||||
const lastAsset = assets.at(0)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
await timelineUtils.locator(page).hover();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await page.mouse.wheel(0, 100);
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
await thumbnailUtils.expectInViewport(page, 'b7983a13-4b4e-4950-a731-f2962d9a1555');
|
||||
});
|
||||
test('Deep link to last photo, drag scrubber to scroll up', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
const lastMonth = yearMonths.at(-1);
|
||||
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
|
||||
const lastScrubSegment = page.locator(`[data-segment-year-month="${lastMonth}"]`);
|
||||
const sourcebox = (await lastScrubSegment.boundingBox())!;
|
||||
const targetBox = (await firstScrubSegment.boundingBox())!;
|
||||
await firstScrubSegment.hover();
|
||||
const currentY = sourcebox.y;
|
||||
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, targetBox.y, { steps: 100 });
|
||||
await page.mouse.up();
|
||||
await thumbnailUtils.expectInViewport(page, assets[0].id);
|
||||
});
|
||||
test('Deep link to first bucket, drag scrubber to scroll down', async ({ page }) => {
|
||||
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
|
||||
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
|
||||
const sourcebox = (await firstScrubSegment.boundingBox())!;
|
||||
await firstScrubSegment.hover();
|
||||
const currentY = sourcebox.y;
|
||||
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
|
||||
await page.mouse.down();
|
||||
const height = page.viewportSize()?.height;
|
||||
expect(height).toBeDefined();
|
||||
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, height! - 10, {
|
||||
steps: 100,
|
||||
});
|
||||
await page.mouse.up();
|
||||
await thumbnailUtils.expectInViewport(page, assets.at(-1)!.id);
|
||||
});
|
||||
test('Buckets cancel on scroll', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
testContext.slowBucket = true;
|
||||
const failedUris: string[] = [];
|
||||
page.on('requestfailed', (request) => {
|
||||
failedUris.push(request.url());
|
||||
});
|
||||
const offscreenSegment = page.locator(`[data-segment-year-month="${yearMonths[12]}"]`);
|
||||
await offscreenSegment.click({ force: true });
|
||||
const lastSegment = page.locator(`[data-segment-year-month="${yearMonths.at(-1)!}"]`);
|
||||
await lastSegment.click({ force: true });
|
||||
const uris = await poll(page, async () => (failedUris.length > 0 ? failedUris : null));
|
||||
expect(uris).toEqual(expect.arrayContaining([expect.stringContaining(padYearMonth(yearMonths[12]!))]));
|
||||
});
|
||||
});
|
||||
test.describe('/albums', () => {
|
||||
test('Open album', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, album.assetIds[0]);
|
||||
});
|
||||
test('Deep link to last photo', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
const lastAsset = album.assetIds.at(-1);
|
||||
await pageUtils.deepLinkAlbumPage(page, album.id, lastAsset!);
|
||||
await thumbnailUtils.expectInViewport(page, album.assetIds.at(-1)!);
|
||||
await thumbnailUtils.expectBottomIsTimelineBottom(page, album.assetIds.at(-1)!);
|
||||
});
|
||||
test('Add photos to album pre-selects existing', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await page.getByLabel('Add photos').click();
|
||||
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
|
||||
});
|
||||
test('Add photos to album', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await page.locator('nav button[aria-label="Add photos"]').click();
|
||||
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
|
||||
await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
|
||||
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: requestJson.ids.map((id: string) => ({ id, success: true })),
|
||||
});
|
||||
changes.albumAdditions.push(...requestJson.ids);
|
||||
});
|
||||
await page.getByText('Add assets').click();
|
||||
await expect(put).resolves.toEqual({
|
||||
ids: [
|
||||
'c077ea7b-cfa1-45e4-8554-f86c00ee5658',
|
||||
'040fd762-dbbc-486d-a51a-2d84115e6229',
|
||||
'86af0b5f-79d3-4f75-bab3-3b61f6c72b23',
|
||||
],
|
||||
});
|
||||
const addedAsset = getAsset(timelineRestData, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658')!;
|
||||
await pageUtils.goToAsset(page, addedAsset.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658');
|
||||
await thumbnailUtils.expectInViewport(page, '040fd762-dbbc-486d-a51a-2d84115e6229');
|
||||
await thumbnailUtils.expectInViewport(page, '86af0b5f-79d3-4f75-bab3-3b61f6c72b23');
|
||||
});
|
||||
});
|
||||
test.describe('/trash', () => {
|
||||
test('open /photos, trash photo, open /trash, restore', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const assetToTrash = assets[0];
|
||||
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
changes.assetDeletions.push(...requestJson.ids);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: requestJson.ids.map((id: string) => ({ id, success: true })),
|
||||
});
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Delete').click();
|
||||
await expect(deleteRequest).resolves.toEqual({
|
||||
force: false,
|
||||
ids: [assetToTrash.id],
|
||||
});
|
||||
await page.getByText('Trash', { exact: true }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: { count: requestJson.ids.length },
|
||||
});
|
||||
});
|
||||
await page.getByText('Restore', { exact: true }).click();
|
||||
await expect(restoreRequest).resolves.toEqual({
|
||||
ids: [assetToTrash.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
|
||||
await page.getByText('Photos', { exact: true }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||
});
|
||||
test('open album, trash photo, open /trash, restore', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
const assetToTrash = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
changes.assetDeletions.push(...requestJson.ids);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: requestJson.ids.map((id: string) => ({ id, success: true })),
|
||||
});
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Delete').click();
|
||||
await expect(deleteRequest).resolves.toEqual({
|
||||
force: false,
|
||||
ids: [assetToTrash.id],
|
||||
});
|
||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||
await page.getByText('Trash', { exact: true }).click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: { count: requestJson.ids.length },
|
||||
});
|
||||
});
|
||||
await page.getByText('Restore', { exact: true }).click();
|
||||
await expect(restoreRequest).resolves.toEqual({
|
||||
ids: [assetToTrash.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||
});
|
||||
});
|
||||
test.describe('/archive', () => {
|
||||
test('open /photos, archive photo, open /archive, unarchive', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const assetToArchive = assets[0];
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'archive') {
|
||||
return await route.continue();
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
changes.assetArchivals.push(...requestJson.ids);
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Archive').click();
|
||||
await expect(archive).resolves.toEqual({
|
||||
visibility: 'archive',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'timeline') {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Unarchive').click();
|
||||
await expect(unarchiveRequest).resolves.toEqual({
|
||||
visibility: 'timeline',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await page.getByText('Photos', { exact: true }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
});
|
||||
test('open /archive, favorite photo, unfavorite', async ({ page }) => {
|
||||
const assetToFavorite = assets[0];
|
||||
changes.assetArchivals.push(assetToFavorite.id);
|
||||
await pageUtils.openArchivePage(page);
|
||||
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
const isFavorite = requestJson.isFavorite;
|
||||
if (isFavorite) {
|
||||
changes.assetFavorites.push(...requestJson.ids);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
await page.getByLabel('Favorite').click();
|
||||
await expect(favorite).resolves.toEqual({
|
||||
isFavorite: true,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Remove from favorites').click();
|
||||
await expect(unFavoriteRequest).resolves.toEqual({
|
||||
isFavorite: false,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
|
||||
await thumbnailUtils.expectThumbnailIsNotFavorite(page, assetToFavorite.id);
|
||||
});
|
||||
test('open album, archive photo, open album, unarchive', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
const assetToArchive = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'archive') {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetArchivals.push(...requestJson.ids);
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Archive').click();
|
||||
await expect(archive).resolves.toEqual({
|
||||
visibility: 'archive',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
|
||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'timeline') {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Unarchive').click();
|
||||
await expect(unarchiveRequest).resolves.toEqual({
|
||||
visibility: 'timeline',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
});
|
||||
});
|
||||
test.describe('/favorite', () => {
|
||||
test('open /photos, favorite photo, open /favorites, remove favorite, open /photos', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const assetToFavorite = assets[0];
|
||||
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
const isFavorite = requestJson.isFavorite;
|
||||
if (isFavorite) {
|
||||
changes.assetFavorites.push(...requestJson.ids);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Favorite').click();
|
||||
await expect(favorite).resolves.toEqual({
|
||||
isFavorite: true,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
// ensure thumbnail still exists and has favorite icon
|
||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||
await page.getByRole('link').getByText('Favorites').click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Remove from favorites').click();
|
||||
await expect(unFavoriteRequest).resolves.toEqual({
|
||||
isFavorite: false,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(0);
|
||||
await page.getByText('Photos', { exact: true }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
});
|
||||
test('open /favorites, archive photo, unarchive photo', async ({ page }) => {
|
||||
await pageUtils.openFavorites(page);
|
||||
const assetToArchive = getAsset(timelineRestData, 'ad31e29f-2069-4574-b9a9-ad86523c92cb')!;
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'archive') {
|
||||
return await route.continue();
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
changes.assetArchivals.push(...requestJson.ids);
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Archive').click();
|
||||
await expect(archive).resolves.toEqual({
|
||||
visibility: 'archive',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'timeline') {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Unarchive').click();
|
||||
await expect(unarchiveRequest).resolves.toEqual({
|
||||
visibility: 'timeline',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
|
||||
});
|
||||
test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
const assetToFavorite = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
const isFavorite = requestJson.isFavorite;
|
||||
if (isFavorite) {
|
||||
changes.assetFavorites.push(...requestJson.ids);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Favorite').click();
|
||||
await expect(favorite).resolves.toEqual({
|
||||
isFavorite: true,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
// ensure thumbnail still exists and has favorite icon
|
||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||
await page.getByRole('link').getByText('Favorites').click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Remove from favorites').click();
|
||||
await expect(unFavoriteRequest).resolves.toEqual({
|
||||
isFavorite: false,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(0);
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const getYearMonth = (assets: TimelineAssetConfig[], assetId: string) => {
|
||||
const mockAsset = assets.find((mockAsset) => mockAsset.id === assetId)!;
|
||||
const dateTime = DateTime.fromISO(mockAsset.fileCreatedAt!);
|
||||
return dateTime.year + '-' + dateTime.month;
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import { BrowserContext, expect, Page } from '@playwright/test';
|
||||
import { DateTime } from 'luxon';
|
||||
import { TimelineAssetConfig } from 'src/ui/generators/timeline';
|
||||
|
||||
export const sleep = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
export const padYearMonth = (yearMonth: string) => {
|
||||
const [year, month] = yearMonth.split('-');
|
||||
return `${year}-${month.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export async function throttlePage(context: BrowserContext, page: Page) {
|
||||
const session = await context.newCDPSession(page);
|
||||
await session.send('Network.emulateNetworkConditions', {
|
||||
offline: false,
|
||||
downloadThroughput: (1.5 * 1024 * 1024) / 8,
|
||||
uploadThroughput: (750 * 1024) / 8,
|
||||
latency: 40,
|
||||
connectionType: 'cellular3g',
|
||||
});
|
||||
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
|
||||
}
|
||||
|
||||
export const poll = async <T>(
|
||||
page: Page,
|
||||
query: () => Promise<T>,
|
||||
callback?: (result: Awaited<T> | undefined) => boolean,
|
||||
) => {
|
||||
let result;
|
||||
const timeout = Date.now() + 10_000;
|
||||
|
||||
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
|
||||
while (!terminate(result) && Date.now() < timeout) {
|
||||
try {
|
||||
result = await query();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await page.waitForTimeout(50);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!result) {
|
||||
// rerun to trigger error if any
|
||||
result = await query();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const thumbnailUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('[data-thumbnail-focus-container]');
|
||||
},
|
||||
withAssetId(page: Page, assetId: string) {
|
||||
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`);
|
||||
},
|
||||
selectedAsset(page: Page) {
|
||||
return page.locator('[data-thumbnail-focus-container][data-selected]');
|
||||
},
|
||||
async clickAssetId(page: Page, assetId: string) {
|
||||
await thumbnailUtils.withAssetId(page, assetId).click();
|
||||
},
|
||||
async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) {
|
||||
const assetIds: string[] = [];
|
||||
for (const thumb of await this.locator(page).all()) {
|
||||
const box = await thumb.boundingBox();
|
||||
if (box) {
|
||||
const assetId = await thumb.evaluate((e) => e.dataset.asset);
|
||||
if (collector?.(assetId!)) {
|
||||
return [assetId!];
|
||||
}
|
||||
assetIds.push(assetId!);
|
||||
}
|
||||
}
|
||||
return assetIds;
|
||||
},
|
||||
async getFirstInViewport(page: Page) {
|
||||
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, () => true));
|
||||
},
|
||||
async getAllInViewport(page: Page, collector: (assetId: string) => boolean) {
|
||||
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
|
||||
},
|
||||
async expectThumbnailIsFavorite(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(1);
|
||||
},
|
||||
async expectThumbnailIsNotFavorite(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(0);
|
||||
},
|
||||
async expectThumbnailIsArchive(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(1);
|
||||
},
|
||||
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
||||
},
|
||||
async expectSelectedDisabled(page: Page, assetId: string) {
|
||||
await expect(
|
||||
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected][data-disabled]`),
|
||||
).toBeVisible();
|
||||
},
|
||||
async expectTimelineHasOnScreenAssets(page: Page) {
|
||||
const first = await thumbnailUtils.getFirstInViewport(page);
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(first).toBeTruthy();
|
||||
},
|
||||
async expectInViewport(page: Page, assetId: string) {
|
||||
const box = await poll(page, () => thumbnailUtils.withAssetId(page, assetId).boundingBox());
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(box).toBeTruthy();
|
||||
},
|
||||
async expectBottomIsTimelineBottom(page: Page, assetId: string) {
|
||||
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
|
||||
const gridBox = await timelineUtils.locator(page).boundingBox();
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(box!.y + box!.height).toBeCloseTo(gridBox!.y + gridBox!.height, 0);
|
||||
},
|
||||
async expectTopIsTimelineTop(page: Page, assetId: string) {
|
||||
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
|
||||
const gridBox = await timelineUtils.locator(page).boundingBox();
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(box!.y).toBeCloseTo(gridBox!.y, 0);
|
||||
},
|
||||
};
|
||||
export const timelineUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#asset-grid');
|
||||
},
|
||||
async waitForTimelineLoad(page: Page) {
|
||||
await expect(timelineUtils.locator(page)).toBeInViewport();
|
||||
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
|
||||
},
|
||||
async getScrollTop(page: Page) {
|
||||
const queryTop = () =>
|
||||
page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return document.querySelector('#asset-grid').scrollTop;
|
||||
});
|
||||
await expect.poll(queryTop).toBeGreaterThan(0);
|
||||
return await queryTop();
|
||||
},
|
||||
};
|
||||
|
||||
export const assetViewerUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#immich-asset-viewer');
|
||||
},
|
||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||
await page
|
||||
.locator(
|
||||
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
|
||||
)
|
||||
.or(
|
||||
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
|
||||
)
|
||||
.waitFor();
|
||||
},
|
||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||
const activeElement = () =>
|
||||
page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return document.activeElement?.dataset?.asset;
|
||||
});
|
||||
await expect(poll(page, activeElement, (result) => result === assetId)).resolves.toBe(assetId);
|
||||
},
|
||||
};
|
||||
export const pageUtils = {
|
||||
async deepLinkPhotosPage(page: Page, assetId: string) {
|
||||
await page.goto(`/photos?at=${assetId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openPhotosPage(page: Page) {
|
||||
await page.goto(`/photos`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openFavorites(page: Page) {
|
||||
await page.goto(`/favorites`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openAlbumPage(page: Page, albumId: string) {
|
||||
await page.goto(`/albums/${albumId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openArchivePage(page: Page) {
|
||||
await page.goto(`/archive`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
|
||||
await page.goto(`/albums/${albumId}?at=${assetId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async goToAsset(page: Page, assetDate: string) {
|
||||
await timelineUtils.locator(page).hover();
|
||||
const stringDate = DateTime.fromISO(assetDate).toFormat('MMddyyyy,hh:mm:ss.SSSa');
|
||||
await page.keyboard.press('g');
|
||||
await page.locator('#datetime').pressSequentially(stringDate);
|
||||
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();
|
||||
},
|
||||
async pauseTestDebug() {
|
||||
console.log('NOTE: pausing test indefinately for debug');
|
||||
await new Promise(() => void 0);
|
||||
},
|
||||
};
|
||||
+189
-50
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AllJobStatusResponseDto,
|
||||
AssetMediaCreateDto,
|
||||
AssetMediaResponseDto,
|
||||
AssetResponseDto,
|
||||
@@ -7,11 +6,15 @@ import {
|
||||
CheckExistingAssetsDto,
|
||||
CreateAlbumDto,
|
||||
CreateLibraryDto,
|
||||
JobCommandDto,
|
||||
JobName,
|
||||
JobCreateDto,
|
||||
MaintenanceAction,
|
||||
ManualJobName,
|
||||
MetadataSearchDto,
|
||||
Permission,
|
||||
PersonCreateDto,
|
||||
QueueCommandDto,
|
||||
QueueName,
|
||||
QueuesResponseLegacyDto,
|
||||
SharedLinkCreateDto,
|
||||
UpdateLibraryDto,
|
||||
UserAdminCreateDto,
|
||||
@@ -20,6 +23,7 @@ import {
|
||||
checkExistingAssets,
|
||||
createAlbum,
|
||||
createApiKey,
|
||||
createJob,
|
||||
createLibrary,
|
||||
createPartner,
|
||||
createPerson,
|
||||
@@ -27,15 +31,18 @@ import {
|
||||
createStack,
|
||||
createUserAdmin,
|
||||
deleteAssets,
|
||||
getAllJobsStatus,
|
||||
deleteDatabaseBackup,
|
||||
getAssetInfo,
|
||||
getConfig,
|
||||
getConfigDefaults,
|
||||
getQueuesLegacy,
|
||||
listDatabaseBackups,
|
||||
login,
|
||||
runQueueCommandLegacy,
|
||||
scanLibrary,
|
||||
searchAssets,
|
||||
sendJobCommand,
|
||||
setBaseUrl,
|
||||
setMaintenanceMode,
|
||||
signUpAdmin,
|
||||
tagAssets,
|
||||
updateAdminOnboarding,
|
||||
@@ -50,16 +57,22 @@ import {
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { exec, spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path, { dirname } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import { createGzip } from 'node:zlib';
|
||||
import pg from 'pg';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { loginDto, signupDto } from 'src/fixtures';
|
||||
import { makeRandomImage } from 'src/generators';
|
||||
import request from 'supertest';
|
||||
import { playwrightDbHost, playwrightHost, playwriteBaseUrl } from '../playwright.config';
|
||||
|
||||
export type { Emitter } from '@socket.io/component-emitter';
|
||||
|
||||
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
|
||||
@@ -68,20 +81,21 @@ type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: nu
|
||||
type AdminSetupOptions = { onboarding?: boolean };
|
||||
type FileData = { bytes?: Buffer; filename: string };
|
||||
|
||||
const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5435/immich';
|
||||
export const baseUrl = 'http://127.0.0.1:2285';
|
||||
const dbUrl = `postgres://postgres:postgres@${playwrightDbHost}:5435/immich`;
|
||||
export const baseUrl = playwriteBaseUrl;
|
||||
export const shareUrl = `${baseUrl}/share`;
|
||||
export const app = `${baseUrl}/api`;
|
||||
// TODO move test assets into e2e/assets
|
||||
export const testAssetDir = path.resolve('./test-assets');
|
||||
export const testAssetDir = resolve(import.meta.dirname, '../test-assets');
|
||||
export const testAssetDirInternal = '/test-assets';
|
||||
export const tempDir = tmpdir();
|
||||
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
|
||||
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
||||
export const immichCli = (args: string[]) =>
|
||||
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
|
||||
export const immichAdmin = (args: string[]) =>
|
||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
|
||||
export const dockerExec = (args: string[]) =>
|
||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', args.join(' ')]);
|
||||
export const immichAdmin = (args: string[]) => dockerExec([`immich-admin ${args.join(' ')}`]);
|
||||
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
|
||||
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
|
||||
@@ -145,44 +159,69 @@ const onEvent = ({ event, id }: { event: EventType; id: string }) => {
|
||||
};
|
||||
|
||||
export const utils = {
|
||||
connectDatabase: async () => {
|
||||
if (!client) {
|
||||
client = new pg.Client(dbUrl);
|
||||
client.on('end', () => (client = null));
|
||||
client.on('error', () => (client = null));
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
return client;
|
||||
},
|
||||
|
||||
disconnectDatabase: async () => {
|
||||
if (client) {
|
||||
await client.end();
|
||||
}
|
||||
},
|
||||
|
||||
resetDatabase: async (tables?: string[]) => {
|
||||
try {
|
||||
if (!client) {
|
||||
client = new pg.Client(dbUrl);
|
||||
await client.connect();
|
||||
}
|
||||
client = await utils.connectDatabase();
|
||||
|
||||
tables = tables || [
|
||||
// TODO e2e test for deleting a stack, since it is quite complex
|
||||
'stack',
|
||||
'library',
|
||||
'shared_link',
|
||||
'person',
|
||||
'album',
|
||||
'asset',
|
||||
'asset_face',
|
||||
'activity',
|
||||
'api_key',
|
||||
'session',
|
||||
'user',
|
||||
'system_metadata',
|
||||
'tag',
|
||||
];
|
||||
tables = tables || [
|
||||
// TODO e2e test for deleting a stack, since it is quite complex
|
||||
'stack',
|
||||
'library',
|
||||
'shared_link',
|
||||
'person',
|
||||
'album',
|
||||
'asset',
|
||||
'asset_face',
|
||||
'activity',
|
||||
'api_key',
|
||||
'session',
|
||||
'user',
|
||||
'system_metadata',
|
||||
'tag',
|
||||
];
|
||||
|
||||
const sql: string[] = [];
|
||||
const truncateTables = tables.filter((table) => table !== 'system_metadata');
|
||||
const sql: string[] = [];
|
||||
|
||||
for (const table of tables) {
|
||||
if (table === 'system_metadata') {
|
||||
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
|
||||
} else {
|
||||
sql.push(`DELETE FROM "${table}" CASCADE;`);
|
||||
if (truncateTables.length > 0) {
|
||||
sql.push(`TRUNCATE "${truncateTables.join('", "')}" CASCADE;`);
|
||||
}
|
||||
|
||||
if (tables.includes('system_metadata')) {
|
||||
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
|
||||
}
|
||||
|
||||
const query = sql.join('\n');
|
||||
const maxRetries = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await client.query(query);
|
||||
return;
|
||||
} catch (error: any) {
|
||||
if (error?.code === '40P01' && attempt < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
|
||||
continue;
|
||||
}
|
||||
console.error('Failed to reset database', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await client.query(sql.join('\n'));
|
||||
} catch (error) {
|
||||
console.error('Failed to reset database', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -471,16 +510,22 @@ export const utils = {
|
||||
createStack: (accessToken: string, assetIds: string[]) =>
|
||||
createStack({ stackCreateDto: { assetIds } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
setAssetDuplicateId: (accessToken: string, assetId: string, duplicateId: string | null) =>
|
||||
updateAssets({ assetBulkUpdateDto: { ids: [assetId], duplicateId } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
upsertTags: (accessToken: string, tags: string[]) =>
|
||||
upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
|
||||
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
jobCommand: async (accessToken: string, jobName: JobName, jobCommandDto: JobCommandDto) =>
|
||||
sendJobCommand({ id: jobName, jobCommandDto }, { headers: asBearerAuth(accessToken) }),
|
||||
createJob: async (accessToken: string, jobCreateDto: JobCreateDto) =>
|
||||
createJob({ jobCreateDto }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
|
||||
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
|
||||
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = playwrightHost) =>
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'immich_access_token',
|
||||
@@ -514,23 +559,98 @@ export const utils = {
|
||||
},
|
||||
]),
|
||||
|
||||
setMaintenanceAuthCookie: async (context: BrowserContext, token: string, domain = '127.0.0.1') =>
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'immich_maintenance_token',
|
||||
value: token,
|
||||
domain,
|
||||
path: '/',
|
||||
expires: 2_058_028_213,
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
]),
|
||||
|
||||
enterMaintenance: async (accessToken: string) => {
|
||||
let setCookie: string[] | undefined;
|
||||
|
||||
await setMaintenanceMode(
|
||||
{
|
||||
setMaintenanceModeDto: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: asBearerAuth(accessToken),
|
||||
fetch: (...args: Parameters<typeof fetch>) =>
|
||||
fetch(...args).then((response) => {
|
||||
setCookie = response.headers.getSetCookie();
|
||||
return response;
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return setCookie;
|
||||
},
|
||||
|
||||
resetTempFolder: () => {
|
||||
rmSync(`${testAssetDir}/temp`, { recursive: true, force: true });
|
||||
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
||||
},
|
||||
|
||||
async move(source: string, dest: string) {
|
||||
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mv', source, dest]).promise;
|
||||
},
|
||||
|
||||
createBackup: async (accessToken: string) => {
|
||||
await utils.createJob(accessToken, {
|
||||
name: ManualJobName.BackupDatabase,
|
||||
});
|
||||
|
||||
return utils.poll(
|
||||
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
|
||||
({ status, body }) => status === 200 && body.backups.length === 1,
|
||||
({ body }) => body.backups[0].filename,
|
||||
);
|
||||
},
|
||||
|
||||
resetBackups: async (accessToken: string) => {
|
||||
const { backups } = await listDatabaseBackups({ headers: asBearerAuth(accessToken) });
|
||||
|
||||
const backupFiles = backups.map((b) => b.filename);
|
||||
await deleteDatabaseBackup(
|
||||
{ databaseBackupDeleteDto: { backups: backupFiles } },
|
||||
{ headers: asBearerAuth(accessToken) },
|
||||
);
|
||||
},
|
||||
|
||||
prepareTestBackup: async (generate: 'empty' | 'corrupted') => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'test-'));
|
||||
const fn = join(dir, 'file');
|
||||
|
||||
const sql = Readable.from(generate === 'corrupted' ? 'IM CORRUPTED;' : 'SELECT 1;');
|
||||
const gzip = createGzip();
|
||||
const writeStream = createWriteStream(fn);
|
||||
await pipeline(sql, gzip, writeStream);
|
||||
|
||||
await executeCommand('docker', ['cp', fn, `immich-e2e-server:/data/backups/development-${generate}.sql.gz`])
|
||||
.promise;
|
||||
},
|
||||
|
||||
resetAdminConfig: async (accessToken: string) => {
|
||||
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
|
||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||
},
|
||||
|
||||
isQueueEmpty: async (accessToken: string, queue: keyof AllJobStatusResponseDto) => {
|
||||
const queues = await getAllJobsStatus({ headers: asBearerAuth(accessToken) });
|
||||
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
|
||||
const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) });
|
||||
const jobCounts = queues[queue].jobCounts;
|
||||
return !jobCounts.active && !jobCounts.waiting;
|
||||
},
|
||||
|
||||
waitForQueueFinish: (accessToken: string, queue: keyof AllJobStatusResponseDto, ms?: number) => {
|
||||
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);
|
||||
@@ -562,6 +682,25 @@ export const utils = {
|
||||
await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
|
||||
},
|
||||
|
||||
async poll<T>(cb: () => Promise<T>, validate: (value: T) => boolean, map?: (value: T) => any) {
|
||||
let timeout = 0;
|
||||
while (true) {
|
||||
try {
|
||||
const data = await cb();
|
||||
if (validate(data)) {
|
||||
return map ? map(data) : data;
|
||||
}
|
||||
timeout++;
|
||||
if (timeout >= 10) {
|
||||
throw 'Could not clean up test.';
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5e2));
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
downloadAsset: async (accessToken: string, id: string) => {
|
||||
const downloadedRes = await fetch(`${baseUrl}/api/assets/${id}/original`, {
|
||||
headers: asBearerAuth(accessToken),
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe('Album', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test(`doesn't delete album after canceling add assets`, async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/albums');
|
||||
await page.getByRole('button', { name: 'Create album' }).click();
|
||||
await page.getByRole('button', { name: 'Select photos' }).click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: 'Select photos' }).waitFor();
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
function imageLocator(page: Page) {
|
||||
return page.getByAltText('Image taken on').locator('visible=true');
|
||||
}
|
||||
test.describe('Photo Viewer', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
let rawAsset: AssetMediaResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
asset = await utils.createAsset(admin.accessToken);
|
||||
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
// before each test, login as user
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('loads original photo when zoomed', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
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 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}`);
|
||||
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 expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
|
||||
});
|
||||
|
||||
test('reloads photo when checksum changes', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
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);
|
||||
});
|
||||
});
|
||||
+2
-3
@@ -9,15 +9,14 @@
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"target": "es2022",
|
||||
"target": "es2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"rootDirs": ["src"],
|
||||
"baseUrl": "./"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "vitest*.config.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
+17
-12
@@ -1,23 +1,28 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// skip `docker compose up` if `make e2e` was already run
|
||||
const globalSetup: string[] = ['src/setup/auth-server.ts'];
|
||||
try {
|
||||
await fetch('http://127.0.0.1:2285/api/server-info/ping');
|
||||
} catch {
|
||||
globalSetup.push('src/setup/docker-compose.ts');
|
||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||
|
||||
// skip `docker compose up` if `make e2e` was already run or if VITEST_DISABLE_DOCKER_SETUP is set
|
||||
const globalSetup: string[] = [];
|
||||
if (!skipDockerSetup) {
|
||||
try {
|
||||
await fetch('http://127.0.0.1:2285/api/server/ping');
|
||||
} catch {
|
||||
globalSetup.push('src/docker-compose.ts');
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/{api,cli,immich-admin}/specs/*.e2e-spec.ts'],
|
||||
name: 'e2e:server',
|
||||
retry: process.env.CI ? 4 : 0,
|
||||
include: ['src/specs/server/**/*.e2e-spec.ts'],
|
||||
globalSetup,
|
||||
testTimeout: 15_000,
|
||||
pool: 'threads',
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
maxWorkers: 1,
|
||||
isolate: false,
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||
|
||||
// skip `docker compose up` if `make e2e` was already run or if VITEST_DISABLE_DOCKER_SETUP is set
|
||||
const globalSetup: string[] = [];
|
||||
if (!skipDockerSetup) {
|
||||
try {
|
||||
await fetch('http://127.0.0.1:2285/api/server/ping');
|
||||
} catch {
|
||||
globalSetup.push('src/docker-compose.ts');
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
name: 'e2e:maintenance',
|
||||
retry: process.env.CI ? 4 : 0,
|
||||
include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'],
|
||||
globalSetup,
|
||||
testTimeout: 15_000,
|
||||
pool: 'threads',
|
||||
maxWorkers: 1,
|
||||
isolate: false,
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
});
|
||||
Reference in New Issue
Block a user