Skip to content

Commit

Permalink
do e2e instead
Browse files Browse the repository at this point in the history
  • Loading branch information
etnoy committed Feb 5, 2025
1 parent e8c4d27 commit 53660aa
Show file tree
Hide file tree
Showing 5 changed files with 200 additions and 60 deletions.
1 change: 0 additions & 1 deletion e2e/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ services:
- IMMICH_ENV=testing
- IMMICH_PORT=2285
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
- IMMICH_LOG_LEVEL=verbose
volumes:
- ./test-assets:/test-assets
extra_hosts:
Expand Down
217 changes: 185 additions & 32 deletions e2e/src/api/specs/jobs.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { JobCommand, JobName, LoginResponseDto, updateConfig } from '@immich/sdk';
import { cpSync } from 'node:fs';
import { cpSync, rmSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { errorDto } from 'src/responses';
Expand Down Expand Up @@ -32,6 +32,16 @@ describe('/jobs', () => {
force: false,
});

await utils.jobCommand(admin.accessToken, JobName.SmartSearch, {
command: JobCommand.Resume,
force: false,
});

await utils.jobCommand(admin.accessToken, JobName.DuplicateDetection, {
command: JobCommand.Resume,
force: false,
});

const config = await utils.getSystemConfig(admin.accessToken);
config.machineLearning.duplicateDetection.enabled = false;
config.machineLearning.enabled = false;
Expand All @@ -47,22 +57,15 @@ describe('/jobs', () => {
});

it('should queue metadata extraction for missing assets', async () => {
const path1 = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
const path2 = `${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`;

await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path1), filename: basename(path1) },
});

await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;

await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Pause,
force: false,
});

const { id } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path2), filename: basename(path2) },
assetData: { bytes: await readFile(path), filename: basename(path) },
});

await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
Expand Down Expand Up @@ -101,36 +104,62 @@ describe('/jobs', () => {
}
});

it('should queue thumbnail extraction for missing assets', async () => {
const path1 = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
const path2 = `${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`;
it('should not re-extract metadata for existing assets', async () => {
const path = `${testAssetDir}/temp/metadata/asset.jpg`;

await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path1), filename: basename(path1) },
cpSync(`${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`, path);

const { id } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path), filename: basename(path) },
});

await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');

{
const asset = await utils.getAssetInfo(admin.accessToken, id);

expect(asset.exifInfo).toBeDefined();
expect(asset.exifInfo?.model).toBe('NIKON D700');
}

cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, path);

await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Start,
force: false,
});

await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');

{
const asset = await utils.getAssetInfo(admin.accessToken, id);

expect(asset.exifInfo).toBeDefined();
expect(asset.exifInfo?.model).toBe('NIKON D700');
}

rmSync(path);
});

it('should queue thumbnail extraction for assets missing thumbs', async () => {
const path = `${testAssetDir}/albums/nature/tanners_ridge.jpg`;

await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Pause,
force: false,
});

const { id } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path2), filename: basename(path2) },
assetData: { bytes: await readFile(path), filename: basename(path) },
});

await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);

{
const asset = await utils.getAssetInfo(admin.accessToken, id);
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
expect(assetBefore.thumbhash).toBeNull();

expect(asset.thumbhash).toBeNull();
}

await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Empty,
force: false,
});
Expand All @@ -151,11 +180,46 @@ describe('/jobs', () => {
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);

{
const asset = await utils.getAssetInfo(admin.accessToken, id);
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
expect(assetAfter.thumbhash).not.toBeNull();
});

expect(asset.thumbhash).not.toBeNull();
}
it('should not reload existing thumbnail when running thumb job for missing assets', async () => {
const path = `${testAssetDir}/temp/thumbs/asset1.jpg`;

cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, path);

const { id } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path), filename: basename(path) },
});

await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);

const assetBefore = await utils.getAssetInfo(admin.accessToken, id);

cpSync(`${testAssetDir}/albums/nature/notocactus_minimus.jpg`, path);

await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Resume,
force: false,
});

// This runs the missing thumbnail job
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Start,
force: false,
});

await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);

const assetAfter = await utils.getAssetInfo(admin.accessToken, id);

// Asset 1 thumbnail should be untouched since its thumb should not have been reloaded, even though the file was changed
expect(assetAfter.thumbhash).toEqual(assetBefore.thumbhash);

rmSync(path);
});

it('should queue duplicate detection for missing duplicates', async () => {
Expand Down Expand Up @@ -235,20 +299,22 @@ describe('/jobs', () => {

expect(asset1.duplicateId).toEqual(asset2.duplicateId);
}

rmSync(`${testAssetDir}/temp/dupes/asset1.jpg`);
rmSync(`${testAssetDir}/temp/dupes/asset2.jpg`);
}, 120_000);

it('should queue smart search for missing assets', async () => {
{
const config = await utils.getSystemConfig(admin.accessToken);
config.machineLearning.duplicateDetection.enabled = false;
config.machineLearning.enabled = false;
config.machineLearning.clip.enabled = false;
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
}

const path = `${testAssetDir}/albums/nature/prairie_falcon.jpg`;

const { id: id1 } = await utils.createAsset(admin.accessToken, {
await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path), filename: basename(path) },
});

Expand Down Expand Up @@ -281,7 +347,49 @@ describe('/jobs', () => {
const results = await utils.searchSmart(admin.accessToken, { query: 'bird' });
expect(results.assets.count).toBeGreaterThanOrEqual(1);
}
}, 120_000);
}, 60_000);

it('should not re-do smart search for already-indexed assets', async () => {
{
const config = await utils.getSystemConfig(admin.accessToken);
config.machineLearning.enabled = true;
config.machineLearning.clip.enabled = true;
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
}

const path = `${testAssetDir}/temp/smart/asset.jpg`;

cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, path);

await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path), filename: basename(path) },
});

