test: web e2e tests

This commit is contained in:
izzy 2026-04-20 10:46:48 +01:00
parent 5f5d3ea0ba
commit 4ded06dbb7
No known key found for this signature in database
7 changed files with 171 additions and 26 deletions

View File

@ -44,7 +44,7 @@
"exiftool-vendored": "^35.0.0",
"globals": "^17.0.0",
"luxon": "^3.4.4",
"orchestration-ui": "0.1.57",
"orchestration-ui": "0.1.61",
"pg": "^8.11.3",
"pngjs": "^7.0.0",
"prettier": "^3.7.4",

View File

@ -90,7 +90,7 @@ describe('/yucca', () => {
beforeAll(async () => {
await sdk.importRecoveryKey(
{
recoveryKey: '0'.repeat(32),
recoveryKey: '0'.repeat(64),
},
requestOpts,
);
@ -285,7 +285,7 @@ describe('/yucca', () => {
await sdk.importRecoveryKey(
{
recoveryKey: '0'.repeat(32),
recoveryKey: '0'.repeat(64),
},
maintenanceRequestOpts,
);

View File

@ -0,0 +1,141 @@
import { LoginResponseDto, confirmRecoveryKey, importRecoveryKey, resetOrchestrator } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { io, type Socket } from 'socket.io-client';
import { asBearerAuth, baseUrl, utils } from 'src/utils';
test.describe.configure({ mode: 'serial' });
test.describe('Yucca Backups', () => {
let admin: LoginResponseDto;
let socket: Socket;
const waitForTaskEnd = () =>
new Promise<void>((resolve) => {
const listener = (msg: string) => {
try {
const payload = JSON.parse(msg);
if (payload.type === 'TaskEnd') {
socket.offAny(listener);
resolve();
}
} catch {
// no-op
}
};
socket.onAny(listener);
});
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
const headers = asBearerAuth(admin.accessToken);
await resetOrchestrator({ headers });
await importRecoveryKey({ importRecoveryKeyRequest: { recoveryKey: '0'.repeat(64) } }, { headers });
await confirmRecoveryKey({ headers });
await utils.mkdir('/local-backend');
socket = io(baseUrl, {
path: '/api/yucca/socket.io',
transports: ['websocket'],
extraHeaders: headers,
forceNew: true,
});
await new Promise<void>((resolve) => socket.on('connect', () => resolve()));
});
test.afterAll(async () => {
socket?.close();
});
test('onboarding configures a local backend', async ({ context, page }) => {
test.setTimeout(30_000);
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/backups');
const dialog = page.getByRole('dialog');
await expect(dialog.filter({ hasText: 'Backup options' })).toBeVisible();
await dialog.getByText('Local Folder').click();
await expect(dialog.filter({ hasText: 'Create local backend' })).toBeVisible();
await dialog.getByLabel('Path').fill('/local-backend');
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog.filter({ hasText: 'Configure Your Immich Backup' })).toBeVisible();
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole('link', { name: 'Repositories' })).toBeVisible();
});
test('manually triggers a backup and waits for completion', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/backups/repositories');
const backupNow = page.getByRole('button', { name: 'Backup Now' });
await expect(backupNow).toBeVisible();
const taskEnd = waitForTaskEnd();
await backupNow.click();
await expect(page.getByRole('dialog').filter({ hasText: 'Log Output' })).toBeVisible();
await taskEnd;
});
test('resets immich and restores from the local yucca backup', async ({ context, page }) => {
test.setTimeout(120_000);
await utils.setAuthCookies(context, admin.accessToken);
await utils.resetBackups(admin.accessToken);
await utils.createBackup(admin.accessToken);
await resetOrchestrator({ headers: asBearerAuth(admin.accessToken) });
await utils.resetDatabase();
await page.goto('/');
await page.getByRole('button', { name: 'Restore from backup' }).click();
try {
await page.waitForURL('/maintenance**');
} catch {
await page.goto('/maintenance');
await page.waitForURL('/maintenance**');
}
await page.getByRole('button', { name: 'FUTO Backups' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.filter({ hasText: 'Import recovery key' })).toBeVisible();
await dialog.getByLabel('Recovery Key').fill('0'.repeat(64));
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog.filter({ hasText: 'Where would you like to restore from?' })).toBeVisible();
await dialog.getByText('Local Folder').click();
await expect(dialog.filter({ hasText: 'Create local backend' })).toBeVisible();
await dialog.getByLabel('Path').fill('/local-backend');
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog.filter({ hasText: 'Select Restore Point' })).toBeVisible();
await dialog.getByRole('button', { name: 'Select' }).first().click();
await expect(dialog.filter({ hasText: /Restore from/ })).toBeVisible();
await dialog.getByRole('button', { name: 'Restore' }).first().click();
await expect(dialog.filter({ hasText: 'Confirm restore from snapshot' })).toBeVisible();
await dialog.getByRole('button', { name: 'Restore' }).click();
await expect(dialog.filter({ hasText: 'Restoring' })).toBeVisible();
await expect(dialog.filter({ hasText: 'Restoring' })).toBeHidden({ timeout: 60_000 });
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
await page.waitForURL('/maintenance?**');
await page.waitForURL('/photos', { timeout: 90_000 });
});
});

