refactor(server): format and metadata e2e (#7477)

* refactor(server): format and metadata e2e

* refactor: on upload success waiting
This commit is contained in:
Jason Rasmussen
2024-02-28 04:21:31 -05:00
committed by GitHub
parent e2c0945bc1
commit 74d431f881
9 changed files with 509 additions and 322 deletions

View File

@@ -1,16 +1,39 @@
import {
AssetFileUploadResponseDto,
AssetResponseDto,
AssetTypeEnum,
LoginResponseDto,
SharedLinkType,
} from '@immich/sdk';
import { exiftool } from 'exiftool-vendored';
import { DateTime } from 'luxon';
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, dbUtils } from 'src/utils';
import {
apiUtils,
app,
dbUtils,
tempDir,
testAssetDir,
wsUtils,
} from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
const sha1 = (bytes: Buffer) =>
createHash('sha1').update(bytes).digest('base64');
const readTags = async (bytes: Buffer, filename: string) => {
const filepath = join(tempDir, filename);
await writeFile(filepath, bytes);
return exiftool.read(filepath);
};
const today = DateTime.fromObject({
year: 2023,
@@ -24,25 +47,36 @@ describe('/asset', () => {
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let userStats: LoginResponseDto;
let asset1: AssetFileUploadResponseDto;
let asset2: AssetFileUploadResponseDto;
let asset3: AssetFileUploadResponseDto;
let asset4: AssetFileUploadResponseDto; // user2 asset
let asset5: AssetFileUploadResponseDto;
let asset6: AssetFileUploadResponseDto;
let user1Assets: AssetFileUploadResponseDto[];
let user2Assets: AssetFileUploadResponseDto[];
let assetLocation: AssetFileUploadResponseDto;
let ws: Socket;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
admin = await apiUtils.adminSetup({ onboarding: false });
[user1, user2, userStats] = await Promise.all([
[ws, user1, user2, userStats] = await Promise.all([
wsUtils.connect(admin.accessToken),
apiUtils.userSetup(admin.accessToken, createUserDto.user1),
apiUtils.userSetup(admin.accessToken, createUserDto.user2),
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
]);
[asset1, asset2, asset3, asset4, asset5, asset6] = await Promise.all([
// asset location
assetLocation = await apiUtils.createAsset(
admin.accessToken,
{},
{
filename: 'thompson-springs.jpg',
bytes: await readFile(locationAssetFilepath),
},
);
await wsUtils.waitForEvent({ event: 'upload', assetId: assetLocation.id });
user1Assets = await Promise.all([
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(
@@ -56,10 +90,13 @@ describe('/asset', () => {
},
{ filename: 'example.mp4' },
),
apiUtils.createAsset(user2.accessToken),
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
]);
user2Assets = await Promise.all([apiUtils.createAsset(user2.accessToken)]);
await Promise.all([
// stats
apiUtils.createAsset(userStats.accessToken),
apiUtils.createAsset(userStats.accessToken, { isFavorite: true }),
@@ -77,7 +114,14 @@ describe('/asset', () => {
const person1 = await apiUtils.createPerson(user1.accessToken, {
name: 'Test Person',
});
await dbUtils.createFace({ assetId: asset1.id, personId: person1.id });
await dbUtils.createFace({
assetId: user1Assets[0].id,
personId: person1.id,
});
}, 30_000);
afterAll(() => {
wsUtils.disconnect(ws);
});
describe('GET /asset/:id', () => {
@@ -99,7 +143,7 @@ describe('/asset', () => {
it('should require access', async () => {
const { status, body } = await request(app)
.get(`/asset/${asset4.id}`)
.get(`/asset/${user2Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
@@ -107,33 +151,33 @@ describe('/asset', () => {
it('should get the asset info', async () => {
const { status, body } = await request(app)
.get(`/asset/${asset1.id}`)
.get(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: asset1.id });
expect(body).toMatchObject({ id: user1Assets[0].id });
});
it('should work with a shared link', async () => {
const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset1.id],
assetIds: [user1Assets[0].id],
});
const { status, body } = await request(app).get(
`/asset/${asset1.id}?key=${sharedLink.key}`,
`/asset/${user1Assets[0].id}?key=${sharedLink.key}`,
);
expect(status).toBe(200);
expect(body).toMatchObject({ id: asset1.id });
expect(body).toMatchObject({ id: user1Assets[0].id });
});
it('should not send people data for shared links for un-authenticated users', async () => {
const { status, body } = await request(app)
.get(`/asset/${asset1.id}`)
.get(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(200);
expect(body).toMatchObject({
id: asset1.id,
id: user1Assets[0].id,
isFavorite: false,
people: [
{
@@ -148,11 +192,11 @@ describe('/asset', () => {
const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset1.id],
assetIds: [user1Assets[0].id],
});
const data = await request(app).get(
`/asset/${asset1.id}?key=${sharedLink.key}`,
`/asset/${user1Assets[0].id}?key=${sharedLink.key}`,
);
expect(data.status).toBe(200);
expect(data.body).toMatchObject({ people: [] });
@@ -246,11 +290,11 @@ describe('/asset', () => {
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(1);
expect(assets[0].ownerId).toBe(user1.userId);
//
// assets owned by user2
expect(assets[0].id).not.toBe(asset4.id);
// assets owned by user1
expect([asset1.id, asset2.id, asset3.id]).toContain(assets[0].id);
expect([user1Assets.map(({ id }) => id)]).toContain(assets[0].id);
// assets owned by user2
expect([user1Assets.map(({ id }) => id)]).not.toContain(assets[0].id);
});
it.each(Array(10))('should return 2 random assets', async () => {
@@ -266,9 +310,9 @@ describe('/asset', () => {
for (const asset of assets) {
expect(asset.ownerId).toBe(user1.userId);
// assets owned by user1
expect([asset1.id, asset2.id, asset3.id]).toContain(asset.id);
expect([user1Assets.map(({ id }) => id)]).toContain(asset.id);
// assets owned by user2
expect(asset.id).not.toBe(asset4.id);
expect([user2Assets.map(({ id }) => id)]).not.toContain(asset.id);
}
});
@@ -280,7 +324,9 @@ describe('/asset', () => {
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: asset4.id })]);
expect(body).toEqual([
expect.objectContaining({ id: user2Assets[0].id }),
]);
},
);
@@ -312,44 +358,50 @@ describe('/asset', () => {
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/asset/${asset4.id}`)
.put(`/asset/${user2Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should favorite an asset', async () => {
const before = await apiUtils.getAssetInfo(user1.accessToken, asset1.id);
const before = await apiUtils.getAssetInfo(
user1.accessToken,
user1Assets[0].id,
);
expect(before.isFavorite).toBe(false);
const { status, body } = await request(app)
.put(`/asset/${asset1.id}`)
.put(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true });
expect(body).toMatchObject({ id: asset1.id, isFavorite: true });
expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true });
expect(status).toEqual(200);
});
it('should archive an asset', async () => {
const before = await apiUtils.getAssetInfo(user1.accessToken, asset1.id);
const before = await apiUtils.getAssetInfo(
user1.accessToken,
user1Assets[0].id,
);
expect(before.isArchived).toBe(false);
const { status, body } = await request(app)
.put(`/asset/${asset1.id}`)
.put(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isArchived: true });
expect(body).toMatchObject({ id: asset1.id, isArchived: true });
expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true });
expect(status).toEqual(200);
});
it('should update date time original', async () => {
const { status, body } = await request(app)
.put(`/asset/${asset1.id}`)
.put(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
expect(body).toMatchObject({
id: asset1.id,
id: user1Assets[0].id,
exifInfo: expect.objectContaining({
dateTimeOriginal: '2023-11-20T01:11:00.000Z',
}),
@@ -371,7 +423,7 @@ describe('/asset', () => {
{ latitude: 12, longitude: 181 },
]) {
const { status, body } = await request(app)
.put(`/asset/${asset1.id}`)
.put(`/asset/${user1Assets[0].id}`)
.send(test)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
@@ -381,12 +433,12 @@ describe('/asset', () => {
it('should update gps data', async () => {
const { status, body } = await request(app)
.put(`/asset/${asset1.id}`)
.put(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ latitude: 12, longitude: 12 });
expect(body).toMatchObject({
id: asset1.id,
id: user1Assets[0].id,
exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }),
});
expect(status).toEqual(200);
@@ -394,11 +446,11 @@ describe('/asset', () => {
it('should set the description', async () => {
const { status, body } = await request(app)
.put(`/asset/${asset1.id}`)
.put(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'Test asset description' });
expect(body).toMatchObject({
id: asset1.id,
id: user1Assets[0].id,
exifInfo: expect.objectContaining({
description: 'Test asset description',
}),
@@ -408,12 +460,12 @@ describe('/asset', () => {
it('should return tagged people', async () => {
const { status, body } = await request(app)
.put(`/asset/${asset1.id}`)
.put(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true });
expect(status).toEqual(200);
expect(body).toMatchObject({
id: asset1.id,
id: user1Assets[0].id,
isFavorite: true,
people: [
{
@@ -478,4 +530,279 @@ describe('/asset', () => {
expect(after.isTrashed).toBe(true);
});
});
describe('POST /asset/upload', () => {
const tests = [
{
input: 'formats/jpg/el_torcal_rocks.jpg',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'el_torcal_rocks',
resized: true,
exifInfo: {
dateTimeOriginal: '2012-08-05T11:39:59.000Z',
exifImageWidth: 512,
exifImageHeight: 341,
latitude: null,
longitude: null,
focalLength: 75,
iso: 200,
fNumber: 11,
exposureTime: '1/160',
fileSizeInByte: 53_493,
make: 'SONY',
model: 'DSLR-A550',
orientation: null,
description: 'SONY DSC',
},
},
},
{
input: 'formats/heic/IMG_2682.heic',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'IMG_2682',
resized: true,
fileCreatedAt: '2019-03-21T16:04:22.348Z',
exifInfo: {
dateTimeOriginal: '2019-03-21T16:04:22.348Z',
exifImageWidth: 4032,
exifImageHeight: 3024,
latitude: 41.2203,
longitude: -96.071_625,
make: 'Apple',
model: 'iPhone 7',
lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
fileSizeInByte: 880_703,
exposureTime: '1/887',
iso: 20,
focalLength: 3.99,
fNumber: 1.8,
timeZone: 'America/Chicago',
},
},
},
{
input: 'formats/png/density_plot.png',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'density_plot',
resized: true,
exifInfo: {
exifImageWidth: 800,
exifImageHeight: 800,
latitude: null,
longitude: null,
fileSizeInByte: 25_408,
},
},
},
{
input: 'formats/raw/Nikon/D80/glarus.nef',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'glarus',
resized: true,
fileCreatedAt: '2010-07-20T17:27:12.000Z',
exifInfo: {
make: 'NIKON CORPORATION',
model: 'NIKON D80',
exposureTime: '1/200',
fNumber: 10,
focalLength: 18,
iso: 100,
fileSizeInByte: 9_057_784,
dateTimeOriginal: '2010-07-20T17:27:12.000Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
},
{
input: 'formats/raw/Nikon/D700/philadelphia.nef',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'philadelphia',
resized: true,
fileCreatedAt: '2016-09-22T22:10:29.060Z',
exifInfo: {
make: 'NIKON CORPORATION',
model: 'NIKON D700',
exposureTime: '1/400',
fNumber: 11,
focalLength: 85,
iso: 200,
fileSizeInByte: 15_856_335,
dateTimeOriginal: '2016-09-22T22:10:29.060Z',
latitude: null,
longitude: null,
orientation: '1',
timeZone: 'UTC-5',
},
},
},
];
for (const { input, expected } of tests) {
it(`should generate a thumbnail for ${input}`, async () => {
const filepath = join(testAssetDir, input);
const { id, duplicate } = await apiUtils.createAsset(
admin.accessToken,
{},
{ bytes: await readFile(filepath), filename: basename(filepath) },
);
expect(duplicate).toBe(false);
await wsUtils.waitForEvent({ event: 'upload', assetId: id });
const asset = await apiUtils.getAssetInfo(admin.accessToken, id);
expect(asset.exifInfo).toBeDefined();
expect(asset.exifInfo).toMatchObject(expected.exifInfo);
expect(asset).toMatchObject(expected);
});
}
it('should handle a duplicate', async () => {
const filepath = 'formats/jpeg/el_torcal_rocks.jpeg';
const { duplicate } = await apiUtils.createAsset(
admin.accessToken,
{},
{
bytes: await readFile(join(testAssetDir, filepath)),
filename: basename(filepath),
},
);
expect(duplicate).toBe(true);
});
// These hashes were created by copying the image files to a Samsung phone,
// exporting the video from Samsung's stock Gallery app, and hashing them locally.
// This ensures that immich+exiftool are extracting the videos the same way Samsung does.
// DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives
// into the test here.
const motionTests = [
{
filepath: 'formats/motionphoto/Samsung One UI 5.jpg',
checksum: 'fr14niqCq6N20HB8rJYEvpsUVtI=',
},
{
filepath: 'formats/motionphoto/Samsung One UI 6.jpg',
checksum: 'lT9Uviw/FFJYCjfIxAGPTjzAmmw=',
},
{
filepath: 'formats/motionphoto/Samsung One UI 6.heic',
checksum: '/ejgzywvgvzvVhUYVfvkLzFBAF0=',
},
];
for (const { filepath, checksum } of motionTests) {
it(`should extract motionphoto video from ${filepath}`, async () => {
const response = await apiUtils.createAsset(
admin.accessToken,
{},
{
bytes: await readFile(join(testAssetDir, filepath)),
filename: basename(filepath),
},
);
await wsUtils.waitForEvent({ event: 'upload', assetId: response.id });
expect(response.duplicate).toBe(false);
const asset = await apiUtils.getAssetInfo(
admin.accessToken,
response.id,
);
expect(asset.livePhotoVideoId).toBeDefined();
const video = await apiUtils.getAssetInfo(
admin.accessToken,
asset.livePhotoVideoId as string,
);
expect(video.checksum).toStrictEqual(checksum);
});
}
});
describe('GET /asset/thumbnail/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/asset/thumbnail/${assetLocation.id}`,
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should not include gps data for webp thumbnails', async () => {
const { status, body, type } = await request(app)
.get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`)
.set('Authorization', `Bearer ${admin.accessToken}`);
await wsUtils.waitForEvent({
event: 'upload',
assetId: assetLocation.id,
});
expect(status).toBe(200);
expect(body).toBeDefined();
expect(type).toBe('image/webp');
const exifData = await readTags(body, 'thumbnail.webp');
expect(exifData).not.toHaveProperty('GPSLongitude');
expect(exifData).not.toHaveProperty('GPSLatitude');
});
it('should not include gps data for jpeg thumbnails', async () => {
const { status, body, type } = await request(app)
.get(`/asset/thumbnail/${assetLocation.id}?format=JPEG`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toBeDefined();
expect(type).toBe('image/jpeg');
const exifData = await readTags(body, 'thumbnail.jpg');
expect(exifData).not.toHaveProperty('GPSLongitude');
expect(exifData).not.toHaveProperty('GPSLatitude');
});
});
describe('GET /asset/file/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/asset/thumbnail/${assetLocation.id}`,
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should download the original', async () => {
const { status, body, type } = await request(app)
.get(`/asset/file/${assetLocation.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toBeDefined();
expect(type).toBe('image/jpeg');
const asset = await apiUtils.getAssetInfo(
admin.accessToken,
assetLocation.id,
);
const original = await readFile(locationAssetFilepath);
const originalChecksum = sha1(original);
const downloadChecksum = sha1(body);
expect(originalChecksum).toBe(downloadChecksum);
expect(downloadChecksum).toBe(asset.checksum);
});
});
});

View File

@@ -29,14 +29,14 @@ describe('/audit', () => {
await Promise.all([
deleteAssets(
{ assetBulkDeleteDto: { ids: [trashedAsset.id] } },
{ headers: asBearerAuth(admin.accessToken) }
{ headers: asBearerAuth(admin.accessToken) },
),
updateAsset(
{
id: archivedAsset.id,
updateAssetDto: { isArchived: true },
},
{ headers: asBearerAuth(admin.accessToken) }
{ headers: asBearerAuth(admin.accessToken) },
),
]);

View File

@@ -44,7 +44,7 @@ describe('/trash', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
await wsUtils.once(ws, 'on_asset_delete');
await wsUtils.waitForEvent({ event: 'delete', assetId });
const after = await getAllAssets(
{},

View File

@@ -2,25 +2,25 @@ import { spawn, exec } from 'child_process';
export default async () => {
let _resolve: () => unknown;
const promise = new Promise<void>((resolve) => (_resolve = resolve));
const ready = new Promise<void>((resolve) => (_resolve = resolve));
const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' });
child.stdout.on('data', (data) => {
const input = data.toString();
console.log(input);
if (input.includes('Immich Server is listening')) {
if (input.includes('Immich Microservices is listening')) {
_resolve();
}
});
child.stderr.on('data', (data) => console.log(data.toString()));
await promise;
await ready;
return async () => {
await new Promise<void>((resolve) =>
exec('docker compose down', () => resolve())
exec('docker compose down', () => resolve()),
);
};
};

View File

@@ -1,5 +1,6 @@
import {
AssetFileUploadResponseDto,
AssetResponseDto,
CreateAlbumDto,
CreateAssetDto,
CreateUserDto,
@@ -19,10 +20,12 @@ import {
updatePerson,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { exec, spawn } from 'child_process';
import { exec, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { EventEmitter } from 'node:stream';
import { promisify } from 'node:util';
import pg from 'pg';
import { io, type Socket } from 'socket.io-client';
@@ -40,6 +43,7 @@ const directoryExists = (directory: string) =>
// TODO move test assets into e2e/assets
export const testAssetDir = path.resolve(`./../server/test/assets/`);
export const tempDir = tmpdir();
const serverContainerName = 'immich-e2e-server';
const mediaDir = '/usr/src/app/upload';
@@ -47,6 +51,7 @@ const dirs = [
`"${mediaDir}/thumbs"`,
`"${mediaDir}/upload"`,
`"${mediaDir}/library"`,
`"${mediaDir}/encoded-video"`,
].join(' ');
if (!(await directoryExists(`${testAssetDir}/albums`))) {
@@ -177,33 +182,85 @@ export interface AdminSetupOptions {
onboarding?: boolean;
}
export enum SocketEvent {
UPLOAD = 'upload',
DELETE = 'delete',
}
export type EventType = 'upload' | 'delete';
export interface WaitOptions {
event: EventType;
assetId: string;
timeout?: number;
}
const events: Record<EventType, Set<string>> = {
upload: new Set<string>(),
delete: new Set<string>(),
};
const callbacks: Record<string, () => void> = {};
const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => {
events[event].add(assetId);
const callback = callbacks[assetId];
if (callback) {
callback();
delete callbacks[assetId];
}
};
export const wsUtils = {
connect: async (accessToken: string) => {
const websocket = io('http://127.0.0.1:2283', {
path: '/api/socket.io',
transports: ['websocket'],
extraHeaders: { Authorization: `Bearer ${accessToken}` },
autoConnect: false,
autoConnect: true,
forceNew: true,
});
return new Promise<Socket>((resolve) => {
websocket.on('connect', () => resolve(websocket));
websocket.connect();
websocket
.on('connect', () => resolve(websocket))
.on('on_upload_success', (data: AssetResponseDto) =>
onEvent({ event: 'upload', assetId: data.id }),
)
.on('on_asset_delete', (assetId: string) =>
onEvent({ event: 'delete', assetId }),
)
.connect();
});
},
disconnect: (ws: Socket) => {
if (ws?.connected) {
ws.disconnect();
}
for (const set of Object.values(events)) {
set.clear();
}
},
once: <T = any>(ws: Socket, event: string): Promise<T> => {
return new Promise<T>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timeout')), 4000);
ws.once(event, (data: T) => {
waitForEvent: async ({
event,
assetId,
timeout: ms,
}: WaitOptions): Promise<void> => {
const set = events[event];
if (set.has(assetId)) {
return;
}
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Timed out waiting for ${event} event`)),
ms || 5000,
);
callbacks[assetId] = () => {
clearTimeout(timeout);
resolve(data);
});
resolve();
};
});
},
};