await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, JobName.SmartSearch);

{
const results = await utils.searchSmart(admin.accessToken, { query: 'bird' });
expect(results.assets.count).toBeGreaterThanOrEqual(1);
}

cpSync(`${testAssetDir}/albums/nature/el_torcal_rocks.jpg`, path);

await utils.jobCommand(admin.accessToken, JobName.SmartSearch, {
command: JobCommand.Start,
force: false,
});

await utils.waitForQueueFinish(admin.accessToken, JobName.SmartSearch, 60_000);

{
const results = await utils.searchSmart(admin.accessToken, { query: 'bird' });
expect(results.assets.count).toBeGreaterThanOrEqual(1);
}

rmSync(path);
}, 60_000);

it('should queue face detection for missing faces', async () => {
const path = `${testAssetDir}/metadata/faces/solvay.jpg`;
Expand Down Expand Up @@ -350,6 +458,51 @@ describe('/jobs', () => {
expect(asset.people).toEqual([]);
expect(asset.unassignedFaces?.length).toBeGreaterThan(10);
}
}, 120_000);
}, 60_000);

it('should not rerun face detection for existing faces', async () => {
const config = await utils.getSystemConfig(admin.accessToken);
config.metadata.faces.import = true;
config.machineLearning.enabled = true;
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });

const path = `${testAssetDir}/temp/faces/asset.jpg`;

cpSync(`${testAssetDir}/metadata/faces/solvay.jpg`, path);

Check failure on line 471 in e2e/src/api/specs/jobs.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / End-to-End Tests (Server & CLI)

src/api/specs/jobs.e2e-spec.ts > /jobs > PUT /jobs > should not rerun face detection for existing faces

Error: ENOENT: no such file or directory, lstat '/home/runner/_work/immich/immich/e2e/test-assets/metadata/faces/solvay.jpg' ❯ src/api/specs/jobs.e2e-spec.ts:471:7 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'lstat', path: '/home/runner/_work/immich/immich/e2e/test-assets/metadata/faces/solvay.jpg' }

const { id } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path), filename: basename(path) },
});

await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, JobName.FaceDetection);

{
const asset = await utils.getAssetInfo(admin.accessToken, id);
expect(asset.people).toEqual([]);
expect(asset.unassignedFaces?.length).toBeGreaterThan(10);
}

cpSync(`${testAssetDir}/albums/nature/el_torcal_rocks.jpg`, path);

await utils.jobCommand(admin.accessToken, JobName.FaceDetection, {
command: JobCommand.Start,
force: false,
});

await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, JobName.FaceDetection, 60_000);

{
const asset = await utils.getAssetInfo(admin.accessToken, id);

expect(asset.people).toEqual([]);
expect(asset.unassignedFaces?.length).toBeGreaterThan(10);
}

rmSync(path);
}, 60_000);
});
});
25 changes: 7 additions & 18 deletions e2e/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
PersonCreateDto,
SharedLinkCreateDto,
SmartSearchDto,
SystemConfigDto,
UpdateLibraryDto,
UserAdminCreateDto,
UserPreferencesUpdateDto,
Expand Down Expand Up @@ -61,10 +60,9 @@ import { io, type Socket } from 'socket.io-client';
import { loginDto, signupDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
import request from 'supertest';
import { a } from 'vitest/dist/chunks/suite.B2jumIFP.js';

type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete' | 'assetHidden' | 'configUpdate';
type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete' | 'assetHidden';
type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: number };
type AdminSetupOptions = { onboarding?: boolean };
type FileData = { bytes?: Buffer; filename: string };
Expand Down Expand Up @@ -115,26 +113,23 @@ const events: Record<EventType, Set<string>> = {
assetUpdate: new Set<string>(),
assetDelete: new Set<string>(),
userDelete: new Set<string>(),
configUpdate: new Set<string>(),
};

const idCallbacks: Record<string, () => void> = {};
const countCallbacks: Record<string, { count: number; callback: () => void }> = {};

const execPromise = promisify(exec);

const onEvent = ({ event, id }: { event: EventType; id?: string }) => {
const onEvent = ({ event, id }: { event: EventType; id: string }) => {
// console.log(`Received event: ${event} [id=${id}]`);
const set = events[event];

if (id) {
set.add(id);
set.add(id);

const idCallback = idCallbacks[id];
if (idCallback) {
idCallback();
delete idCallbacks[id];
}
const idCallback = idCallbacks[id];
if (idCallback) {
idCallback();
delete idCallbacks[id];
}

const item = countCallbacks[event];
Expand Down Expand Up @@ -225,8 +220,6 @@ export const utils = {
.on('on_asset_hidden', (assetId: string) => onEvent({ event: 'assetHidden', id: assetId }))
.on('on_asset_delete', (assetId: string) => onEvent({ event: 'assetDelete', id: assetId }))
.on('on_user_delete', (userId: string) => onEvent({ event: 'userDelete', id: userId }))
//.on('on_config_update', () => onEvent({ event: 'configUpdate' }))
.onAny((event, ...args) => console.log(`Received event: ${event}`, args))
.connect();
});
},
Expand All @@ -249,10 +242,6 @@ export const utils = {

waitForWebsocketEvent: ({ event, id, total: count, timeout: ms }: WaitOptions): Promise<void> => {
return new Promise<void>((resolve, reject) => {
if (event === 'configUpdate') {
count = 1;
}

if (!id && !count) {
reject(new Error('id or count must be provided for waitForWebsocketEvent'));
}
Expand Down
2 changes: 1 addition & 1 deletion e2e/test-assets
Loading

0 comments on commit 53660aa

Please sign in to comment.