38
pnpm-lock.yaml generated
View File

@ -253,8 +253,8 @@ importers:
specifier: ^3.4.4
version: 3.7.2
orchestration-ui:
specifier: 0.1.57
version: 0.1.57(svelte@5.55.1)
specifier: 0.1.61
version: 0.1.61(svelte@5.55.1)
pg:
specifier: ^8.11.3
version: 8.20.0
@ -524,8 +524,8 @@ importers:
specifier: ^6.3.3
version: 6.8.2
orchestration-api:
specifier: 0.1.57
version: 0.1.57(@nestjs/platform-express@11.1.17)(class-transformer@0.5.1)(reflect-metadata@0.2.2)
specifier: 0.1.61
version: 0.1.61(@nestjs/platform-express@11.1.17)(class-transformer@0.5.1)(reflect-metadata@0.2.2)
pg:
specifier: ^8.11.3
version: 8.20.0
@ -816,8 +816,8 @@ importers:
specifier: ^5.6.2
version: 5.21.0
orchestration-ui:
specifier: 0.1.57
version: 0.1.57(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)
specifier: 0.1.61
version: 0.1.61(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)
pmtiles:
specifier: ^4.3.0
version: 4.4.0
@ -9675,11 +9675,11 @@ packages:
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
engines: {node: '>=18'}
orchestration-api@0.1.57:
resolution: {integrity: sha512-rXsiIurhkliJ/5fbLz6kTg7VBrgaq6MrNZJQwdnDvsw1FLZCANY1KUh1WwSkgI5OLmb/lAcyLE+vO6UeMkXgyg==}
orchestration-api@0.1.61:
resolution: {integrity: sha512-RClFa0Xtlyyg1VaO9bUKWGVW34xgHNbHv/UOmybz2EWl2kTA+nZA4ZzoRBN/LbAuXsnTnlevsqJqTkqK1vCkTg==}
orchestration-ui@0.1.57:
resolution: {integrity: sha512-vvpVItUdFpj5PKgIvkKMKv5gLpygc4b1rFtXBE4P0+rtFLklw6QTuVrIXzmxU/t2fMwP8J93ZPRSTP05t0MzBA==}
orchestration-ui@0.1.61:
resolution: {integrity: sha512-BvefDhMN4AwujszsXY3J0hDLmQM+x1qAbgWfb/KfKbjq+OB+hh6+8o4DMa2xCWsw80A8M6/cgKutYGvUa8FKJw==}
peerDependencies:
svelte: ^5.0.0
@ -12639,8 +12639,8 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
yucca-api-client@0.1.57:
resolution: {integrity: sha512-faaYkjeO2IPvmvxYHrGefvGm7zE69sMk0fjizxd8/TOV9WztC2mWPDDwRIn9SZngg8AMekhg1xHakkWhMjttyQ==}
yucca-api-client@0.1.61:
resolution: {integrity: sha512-JNo95RWc4Zb3WslT50yN3l2BWIfXeNXnYJv6ULn40qDY1nlpZPGorYS2QlIxVLSz6ypmyf1Ia1vQVtzA75L5/w==}
yup@1.7.1:
resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==}
@ -23009,7 +23009,7 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.2.0
orchestration-api@0.1.57(@nestjs/platform-express@11.1.17)(class-transformer@0.5.1)(reflect-metadata@0.2.2):
orchestration-api@0.1.61(@nestjs/platform-express@11.1.17)(class-transformer@0.5.1)(reflect-metadata@0.2.2):
dependencies:
'@futo-org/restic-wrapper': 1.1.2
'@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@ -23030,7 +23030,7 @@ snapshots:
rxjs: 7.8.2
socket.io: 4.8.3
tail: 2.2.6
yucca-api-client: 0.1.57
yucca-api-client: 0.1.61
transitivePeerDependencies:
- '@nestjs/microservices'
- '@nestjs/platform-express'
@ -23040,7 +23040,7 @@ snapshots:
- supports-color
- utf-8-validate
orchestration-ui@0.1.57(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1):
orchestration-ui@0.1.61(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1):
dependencies:
'@immich/ui': 0.59.0(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)
'@mdi/js': 7.4.47
@ -23052,14 +23052,14 @@ snapshots:
luxon: 3.7.2
socket.io-client: 4.8.3
svelte: 5.55.1
yucca-api-client: 0.1.57
yucca-api-client: 0.1.61
transitivePeerDependencies:
- '@sveltejs/kit'
- bufferutil
- supports-color
- utf-8-validate
orchestration-ui@0.1.57(svelte@5.55.1):
orchestration-ui@0.1.61(svelte@5.55.1):
dependencies:
'@immich/ui': 0.59.0(svelte@5.55.1)
'@mdi/js': 7.4.47
@ -23071,7 +23071,7 @@ snapshots:
luxon: 3.7.2
socket.io-client: 4.8.3
svelte: 5.55.1
yucca-api-client: 0.1.57
yucca-api-client: 0.1.61
transitivePeerDependencies:
- '@sveltejs/kit'
- bufferutil
@ -26594,7 +26594,7 @@ snapshots:
yoctocolors@2.1.2: {}
yucca-api-client@0.1.57:
yucca-api-client@0.1.61:
dependencies:
'@oazapfts/runtime': 1.2.0

