From d9fd52ea188979dc3b412455899f946f22f65ea8 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Tue, 18 Nov 2025 22:08:55 -0500 Subject: [PATCH] feat: timeline e2e tests (#23895) * feat: timeline e2e tests * Fix flakiness, mock all apis, allow parallel tests * Upload playwright reports * wrong report path * Add CI=true, disable flaky/failing tests * Re-enable tests, fix worker thread config * fix maintance e2e test * increase retries --- .github/workflows/test.yml | 8 + e2e/.gitignore | 1 + e2e/package.json | 2 + e2e/playwright.config.ts | 43 +- e2e/src/generators/timeline.ts | 37 + .../timeline/distribution-patterns.ts | 183 +++++ e2e/src/generators/timeline/images.ts | 111 +++ e2e/src/generators/timeline/model-objects.ts | 265 ++++++ e2e/src/generators/timeline/rest-response.ts | 436 ++++++++++ .../generators/timeline/timeline-config.ts | 200 +++++ e2e/src/generators/timeline/utils.ts | 186 +++++ e2e/src/mock-network/base-network.ts | 285 +++++++ e2e/src/mock-network/timeline-network.ts | 139 ++++ e2e/src/utils.ts | 12 +- e2e/src/web/specs/maintenance.e2e-spec.ts | 13 +- .../timeline/timeline.parallel-e2e-spec.ts | 776 ++++++++++++++++++ e2e/src/web/specs/timeline/utils.ts | 234 ++++++ pnpm-lock.yaml | 6 + 18 files changed, 2919 insertions(+), 18 deletions(-) create mode 100644 e2e/src/generators/timeline.ts create mode 100644 e2e/src/generators/timeline/distribution-patterns.ts create mode 100644 e2e/src/generators/timeline/images.ts create mode 100644 e2e/src/generators/timeline/model-objects.ts create mode 100644 e2e/src/generators/timeline/rest-response.ts create mode 100644 e2e/src/generators/timeline/timeline-config.ts create mode 100644 e2e/src/generators/timeline/utils.ts create mode 100644 e2e/src/mock-network/base-network.ts create mode 100644 e2e/src/mock-network/timeline-network.ts create mode 100644 e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts create mode 100644 e2e/src/web/specs/timeline/utils.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 44d7250f2f..40ffc48de4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -500,8 +500,16 @@ jobs: run: docker compose build if: ${{ !cancelled() }} - name: Run e2e tests (web) + env: + CI: true run: npx playwright test if: ${{ !cancelled() }} + - name: Archive test results + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + if: success() || failure() + with: + name: e2e-web-test-results-${{ matrix.runner }} + path: e2e/playwright-report/ success-check-e2e: name: End-to-End Tests Success needs: [e2e-tests-server-cli, e2e-tests-web] diff --git a/e2e/.gitignore b/e2e/.gitignore index bbc06c5549..00b1601f07 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -4,3 +4,4 @@ node_modules/ /blob-report/ /playwright/.cache/ /dist +.env diff --git a/e2e/package.json b/e2e/package.json index 9ea02161e4..b1d2cef0aa 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -20,6 +20,7 @@ "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/js": "^9.8.0", + "@faker-js/faker": "^10.1.0", "@immich/cli": "file:../cli", "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", @@ -30,6 +31,7 @@ "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", + "dotenv": "^17.2.3", "eslint": "^9.14.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.1.3", diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 2576a2c5c9..4ae542bacf 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,23 +1,50 @@ -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({ +dotenv.config({ 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 = 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/web/specs', 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', use: { ...devices['Desktop Chrome'] }, + testMatch: /.*\.e2e-spec\.ts/, + workers: 1, + }, + { + name: 'parallel tests', + use: { ...devices['Desktop Chrome'] }, + testMatch: /.*\.parallel-e2e-spec\.ts/, + fullyParallel: true, + workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1), }, // { @@ -59,4 +86,8 @@ export default defineConfig({ stderr: 'pipe', reuseExistingServer: true, }, -}); +}; +if (playwrightDisableWebserver) { + delete config.webServer; +} +export default defineConfig(config); diff --git a/e2e/src/generators/timeline.ts b/e2e/src/generators/timeline.ts new file mode 100644 index 0000000000..d4c91d667f --- /dev/null +++ b/e2e/src/generators/timeline.ts @@ -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'; diff --git a/e2e/src/generators/timeline/distribution-patterns.ts b/e2e/src/generators/timeline/distribution-patterns.ts new file mode 100644 index 0000000000..ae621fd9c5 --- /dev/null +++ b/e2e/src/generators/timeline/distribution-patterns.ts @@ -0,0 +1,183 @@ +import { generateConsecutiveDays, generateDayAssets } from 'src/generators/timeline/model-objects'; +import { SeededRandom, selectRandomDays } from 'src/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 = { + 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 = { + '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 diff --git a/e2e/src/generators/timeline/images.ts b/e2e/src/generators/timeline/images.ts new file mode 100644 index 0000000000..69ec576714 --- /dev/null +++ b/e2e/src/generators/timeline/images.ts @@ -0,0 +1,111 @@ +import sharp from 'sharp'; +import { SeededRandom } from 'src/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 = ` + + `; + break; + } + + case 1: { + // Horizontal stripes + const stripeHeight = 10; + svgPattern = ` + ${Array.from( + { length: height / stripeHeight }, + (_, i) => + ``, + ).join('')} + `; + break; + } + + case 2: { + // Vertical stripes + const stripeWidth = 10; + svgPattern = ` + ${Array.from( + { length: width / stripeWidth }, + (_, i) => + ``, + ).join('')} + `; + break; + } + + case 3: { + // Checkerboard + const squareSize = 10; + svgPattern = ` + ${Array.from({ length: height / squareSize }, (_, row) => + Array.from({ length: width / squareSize }, (_, col) => { + const isEven = (row + col) % 2 === 0; + return ``; + }).join(''), + ).join('')} + `; + break; + } + + case 4: { + // Diagonal stripes + svgPattern = ` + + + + + + + + `; + break; + } + } + + const svgBuffer = Buffer.from(svgPattern); + const jpegData = await sharp(svgBuffer).jpeg({ quality: 50 }).toBuffer(); + return jpegData; +}; diff --git a/e2e/src/generators/timeline/model-objects.ts b/e2e/src/generators/timeline/model-objects.ts new file mode 100644 index 0000000000..f06596fd1a --- /dev/null +++ b/e2e/src/generators/timeline/model-objects.ts @@ -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/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(); + const monthStats: Record = {}; + + 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(); + + 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 }; +} diff --git a/e2e/src/generators/timeline/rest-response.ts b/e2e/src/generators/timeline/rest-response.ts new file mode 100644 index 0000000000..6fcfe52fc2 --- /dev/null +++ b/e2e/src/generators/timeline/rest-response.ts @@ -0,0 +1,436 @@ +/** + * 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/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, + archivedAssetIds: Set, + favoritedAssetIds: Set, +): 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, + }; +} + +/** + * 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; +} diff --git a/e2e/src/generators/timeline/timeline-config.ts b/e2e/src/generators/timeline/timeline-config.ts new file mode 100644 index 0000000000..8dbe8399b1 --- /dev/null +++ b/e2e/src/generators/timeline/timeline-config.ts @@ -0,0 +1,200 @@ +import type { AssetVisibility } from '@immich/sdk'; +import { DayPattern, MonthDistribution } from 'src/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; + album: MockAlbum; // Mock album created from random assets +}; + +export type SerializedTimelineData = { + buckets: Record; + 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(); + + 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, + }; +} diff --git a/e2e/src/generators/timeline/utils.ts b/e2e/src/generators/timeline/utils.ts new file mode 100644 index 0000000000..a0b7fbf175 --- /dev/null +++ b/e2e/src/generators/timeline/utils.ts @@ -0,0 +1,186 @@ +import { DateTime } from 'luxon'; +import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/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(); + 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].sort((a, b) => b - a); +} + +/** + * Select item from array using seeded random + */ +export function selectRandom(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(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(); + + 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; +} diff --git a/e2e/src/mock-network/base-network.ts b/e2e/src/mock-network/base-network.ts new file mode 100644 index 0000000000..f23202ca77 --- /dev/null +++ b/e2e/src/mock-network/base-network.ts @@ -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', + }, + ], + }); + }); +}; diff --git a/e2e/src/mock-network/timeline-network.ts b/e2e/src/mock-network/timeline-network.ts new file mode 100644 index 0000000000..012defe4ab --- /dev/null +++ b/e2e/src/mock-network/timeline-network.ts @@ -0,0 +1,139 @@ +import { BrowserContext, Page, Request, Route } from '@playwright/test'; +import { basename } from 'node:path'; +import { + Changes, + getAlbum, + getAsset, + getTimeBucket, + getTimeBuckets, + randomPreview, + randomThumbnail, + TimelineData, +} from 'src/generators/timeline'; +import { sleep } from 'src/web/specs/timeline/utils'; + +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) => { + const pattern = /\/api\/assets\/(?[^/]+)\/thumbnail\?size=(?preview|thumbnail)/; + const match = request.url().match(pattern); + if (!match) { + const url = new URL(request.url()); + const pathname = url.pathname; + const assetId = basename(pathname); + const asset = getAsset(timelineRestData, assetId); + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: asset, + }); + } + 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/albums/**', async (route, request) => { + const pattern = /\/api\/albums\/(?[^/?]+)/; + const match = request.url().match(pattern); + if (!match) { + return route.continue(); + } + const album = getAlbum(timelineRestData, testContext.adminId, match.groups?.albumId, changes); + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: album, + }); + }); +}; + +export const pageRoutePromise = async ( + page: Page, + route: string, + callback: (route: Route, request: Request) => Promise, +) => { + let resolveRequest: ((value: unknown | PromiseLike) => 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; +}; diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index e3e7381466..f045ea2efd 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -54,7 +54,7 @@ import { exec, spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import path, { dirname } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { setTimeout as setAsyncTimeout } from 'node:timers/promises'; import { promisify } from 'node:util'; import pg from 'pg'; @@ -62,6 +62,8 @@ 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 }; @@ -70,12 +72,12 @@ 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}` }); @@ -482,7 +484,7 @@ export const utils = { queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) => runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }), - setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') => + setAuthCookies: async (context: BrowserContext, accessToken: string, domain = playwrightHost) => await context.addCookies([ { name: 'immich_access_token', diff --git a/e2e/src/web/specs/maintenance.e2e-spec.ts b/e2e/src/web/specs/maintenance.e2e-spec.ts index e8ee33b021..534c05f783 100644 --- a/e2e/src/web/specs/maintenance.e2e-spec.ts +++ b/e2e/src/web/specs/maintenance.e2e-spec.ts @@ -19,10 +19,9 @@ test.describe('Maintenance', () => { await page.goto('/admin/system-settings?isOpen=maintenance'); await page.getByRole('button', { name: 'Start maintenance mode' }).click(); - await page.waitForURL(`/maintenance?${new URLSearchParams({ continue: '/admin/system-settings' })}`); - await expect(page.getByText('Temporarily Unavailable')).toBeVisible(); + await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 }); await page.getByRole('button', { name: 'End maintenance mode' }).click(); - await page.waitForURL('/admin/system-settings'); + await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 }); }); test('maintenance shows no options to users until they authenticate', async ({ page }) => { @@ -35,10 +34,10 @@ test.describe('Maintenance', () => { await expect(async () => { await page.goto('/'); - await page.waitForURL('/maintenance?**', { - timeout: 1e3, + await page.waitForURL('**/maintenance?**', { + timeout: 1000, }); - }).toPass({ timeout: 1e4 }); + }).toPass({ timeout: 10_000 }); await expect(page.getByText('Temporarily Unavailable')).toBeVisible(); await expect(page.getByRole('button', { name: 'End maintenance mode' })).toHaveCount(0); @@ -47,6 +46,6 @@ test.describe('Maintenance', () => { 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'); + await page.waitForURL('**/auth/login'); }); }); diff --git a/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts b/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts new file mode 100644 index 0000000000..e9f29f3413 --- /dev/null +++ b/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts @@ -0,0 +1,776 @@ +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/generators/timeline'; +import { setupBaseMockApiRoutes } from 'src/mock-network/base-network'; +import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network'; +import { utils } from 'src/utils'; +import { + assetViewerUtils, + cancelAllPollers, + padYearMonth, + pageUtils, + poll, + thumbnailUtils, + timelineUtils, +} from 'src/web/specs/timeline/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(() => { + cancelAllPollers(); + 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.expectSelectedReadonly(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.expectSelectedReadonly(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('Done').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], + }); + console.log('Skipping assertion - TODO - fix bug with not removing asset from timeline-manager after unarchive'); + // await expect(thumbnail.withAssetId(page, assetToArchive.id)).toHaveCount(0); + await page.getByText('Photos', { exact: true }).click(); + await thumbnailUtils.expectInViewport(page, assetToArchive.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], + }); + console.log('Skipping assertion - TODO - fix that archiving in album doesnt add icon'); + // await thumbnail.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], + }); + console.log('Skipping assertion - TODO - fix bug with not removing asset from timeline-manager after unarchive'); + // await expect(thumbnail.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 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; +}; diff --git a/e2e/src/web/specs/timeline/utils.ts b/e2e/src/web/specs/timeline/utils.ts new file mode 100644 index 0000000000..8d9e784d8f --- /dev/null +++ b/e2e/src/web/specs/timeline/utils.ts @@ -0,0 +1,234 @@ +import { BrowserContext, expect, Page } from '@playwright/test'; +import { DateTime } from 'luxon'; +import { TimelineAssetConfig } from 'src/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 }); +} + +let activePollsAbortController = new AbortController(); + +export const cancelAllPollers = () => { + activePollsAbortController.abort(); + activePollsAbortController = new AbortController(); +}; + +export const poll = async ( + page: Page, + query: () => Promise, + callback?: (result: Awaited | undefined) => boolean, +) => { + let result; + const timeout = Date.now() + 10_000; + const signal = activePollsAbortController.signal; + + const terminate = callback || ((result: Awaited | undefined) => !!result); + while (!terminate(result) && Date.now() < timeout) { + if (signal.aborted) { + return; + } + try { + result = await query(); + } catch { + // ignore + } + if (signal.aborted) { + return; + } + 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]:has(button[aria-checked])'); + }, + 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( + 'path[d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"]', + ), + ).toHaveCount(1); + }, + async expectThumbnailIsArchive(page: Page, assetId: string) { + await expect( + thumbnailUtils + .withAssetId(page, assetId) + .locator('path[d="M20 21H4V10H6V19H18V10H20V21M3 3H21V9H3V3M5 5V7H19V5M10.5 11V14H8L12 18L16 14H13.5V11"]'), + ).toHaveCount(1); + }, + async expectSelectedReadonly(page: Page, assetId: string) { + // todo - need a data attribute for selected + await expect( + page.locator( + `[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`, + ), + ).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}"]`) + .or(page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`)) + .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 openAlbumPage(page: Page, albumId: string) { + await page.goto(`/albums/${albumId}`); + 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) { + await page.getByTitle(day).hover(); + await page.locator('[data-group] .w-8').click(); + }, + async pauseTestDebug() { + console.log('NOTE: pausing test indefinately for debug'); + await new Promise(() => void 0); + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20bc1de6ce..4a00b6755a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,9 @@ importers: '@eslint/js': specifier: ^9.8.0 version: 9.38.0 + '@faker-js/faker': + specifier: ^10.1.0 + version: 10.1.0 '@immich/cli': specifier: file:../cli version: link:../cli @@ -225,6 +228,9 @@ importers: '@types/supertest': specifier: ^6.0.2 version: 6.0.3 + dotenv: + specifier: ^17.2.3 + version: 17.2.3 eslint: specifier: ^9.14.0 version: 9.38.0(jiti@2.6.1)