mirror of
https://github.com/immich-app/immich.git
synced 2025-11-25 07:45:17 -05:00
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
This commit is contained in:
parent
2a281e7906
commit
d9fd52ea18
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@ -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]
|
||||
|
||||
1
e2e/.gitignore
vendored
1
e2e/.gitignore
vendored
@ -4,3 +4,4 @@ node_modules/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/dist
|
||||
.env
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
37
e2e/src/generators/timeline.ts
Normal file
37
e2e/src/generators/timeline.ts
Normal file
@ -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';
|
||||
183
e2e/src/generators/timeline/distribution-patterns.ts
Normal file
183
e2e/src/generators/timeline/distribution-patterns.ts
Normal file
@ -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<MonthDistribution, AssetDistributionStrategy | null> = {
|
||||
empty: null, // Special case - handled separately
|
||||
sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets
|
||||
medium: (rng) => rng.nextInt(15, 31), // 15-30 assets
|
||||
dense: (rng) => rng.nextInt(50, 81), // 50-80 assets
|
||||
'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets
|
||||
};
|
||||
|
||||
/**
|
||||
* Strategies for distributing assets across days within a month
|
||||
*/
|
||||
export const DAY_DISTRIBUTION: Record<DayPattern, DayDistributionStrategy> = {
|
||||
'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// All assets on one day in the middle of the month
|
||||
const day = Math.floor(daysInMonth / 2);
|
||||
return generateDayAssets(year, month, day, totalAssets, ownerId, rng);
|
||||
},
|
||||
|
||||
'consecutive-large': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// 3-5 consecutive days with evenly distributed assets
|
||||
const numDays = Math.min(5, Math.floor(totalAssets / 15));
|
||||
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
|
||||
return generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
|
||||
},
|
||||
|
||||
'consecutive-small': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Multiple consecutive days with 1-3 assets each (side-by-side layout)
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const numDays = Math.min(totalAssets, Math.floor(daysInMonth / 2));
|
||||
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
|
||||
let assetIndex = 0;
|
||||
|
||||
for (let i = 0; i < numDays && assetIndex < totalAssets; i++) {
|
||||
const dayAssets = Math.min(3, rng.nextInt(1, 4));
|
||||
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
|
||||
// Create a new RNG for this day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, startDay + i, actualAssets, ownerId, dayRng));
|
||||
assetIndex += actualAssets;
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
alternating: (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Alternate between large (15-25) and small (1-3) days
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
let day = 1;
|
||||
let isLarge = true;
|
||||
let assetIndex = 0;
|
||||
|
||||
while (assetIndex < totalAssets && day <= daysInMonth) {
|
||||
const dayAssets = isLarge ? Math.min(25, rng.nextInt(15, 26)) : rng.nextInt(1, 4);
|
||||
|
||||
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
|
||||
// Create a new RNG for this day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, day, actualAssets, ownerId, dayRng));
|
||||
assetIndex += actualAssets;
|
||||
|
||||
day += isLarge ? 1 : 1; // Could add gaps here
|
||||
isLarge = !isLarge;
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
'sparse-scattered': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Spread assets across random days with gaps
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const numDays = Math.min(totalAssets, Math.floor(daysInMonth * GENERATION_CONSTANTS.SPARSE_DAY_COVERAGE));
|
||||
const daysWithPhotos = selectRandomDays(daysInMonth, numDays, rng);
|
||||
let assetIndex = 0;
|
||||
|
||||
for (let i = 0; i < daysWithPhotos.length && assetIndex < totalAssets; i++) {
|
||||
const dayAssets =
|
||||
Math.floor(totalAssets / numDays) + (i === daysWithPhotos.length - 1 ? totalAssets % numDays : 0);
|
||||
// Create a new RNG for this day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, daysWithPhotos[i], dayAssets, ownerId, dayRng));
|
||||
assetIndex += dayAssets;
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
'start-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Most assets in first week
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const firstWeekAssets = Math.floor(totalAssets * 0.7);
|
||||
const remainingAssets = totalAssets - firstWeekAssets;
|
||||
|
||||
// First 7 days
|
||||
assets.push(...generateConsecutiveDays(year, month, 1, 7, firstWeekAssets, ownerId, rng));
|
||||
|
||||
// Remaining scattered
|
||||
if (remainingAssets > 0) {
|
||||
const midDay = Math.floor(daysInMonth / 2);
|
||||
// Create a new RNG for the remaining assets
|
||||
const remainingRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, midDay, remainingAssets, ownerId, remainingRng));
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
'end-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Most assets in last week
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const lastWeekAssets = Math.floor(totalAssets * 0.7);
|
||||
const remainingAssets = totalAssets - lastWeekAssets;
|
||||
|
||||
// Remaining at start
|
||||
if (remainingAssets > 0) {
|
||||
// Create a new RNG for the start assets
|
||||
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, 2, remainingAssets, ownerId, startRng));
|
||||
}
|
||||
|
||||
// Last 7 days
|
||||
const startDay = daysInMonth - 6;
|
||||
assets.push(...generateConsecutiveDays(year, month, startDay, 7, lastWeekAssets, ownerId, rng));
|
||||
return assets;
|
||||
},
|
||||
|
||||
'mid-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Most assets in middle of month
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const midAssets = Math.floor(totalAssets * 0.7);
|
||||
const sideAssets = Math.floor((totalAssets - midAssets) / 2);
|
||||
|
||||
// Start
|
||||
if (sideAssets > 0) {
|
||||
// Create a new RNG for the start assets
|
||||
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, 2, sideAssets, ownerId, startRng));
|
||||
}
|
||||
|
||||
// Middle
|
||||
const midStart = Math.floor(daysInMonth / 2) - 3;
|
||||
assets.push(...generateConsecutiveDays(year, month, midStart, 7, midAssets, ownerId, rng));
|
||||
|
||||
// End
|
||||
const endAssets = totalAssets - midAssets - sideAssets;
|
||||
if (endAssets > 0) {
|
||||
// Create a new RNG for the end assets
|
||||
const endRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, daysInMonth - 1, endAssets, ownerId, endRng));
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
};
|
||||
export type MonthDistribution =
|
||||
| 'empty' // 0 assets
|
||||
| 'sparse' // 3-8 assets
|
||||
| 'medium' // 15-30 assets
|
||||
| 'dense' // 50-80 assets
|
||||
| 'very-dense'; // 80-150 assets
|
||||
|
||||
export type DayPattern =
|
||||
| 'single-day' // All images in one day
|
||||
| 'consecutive-large' // Multiple days with 15-25 images each
|
||||
| 'consecutive-small' // Multiple days with 1-3 images each (side-by-side)
|
||||
| 'alternating' // Alternating large/small days
|
||||
| 'sparse-scattered' // Few images scattered across month
|
||||
| 'start-heavy' // Most images at start of month
|
||||
| 'end-heavy' // Most images at end of month
|
||||
| 'mid-heavy'; // Most images in middle of month
|
||||
111
e2e/src/generators/timeline/images.ts
Normal file
111
e2e/src/generators/timeline/images.ts
Normal file
@ -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 = `<svg width="${width}" height="${height}">
|
||||
<rect x="0" y="0" width="${width}" height="${height}" fill="rgb(${r1},${g1},${b1})"/>
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 1: {
|
||||
// Horizontal stripes
|
||||
const stripeHeight = 10;
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
${Array.from(
|
||||
{ length: height / stripeHeight },
|
||||
(_, i) =>
|
||||
`<rect x="0" y="${i * stripeHeight}" width="${width}" height="${stripeHeight}"
|
||||
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
|
||||
).join('')}
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 2: {
|
||||
// Vertical stripes
|
||||
const stripeWidth = 10;
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
${Array.from(
|
||||
{ length: width / stripeWidth },
|
||||
(_, i) =>
|
||||
`<rect x="${i * stripeWidth}" y="0" width="${stripeWidth}" height="${height}"
|
||||
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
|
||||
).join('')}
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 3: {
|
||||
// Checkerboard
|
||||
const squareSize = 10;
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
${Array.from({ length: height / squareSize }, (_, row) =>
|
||||
Array.from({ length: width / squareSize }, (_, col) => {
|
||||
const isEven = (row + col) % 2 === 0;
|
||||
return `<rect x="${col * squareSize}" y="${row * squareSize}"
|
||||
width="${squareSize}" height="${squareSize}"
|
||||
fill="rgb(${isEven ? r1 : r2},${isEven ? g1 : g2},${isEven ? b1 : b2})"/>`;
|
||||
}).join(''),
|
||||
).join('')}
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 4: {
|
||||
// Diagonal stripes
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
<defs>
|
||||
<pattern id="diagonal" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<rect x="0" y="0" width="10" height="20" fill="rgb(${r1},${g1},${b1})"/>
|
||||
<rect x="10" y="0" width="10" height="20" fill="rgb(${r2},${g2},${b2})"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="${width}" height="${height}" fill="url(#diagonal)" transform="rotate(45 50 50)"/>
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const svgBuffer = Buffer.from(svgPattern);
|
||||
const jpegData = await sharp(svgBuffer).jpeg({ quality: 50 }).toBuffer();
|
||||
return jpegData;
|
||||
};
|
||||
265
e2e/src/generators/timeline/model-objects.ts
Normal file
265
e2e/src/generators/timeline/model-objects.ts
Normal file
@ -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<string, MockTimelineAsset[]>();
|
||||
const monthStats: Record<string, { count: number; distribution: MonthDistribution; pattern: DayPattern }> = {};
|
||||
|
||||
const globalRng = new SeededRandom(config.seed || GENERATION_CONSTANTS.DEFAULT_SEED);
|
||||
faker.seed(globalRng.nextInt(0, 1_000_000));
|
||||
for (const monthConfig of config.months) {
|
||||
const { year, month, distribution, pattern } = monthConfig;
|
||||
|
||||
const monthSeed = globalRng.nextInt(0, 1_000_000);
|
||||
const monthRng = new SeededRandom(monthSeed);
|
||||
|
||||
const monthAssets = generateMonthAssets(
|
||||
year,
|
||||
month,
|
||||
config.ownerId || generateUUID(),
|
||||
distribution,
|
||||
pattern,
|
||||
monthRng,
|
||||
);
|
||||
|
||||
if (monthAssets.length > 0) {
|
||||
const monthKey = `${year}-${month.toString().padStart(2, '0')}`;
|
||||
monthStats[monthKey] = {
|
||||
count: monthAssets.length,
|
||||
distribution,
|
||||
pattern,
|
||||
};
|
||||
|
||||
// Create bucket key (YYYY-MM-01)
|
||||
const bucketKey = `${year}-${month.toString().padStart(2, '0')}-01`;
|
||||
buckets.set(bucketKey, monthAssets);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a mock album from random assets
|
||||
const allAssets = [...buckets.values()].flat();
|
||||
|
||||
// Select 10-30 random assets for the album (or all assets if less than 10)
|
||||
const albumSize = Math.min(allAssets.length, globalRng.nextInt(10, 31));
|
||||
const selectedAssetConfigs: MockTimelineAsset[] = [];
|
||||
const usedIndices = new Set<number>();
|
||||
|
||||
while (selectedAssetConfigs.length < albumSize && usedIndices.size < allAssets.length) {
|
||||
const randomIndex = globalRng.nextInt(0, allAssets.length);
|
||||
if (!usedIndices.has(randomIndex)) {
|
||||
usedIndices.add(randomIndex);
|
||||
selectedAssetConfigs.push(allAssets[randomIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort selected assets by date (newest first)
|
||||
selectedAssetConfigs.sort(
|
||||
(a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds,
|
||||
);
|
||||
|
||||
const selectedAssets = selectedAssetConfigs.map((asset) => asset.id);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const album = {
|
||||
id: generateUUID(),
|
||||
albumName: 'Test Album',
|
||||
description: 'A mock album for testing',
|
||||
assetIds: selectedAssets,
|
||||
thumbnailAssetId: selectedAssets.length > 0 ? selectedAssets[0] : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Write to file if configured
|
||||
if (config.writeToFile) {
|
||||
const outputPath = config.outputPath || '/tmp/timeline-data.json';
|
||||
|
||||
// Convert Map to object for serialization
|
||||
const serializedData: SerializedTimelineData = {
|
||||
buckets: Object.fromEntries(buckets),
|
||||
album,
|
||||
};
|
||||
|
||||
try {
|
||||
writeFileSync(outputPath, JSON.stringify(serializedData, null, 2));
|
||||
console.log(`Timeline data written to ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to write timeline data to ${outputPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { buckets, album };
|
||||
}
|
||||
436
e2e/src/generators/timeline/rest-response.ts
Normal file
436
e2e/src/generators/timeline/rest-response.ts
Normal file
@ -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<string>,
|
||||
archivedAssetIds: Set<string>,
|
||||
favoritedAssetIds: Set<string>,
|
||||
): boolean {
|
||||
// Determine actual status (property or in changes)
|
||||
const actuallyTrashed = asset.isTrashed || deletedAssetIds.has(asset.id);
|
||||
const actuallyArchived = asset.visibility === 'archive' || archivedAssetIds.has(asset.id);
|
||||
const actuallyFavorited = asset.isFavorite || favoritedAssetIds.has(asset.id);
|
||||
|
||||
// Apply filters
|
||||
if (isTrashed !== undefined && actuallyTrashed !== isTrashed) {
|
||||
return false;
|
||||
}
|
||||
if (isArchived !== undefined && actuallyArchived !== isArchived) {
|
||||
return false;
|
||||
}
|
||||
if (isFavorite !== undefined && actuallyFavorited !== isFavorite) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Get summary for all buckets (mimics getTimeBuckets API)
|
||||
* When albumId is provided, only includes buckets that contain assets from that album
|
||||
*/
|
||||
export function getTimeBuckets(
|
||||
timelineData: MockTimelineData,
|
||||
isTrashed: boolean | undefined,
|
||||
isArchived: boolean | undefined,
|
||||
isFavorite: boolean | undefined,
|
||||
albumId: string | undefined,
|
||||
changes: Changes,
|
||||
): TimeBucketsResponseDto[] {
|
||||
const summary: TimeBucketsResponseDto[] = [];
|
||||
|
||||
// Create sets for quick lookups
|
||||
const deletedAssetIds = new Set(changes.assetDeletions);
|
||||
const archivedAssetIds = new Set(changes.assetArchivals);
|
||||
const favoritedAssetIds = new Set(changes.assetFavorites);
|
||||
|
||||
// If no albumId is specified, return summary for all assets
|
||||
if (albumId) {
|
||||
// Filter to only include buckets with assets from the specified album
|
||||
const album = timelineData.album;
|
||||
if (!album || album.id !== albumId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create a Set for faster lookup
|
||||
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
|
||||
for (const removed of changes.assetDeletions) {
|
||||
albumAssetIds.delete(removed);
|
||||
}
|
||||
for (const [bucketKey, assets] of timelineData.buckets) {
|
||||
// Count how many assets in this bucket are in the album and match trashed/archived filters
|
||||
const albumAssetsInBucket = assets.filter((asset) => {
|
||||
// Must be in the album
|
||||
if (!albumAssetIds.has(asset.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return shouldIncludeAsset(
|
||||
asset,
|
||||
isTrashed,
|
||||
isArchived,
|
||||
isFavorite,
|
||||
deletedAssetIds,
|
||||
archivedAssetIds,
|
||||
favoritedAssetIds,
|
||||
);
|
||||
});
|
||||
|
||||
if (albumAssetsInBucket.length > 0) {
|
||||
summary.push({
|
||||
timeBucket: bucketKey,
|
||||
count: albumAssetsInBucket.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const [bucketKey, assets] of timelineData.buckets) {
|
||||
// Filter assets based on trashed/archived status
|
||||
const filteredAssets = assets.filter((asset) =>
|
||||
shouldIncludeAsset(
|
||||
asset,
|
||||
isTrashed,
|
||||
isArchived,
|
||||
isFavorite,
|
||||
deletedAssetIds,
|
||||
archivedAssetIds,
|
||||
favoritedAssetIds,
|
||||
),
|
||||
);
|
||||
|
||||
if (filteredAssets.length > 0) {
|
||||
summary.push({
|
||||
timeBucket: bucketKey,
|
||||
count: filteredAssets.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort summary by date (newest first) using luxon
|
||||
summary.sort((a, b) => {
|
||||
const dateA = DateTime.fromISO(a.timeBucket);
|
||||
const dateB = DateTime.fromISO(b.timeBucket);
|
||||
return dateB.diff(dateA).milliseconds;
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
const createDefaultOwner = (ownerId: string) => {
|
||||
const defaultOwner: UserResponseDto = {
|
||||
id: ownerId,
|
||||
email: signupDto.admin.email,
|
||||
name: signupDto.admin.name,
|
||||
profileImagePath: '',
|
||||
profileChangedAt: new Date().toISOString(),
|
||||
avatarColor: UserAvatarColor.Blue,
|
||||
};
|
||||
return defaultOwner;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
||||
* This matches the response from GET /api/assets/:id
|
||||
*/
|
||||
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Default owner if not provided
|
||||
const defaultOwner = createDefaultOwner(asset.ownerId);
|
||||
|
||||
const exifInfo: ExifResponseDto = {
|
||||
make: null,
|
||||
model: null,
|
||||
exifImageWidth: asset.ratio > 1 ? 4000 : 3000,
|
||||
exifImageHeight: asset.ratio > 1 ? Math.round(4000 / asset.ratio) : Math.round(3000 * asset.ratio),
|
||||
fileSizeInByte: asset.fileSizeInByte,
|
||||
orientation: '1',
|
||||
dateTimeOriginal: asset.fileCreatedAt,
|
||||
modifyDate: asset.fileCreatedAt,
|
||||
timeZone: asset.latitude === null ? null : 'UTC',
|
||||
lensModel: null,
|
||||
fNumber: null,
|
||||
focalLength: null,
|
||||
iso: null,
|
||||
exposureTime: null,
|
||||
latitude: asset.latitude,
|
||||
longitude: asset.longitude,
|
||||
city: asset.city,
|
||||
country: asset.country,
|
||||
state: null,
|
||||
description: null,
|
||||
};
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
deviceAssetId: `device-${asset.id}`,
|
||||
ownerId: asset.ownerId,
|
||||
owner: owner || defaultOwner,
|
||||
libraryId: `library-${asset.ownerId}`,
|
||||
deviceId: `device-${asset.ownerId}`,
|
||||
type: asset.isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
|
||||
originalPath: `/original/${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
|
||||
originalFileName: `${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
|
||||
originalMimeType: asset.isVideo ? 'video/mp4' : 'image/jpeg',
|
||||
thumbhash: asset.thumbhash,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileCreatedAt,
|
||||
localDateTime: asset.localDateTime,
|
||||
updatedAt: now,
|
||||
createdAt: asset.fileCreatedAt,
|
||||
isFavorite: asset.isFavorite,
|
||||
isArchived: false,
|
||||
isTrashed: asset.isTrashed,
|
||||
visibility: asset.visibility,
|
||||
duration: asset.duration || '0:00:00.00000',
|
||||
exifInfo,
|
||||
livePhotoVideoId: asset.livePhotoVideoId,
|
||||
tags: [],
|
||||
people: [],
|
||||
unassignedFaces: [],
|
||||
stack: asset.stack,
|
||||
isOffline: false,
|
||||
hasMetadata: true,
|
||||
duplicateId: null,
|
||||
resized: true,
|
||||
checksum: asset.checksum,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
200
e2e/src/generators/timeline/timeline-config.ts
Normal file
200
e2e/src/generators/timeline/timeline-config.ts
Normal file
@ -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<string, MockTimelineAsset[]>;
|
||||
album: MockAlbum; // Mock album created from random assets
|
||||
};
|
||||
|
||||
export type SerializedTimelineData = {
|
||||
buckets: Record<string, MockTimelineAsset[]>;
|
||||
album: MockAlbum;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a TimelineConfig object to ensure all values are within expected ranges
|
||||
*/
|
||||
export function validateTimelineConfig(config: TimelineConfig): void {
|
||||
if (!config.months || config.months.length === 0) {
|
||||
throw new Error('TimelineConfig must contain at least one month');
|
||||
}
|
||||
|
||||
const seenMonths = new Set<string>();
|
||||
|
||||
for (const month of config.months) {
|
||||
if (month.month < 1 || month.month > 12) {
|
||||
throw new Error(`Invalid month: ${month.month}. Must be between 1 and 12`);
|
||||
}
|
||||
|
||||
if (month.year < 1900 || month.year > 2100) {
|
||||
throw new Error(`Invalid year: ${month.year}. Must be between 1900 and 2100`);
|
||||
}
|
||||
|
||||
const monthKey = `${month.year}-${month.month}`;
|
||||
if (seenMonths.has(monthKey)) {
|
||||
throw new Error(`Duplicate month found: ${monthKey}`);
|
||||
}
|
||||
seenMonths.add(monthKey);
|
||||
|
||||
// Validate distribution if provided
|
||||
if (month.distribution && !['empty', 'sparse', 'medium', 'dense', 'very-dense'].includes(month.distribution)) {
|
||||
throw new Error(
|
||||
`Invalid distribution: ${month.distribution}. Must be one of: empty, sparse, medium, dense, very-dense`,
|
||||
);
|
||||
}
|
||||
|
||||
const validPatterns = [
|
||||
'single-day',
|
||||
'consecutive-large',
|
||||
'consecutive-small',
|
||||
'alternating',
|
||||
'sparse-scattered',
|
||||
'start-heavy',
|
||||
'end-heavy',
|
||||
'mid-heavy',
|
||||
];
|
||||
if (month.pattern && !validPatterns.includes(month.pattern)) {
|
||||
throw new Error(`Invalid pattern: ${month.pattern}. Must be one of: ${validPatterns.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate seed if provided
|
||||
if (config.seed !== undefined && (config.seed < 0 || !Number.isInteger(config.seed))) {
|
||||
throw new Error('Seed must be a non-negative integer');
|
||||
}
|
||||
|
||||
// Validate ownerId if provided
|
||||
if (config.ownerId !== undefined && config.ownerId.trim() === '') {
|
||||
throw new Error('Owner ID cannot be an empty string');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default timeline configuration
|
||||
*/
|
||||
export function createDefaultTimelineConfig(): TimelineConfig {
|
||||
const months: MonthSpec[] = [
|
||||
// 2024 - Mix of patterns
|
||||
{ year: 2024, month: 12, distribution: 'very-dense', pattern: 'alternating' },
|
||||
{ year: 2024, month: 11, distribution: 'dense', pattern: 'consecutive-large' },
|
||||
{ year: 2024, month: 10, distribution: 'medium', pattern: 'mid-heavy' },
|
||||
{ year: 2024, month: 9, distribution: 'sparse', pattern: 'consecutive-small' },
|
||||
{ year: 2024, month: 8, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2024, month: 7, distribution: 'dense', pattern: 'start-heavy' },
|
||||
{ year: 2024, month: 6, distribution: 'medium', pattern: 'sparse-scattered' },
|
||||
{ year: 2024, month: 5, distribution: 'sparse', pattern: 'single-day' },
|
||||
{ year: 2024, month: 4, distribution: 'very-dense', pattern: 'consecutive-large' },
|
||||
{ year: 2024, month: 3, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2024, month: 2, distribution: 'medium', pattern: 'end-heavy' },
|
||||
{ year: 2024, month: 1, distribution: 'dense', pattern: 'alternating' },
|
||||
|
||||
// 2023 - Testing year boundaries and more patterns
|
||||
{ year: 2023, month: 12, distribution: 'very-dense', pattern: 'end-heavy' },
|
||||
{ year: 2023, month: 11, distribution: 'sparse', pattern: 'consecutive-small' },
|
||||
{ year: 2023, month: 10, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2023, month: 9, distribution: 'medium', pattern: 'alternating' },
|
||||
{ year: 2023, month: 8, distribution: 'dense', pattern: 'mid-heavy' },
|
||||
{ year: 2023, month: 7, distribution: 'sparse', pattern: 'sparse-scattered' },
|
||||
{ year: 2023, month: 6, distribution: 'medium', pattern: 'consecutive-large' },
|
||||
{ year: 2023, month: 5, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2023, month: 4, distribution: 'sparse', pattern: 'single-day' },
|
||||
{ year: 2023, month: 3, distribution: 'dense', pattern: 'start-heavy' },
|
||||
{ year: 2023, month: 2, distribution: 'medium', pattern: 'alternating' },
|
||||
{ year: 2023, month: 1, distribution: 'very-dense', pattern: 'consecutive-large' },
|
||||
];
|
||||
|
||||
for (let year = 2022; year >= 2000; year--) {
|
||||
for (let month = 12; month >= 1; month--) {
|
||||
months.push({ year, month, distribution: 'medium', pattern: 'sparse-scattered' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
months,
|
||||
seed: 42,
|
||||
};
|
||||
}
|
||||
186
e2e/src/generators/timeline/utils.ts
Normal file
186
e2e/src/generators/timeline/utils.ts
Normal file
@ -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<number>();
|
||||
const maxAttempts = numDays * GENERATION_CONSTANTS.MAX_SELECT_ATTEMPTS; // Safety limit
|
||||
let attempts = 0;
|
||||
|
||||
while (selectedDays.size < numDays && attempts < maxAttempts) {
|
||||
const day = rng.nextInt(1, daysInMonth + 1);
|
||||
selectedDays.add(day);
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Fallback: if we couldn't select enough random days, fill with sequential days
|
||||
if (selectedDays.size < numDays) {
|
||||
for (let day = 1; day <= daysInMonth && selectedDays.size < numDays; day++) {
|
||||
selectedDays.add(day);
|
||||
}
|
||||
}
|
||||
|
||||
return [...selectedDays].sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select item from array using seeded random
|
||||
*/
|
||||
export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
|
||||
if (arr.length === 0) {
|
||||
throw new Error('Cannot select from empty array');
|
||||
}
|
||||
const index = rng.nextInt(0, arr.length);
|
||||
return arr[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select multiple random items from array using seeded random without duplicates
|
||||
*/
|
||||
export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRandom): T[] {
|
||||
if (arr.length === 0) {
|
||||
throw new Error('Cannot select from empty array');
|
||||
}
|
||||
if (count < 0) {
|
||||
throw new Error('Count must be non-negative');
|
||||
}
|
||||
if (count > arr.length) {
|
||||
throw new Error('Count cannot exceed array length');
|
||||
}
|
||||
|
||||
const result: T[] = [];
|
||||
const selectedIndices = new Set<number>();
|
||||
|
||||
while (result.length < count) {
|
||||
const index = rng.nextInt(0, arr.length);
|
||||
if (!selectedIndices.has(index)) {
|
||||
selectedIndices.add(index);
|
||||
result.push(arr[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse timeBucket parameter to extract year-month key
|
||||
* Handles both formats:
|
||||
* - ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
|
||||
* - Simple format: "2024-12-01" -> "2024-12-01"
|
||||
*/
|
||||
export function parseTimeBucketKey(timeBucket: string): string {
|
||||
if (!timeBucket) {
|
||||
throw new Error('timeBucket parameter cannot be empty');
|
||||
}
|
||||
|
||||
const dt = DateTime.fromISO(timeBucket, { zone: 'utc' });
|
||||
|
||||
if (!dt.isValid) {
|
||||
// Fallback to regex if not a valid ISO string
|
||||
const match = timeBucket.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
return match ? match[1] : timeBucket;
|
||||
}
|
||||
|
||||
// Format as YYYY-MM-01 (first day of month)
|
||||
return `${dt.year}-${String(dt.month).padStart(2, '0')}-01`;
|
||||
}
|
||||
|
||||
export function getMockAsset(
|
||||
asset: MockTimelineAsset,
|
||||
sortedDescendingAssets: MockTimelineAsset[],
|
||||
direction: 'next' | 'previous',
|
||||
unit: 'day' | 'month' | 'year' = 'day',
|
||||
): MockTimelineAsset | null {
|
||||
const currentDateTime = DateTime.fromISO(asset.localDateTime, { zone: 'utc' });
|
||||
|
||||
const currentIndex = sortedDescendingAssets.findIndex((a) => a.id === asset.id);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const step = direction === 'next' ? 1 : -1;
|
||||
const startIndex = currentIndex + step;
|
||||
|
||||
if (direction === 'next' && currentIndex >= sortedDescendingAssets.length - 1) {
|
||||
return null;
|
||||
}
|
||||
if (direction === 'previous' && currentIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
|
||||
if (unit === 'day') {
|
||||
return !date1.startOf('day').equals(date2.startOf('day'));
|
||||
} else if (unit === 'month') {
|
||||
return date1.year !== date2.year || date1.month !== date2.month;
|
||||
} else {
|
||||
return date1.year !== date2.year;
|
||||
}
|
||||
};
|
||||
|
||||
if (direction === 'next') {
|
||||
// Search forward in array (backwards in time)
|
||||
for (let i = startIndex; i < sortedDescendingAssets.length; i++) {
|
||||
const nextAsset = sortedDescendingAssets[i];
|
||||
const nextDate = DateTime.fromISO(nextAsset.localDateTime, { zone: 'utc' });
|
||||
|
||||
if (isInDifferentPeriod(nextDate, currentDateTime)) {
|
||||
return nextAsset;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Search backward in array (forwards in time)
|
||||
for (let i = startIndex; i >= 0; i--) {
|
||||
const prevAsset = sortedDescendingAssets[i];
|
||||
const prevDate = DateTime.fromISO(prevAsset.localDateTime, { zone: 'utc' });
|
||||
|
||||
if (isInDifferentPeriod(prevDate, currentDateTime)) {
|
||||
return prevAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
285
e2e/src/mock-network/base-network.ts
Normal file
285
e2e/src/mock-network/base-network.ts
Normal file
@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
};
|
||||
139
e2e/src/mock-network/timeline-network.ts
Normal file
139
e2e/src/mock-network/timeline-network.ts
Normal file
@ -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\/(?<assetId>[^/]+)\/thumbnail\?size=(?<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\/(?<albumId>[^/?]+)/;
|
||||
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<void>,
|
||||
) => {
|
||||
let resolveRequest: ((value: unknown | PromiseLike<unknown>) => void) | undefined;
|
||||
const deleteRequest = new Promise((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
await page.route(route, async (route, request) => {
|
||||
await callback(route, request);
|
||||
const requestJson = request.postDataJSON();
|
||||
resolveRequest?.(requestJson);
|
||||
});
|
||||
return deleteRequest;
|
||||
};
|
||||
@ -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',
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
776
e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts
Normal file
776
e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts
Normal file
@ -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;
|
||||
};
|
||||
234
e2e/src/web/specs/timeline/utils.ts
Normal file
234
e2e/src/web/specs/timeline/utils.ts
Normal file
@ -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 <T>(
|
||||
page: Page,
|
||||
query: () => Promise<T>,
|
||||
callback?: (result: Awaited<T> | undefined) => boolean,
|
||||
) => {
|
||||
let result;
|
||||
const timeout = Date.now() + 10_000;
|
||||
const signal = activePollsAbortController.signal;
|
||||
|
||||
const terminate = callback || ((result: Awaited<T> | 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);
|
||||
},
|
||||
};
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user