View File

@ -99,7 +99,7 @@
"nestjs-zod": "^5.3.0",
"nodemailer": "^8.0.0",
"openid-client": "^6.3.3",
"orchestration-api": "0.1.57",
"orchestration-api": "0.1.61",
"pg": "^8.11.3",
"pg-connection-string": "^2.9.1",
"picomatch": "^4.0.2",

View File

@ -10,7 +10,7 @@ import { OrchestrationApiModule } from 'orchestration-api/dist';
import { commandsAndQuestions } from 'src/commands';
import { IWorker } from 'src/constants';
import { controllers } from 'src/controllers';
import { ImmichWorker } from 'src/enum';
import { ImmichEnvironment, ImmichWorker } from 'src/enum';
import { MaintenanceAuthGuard } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
@ -60,7 +60,9 @@ const apiMiddleware = [
];
const configRepository = new ConfigRepository();
const { bull, cls, database, otel } = configRepository.getEnv();
const { bull, cls, database, environment, otel } = configRepository.getEnv();
const isYuccaDevelopmentMode =
environment === ImmichEnvironment.Development || environment === ImmichEnvironment.Testing;
const commonImports = [
ClsModule.forRoot(cls.config),
@ -121,6 +123,7 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
statePath: '/data/yucca', // TODO
requireWsAuth: true,
requireLock: true,
developmentMode: isYuccaDevelopmentMode,
}),
],
controllers: [...controllers],
@ -138,6 +141,7 @@ export class ApiModule extends BaseModule {}
externalBaseUrl: 'https://my.immich.app',
requireWsAuth: true,
requireLock: true,
developmentMode: isYuccaDevelopmentMode,
}),
],
controllers: [MaintenanceWorkerController],

View File

@ -50,7 +50,7 @@
"lodash-es": "^4.17.21",
"luxon": "^3.4.4",
"maplibre-gl": "^5.6.2",
"orchestration-ui": "0.1.57",
"orchestration-ui": "0.1.61",
"pmtiles": "^4.3.0",
"qrcode": "^1.5.4",
"simple-icons": "^16.0.0",