mirror of
https://github.com/immich-app/immich.git
synced 2025-12-11 09:13:08 +03:00
Compare commits
8 Commits
push-nklmv
...
fix/downlo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a48ca3bdf | ||
|
|
287f6d5c94 | ||
|
|
fe9125a3d1 | ||
|
|
8b31936bb6 | ||
|
|
19958dfd83 | ||
|
|
1e1cf0d1fe | ||
|
|
879e0ea131 | ||
|
|
42136f9091 |
8
.github/workflows/build-mobile.yml
vendored
8
.github/workflows/build-mobile.yml
vendored
@@ -222,6 +222,7 @@ jobs:
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
working-directory: ./mobile/ios
|
||||
|
||||
- name: Install CocoaPods dependencies
|
||||
@@ -229,13 +230,6 @@ jobs:
|
||||
run: |
|
||||
pod install
|
||||
|
||||
- name: Install Fastlane
|
||||
working-directory: ./mobile/ios
|
||||
run: |
|
||||
gem install bundler
|
||||
bundle config set --local path 'vendor/bundle'
|
||||
bundle install
|
||||
|
||||
- name: Create API Key
|
||||
env:
|
||||
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"exiftool-vendored": "^33.0.0",
|
||||
"exiftool-vendored": "^34.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"jose": "^5.6.3",
|
||||
"luxon": "^3.4.4",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { app, tempDir, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
@@ -43,13 +44,11 @@ describe('/download', () => {
|
||||
|
||||
await writeFile(`${tempDir}/archive.zip`, body);
|
||||
await utils.unzip(`${tempDir}/archive.zip`, `${tempDir}/archive`);
|
||||
const files = [
|
||||
{ filename: 'example.png', id: asset1.id },
|
||||
{ filename: 'example+1.png', id: asset2.id },
|
||||
];
|
||||
for (const { id, filename } of files) {
|
||||
const bytes = await readFile(`${tempDir}/archive/${filename}`);
|
||||
const files = [{ id: asset1.id }, { id: asset2.id }];
|
||||
for (const { id } of files) {
|
||||
const asset = await utils.getAssetInfo(admin.accessToken, id);
|
||||
const bytes = await readFile(`${tempDir}/archive/${path.basename(asset.originalPath)}`);
|
||||
|
||||
expect(utils.sha1(bytes)).toBe(asset.checksum);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,8 +27,19 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
||||
bool isAlbumTitleTextFieldFocus = false;
|
||||
Set<BaseAsset> selectedAssets = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
albumTitleController.addListener(_onTitleChanged);
|
||||
}
|
||||
|
||||
void _onTitleChanged() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
albumTitleController.removeListener(_onTitleChanged);
|
||||
albumTitleController.dispose();
|
||||
albumDescriptionController.dispose();
|
||||
albumTitleTextFieldFocusNode.dispose();
|
||||
|
||||
@@ -22,7 +22,9 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
|
||||
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
|
||||
|
||||
class AddActionButton extends ConsumerStatefulWidget {
|
||||
const AddActionButton({super.key});
|
||||
const AddActionButton({super.key, this.originalTheme});
|
||||
|
||||
final ThemeData? originalTheme;
|
||||
|
||||
@override
|
||||
ConsumerState<AddActionButton> createState() => _AddActionButtonState();
|
||||
@@ -71,7 +73,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
),
|
||||
|
||||
if (isOwner) ...[
|
||||
const PopupMenuDivider(),
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
|
||||
@@ -166,16 +168,25 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final themeData = widget.originalTheme ?? context.themeData;
|
||||
|
||||
return MenuAnchor(
|
||||
consumeOutsideTap: true,
|
||||
style: MenuStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
|
||||
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor),
|
||||
elevation: const WidgetStatePropertyAll(4),
|
||||
shape: const WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||
),
|
||||
),
|
||||
menuChildren: _buildMenuChildren(),
|
||||
menuChildren: widget.originalTheme != null
|
||||
? [
|
||||
Theme(
|
||||
data: widget.originalTheme!,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: _buildMenuChildren()),
|
||||
),
|
||||
]
|
||||
: _buildMenuChildren(),
|
||||
builder: (context, controller, child) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.add,
|
||||
|
||||
@@ -38,11 +38,13 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
opacity = 0;
|
||||
}
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
final actions = <Widget>[
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
if (asset.type == AssetType.image) const EditImageActionButton(),
|
||||
if (asset.hasRemote) const AddActionButton(),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
|
||||
if (isOwner) ...[
|
||||
asset.isLocalOnly
|
||||
|
||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@@ -244,8 +244,8 @@ importers:
|
||||
specifier: ^62.0.0
|
||||
version: 62.0.0(eslint@9.39.1(jiti@2.6.1))
|
||||
exiftool-vendored:
|
||||
specifier: ^33.0.0
|
||||
version: 33.5.0
|
||||
specifier: ^34.0.0
|
||||
version: 34.0.0
|
||||
globals:
|
||||
specifier: ^16.0.0
|
||||
version: 16.5.0
|
||||
@@ -428,8 +428,8 @@ importers:
|
||||
specifier: 4.3.3
|
||||
version: 4.3.3
|
||||
exiftool-vendored:
|
||||
specifier: ^33.0.0
|
||||
version: 33.5.0
|
||||
specifier: ^34.0.0
|
||||
version: 34.0.0
|
||||
express:
|
||||
specifier: ^5.1.0
|
||||
version: 5.2.0
|
||||
@@ -3236,6 +3236,7 @@ packages:
|
||||
'@koa/router@14.0.0':
|
||||
resolution: {integrity: sha512-LBSu5K0qAaaQcXX/0WIB9PGDevyCxxpnc1uq13vV/CgObaVxuis5hKl3Eboq/8gcb6ebnkAStW9NB/Em2eYyFA==}
|
||||
engines: {node: '>= 20'}
|
||||
deprecated: Please upgrade to v15 or higher. All reported bugs in this version are fixed in newer releases, dependencies have been updated, and security has been improved.
|
||||
|
||||
'@koddsson/eslint-plugin-tscompat@0.2.0':
|
||||
resolution: {integrity: sha512-Oqd4kWSX0LiO9wWHjcmDfXZNC7TotFV/tLRhwCFU3XUeb//KYvJ75c9OmeSJ+vBv5lkCeB+xYsqyNrBc5j18XA==}
|
||||
@@ -5503,8 +5504,8 @@ packages:
|
||||
resolution: {integrity: sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==}
|
||||
hasBin: true
|
||||
|
||||
batch-cluster@15.0.1:
|
||||
resolution: {integrity: sha512-eUmh0ld1AUPKTEmdzwGF9QTSexXAyt9rA1F5zDfW1wUi3okA3Tal4NLdCeFI6aiKpBenQhR6NmK9bW9tBHTGPQ==}
|
||||
batch-cluster@16.0.0:
|
||||
resolution: {integrity: sha512-+T7Ho09ikx/kP4P8M+GEnpuePzRQa4gTUhtPIu6ApFC8+0GY0sri1y1PuB+yfXlQWl5DkHC/e58z3U6g0qCz/A==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
batch@0.6.1:
|
||||
@@ -6848,17 +6849,17 @@ packages:
|
||||
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
exiftool-vendored.exe@13.42.0:
|
||||
resolution: {integrity: sha512-6AFybe5IakduMWleuQBfep9OWGSVZSedt2uKL+LzufRsATp+beOF7tZyKtMztjb6VRH1GF/4F9EvBVam6zm70w==}
|
||||
exiftool-vendored.exe@13.43.0:
|
||||
resolution: {integrity: sha512-EENHNz86tYY5yHGPtGB2mto3FIGstQvEhrcU34f7fm4RMxBKNfTWYOGkhU1jzvjOi+V4575LQX/FUES1TwgUbQ==}
|
||||
os: [win32]
|
||||
|
||||
exiftool-vendored.pl@13.42.0:
|
||||
resolution: {integrity: sha512-EF5IdxQNIJIvZjHf4bG4jnwAHVVSLkYZToo2q+Mm89kSuppKfRvHz/lngIxN0JALE8rFdC4zt6NWY/PKqRdCcg==}
|
||||
exiftool-vendored.pl@13.43.0:
|
||||
resolution: {integrity: sha512-0ApWaQ/pxaliPK7HzTxVA0sg/wZ8vl7UtFVhCyWhGQg01WfZkFrKwKmELB0Bnn01WTfgIuMadba8ccmFvpmJag==}
|
||||
os: ['!win32']
|
||||
hasBin: true
|
||||
|
||||
exiftool-vendored@33.5.0:
|
||||
resolution: {integrity: sha512-7cCh6izwdmC5ZaCxpHFehnExIr2Yp7CJuxHg4WFiGcm81yyxXLtvSE+85ep9VsNwhlOtSpk+XxiqrlddjY5lAw==}
|
||||
exiftool-vendored@34.0.0:
|
||||
resolution: {integrity: sha512-rhIe4XGE7kh76nwytwHtq6qK/pc1mpOBHRV++gudFeG2PfAp3XIVQbFWCLK3S4l9I4AWYOe4mxk8mW8l1oHRTw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
expect-type@1.2.1:
|
||||
@@ -17580,7 +17581,7 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.8.31: {}
|
||||
|
||||
batch-cluster@15.0.1: {}
|
||||
batch-cluster@16.0.0: {}
|
||||
|
||||
batch@0.6.1: {}
|
||||
|
||||
@@ -19128,21 +19129,21 @@ snapshots:
|
||||
signal-exit: 3.0.7
|
||||
strip-final-newline: 2.0.0
|
||||
|
||||
exiftool-vendored.exe@13.42.0:
|
||||
exiftool-vendored.exe@13.43.0:
|
||||
optional: true
|
||||
|
||||
exiftool-vendored.pl@13.42.0: {}
|
||||
exiftool-vendored.pl@13.43.0: {}
|
||||
|
||||
exiftool-vendored@33.5.0:
|
||||
exiftool-vendored@34.0.0:
|
||||
dependencies:
|
||||
'@photostructure/tz-lookup': 11.3.0
|
||||
'@types/luxon': 3.7.1
|
||||
batch-cluster: 15.0.1
|
||||
exiftool-vendored.pl: 13.42.0
|
||||
batch-cluster: 16.0.0
|
||||
exiftool-vendored.pl: 13.43.0
|
||||
he: 1.2.0
|
||||
luxon: 3.7.2
|
||||
optionalDependencies:
|
||||
exiftool-vendored.exe: 13.42.0
|
||||
exiftool-vendored.exe: 13.43.0
|
||||
|
||||
expect-type@1.2.1: {}
|
||||
|
||||
|
||||
@@ -50,13 +50,15 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \
|
||||
|
||||
FROM builder AS plugins
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /usr/local/bin/mise /usr/local/bin/mise
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY ./plugins/mise.toml ./plugins/
|
||||
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml
|
||||
ENV MISE_DATA_DIR=/buildcache/mise
|
||||
RUN --mount=type=cache,id=mise-tools,target=/buildcache/mise \
|
||||
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||
mise install --cd plugins
|
||||
|
||||
COPY ./plugins ./plugins/
|
||||
@@ -66,7 +68,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
|
||||
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
|
||||
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
|
||||
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
|
||||
--mount=type=cache,id=mise-tools,target=/buildcache/mise \
|
||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||
cd plugins && mise run build
|
||||
|
||||
FROM ghcr.io/immich-app/base-server-prod:202511261514@sha256:c04c1c38dd90e53455b180aedf93c3c63474c8d20ffe2c6d7a3a61a2181e6d29
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"cookie": "^1.0.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cron": "4.3.3",
|
||||
"exiftool-vendored": "^33.0.0",
|
||||
"exiftool-vendored": "^34.0.0",
|
||||
"express": "^5.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
|
||||
@@ -515,7 +515,7 @@ describe(AssetMediaService.name, () => {
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/original/path.jpg',
|
||||
fileName: 'asset-id.jpg',
|
||||
fileName: 'path.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import { extname } from 'node:path';
|
||||
import { basename, extname } from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { Asset } from 'src/database';
|
||||
@@ -200,7 +200,7 @@ export class AssetMediaService extends BaseService {
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: asset.originalPath,
|
||||
fileName: asset.originalFileName,
|
||||
fileName: basename(asset.originalPath),
|
||||
contentType: mimeTypes.lookup(asset.originalPath),
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
});
|
||||
|
||||
@@ -165,7 +165,7 @@ describe(DownloadService.name, () => {
|
||||
stream: archiveMock.stream,
|
||||
});
|
||||
|
||||
expect(archiveMock.addFile).toHaveBeenCalledWith('/path/to/realpath.jpg', 'IMG_123.jpg');
|
||||
expect(archiveMock.addFile).toHaveBeenCalledWith('/path/to/realpath.jpg', 'symlink.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { parse } from 'node:path';
|
||||
import { basename, parse } from 'node:path';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -94,13 +94,13 @@ export class DownloadService extends BaseService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { originalPath, originalFileName } = asset;
|
||||
const { originalPath } = asset;
|
||||
|
||||
let filename = originalFileName;
|
||||
let filename = basename(originalPath);
|
||||
const count = paths[filename] || 0;
|
||||
paths[filename] = count + 1;
|
||||
if (count !== 0) {
|
||||
const parsedFilename = parse(originalFileName);
|
||||
const parsedFilename = parse(filename);
|
||||
filename = `${parsedFilename.name}+${count}${parsedFilename.ext}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</script>
|
||||
|
||||
<tr
|
||||
class="flex h-12 w-full place-items-center border-3 border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
|
||||
class="flex w-full place-items-center border-3 border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:px-5 md:py-2"
|
||||
onclick={() => goto(resolve(`${AppRoute.ALBUMS}/${album.id}`))}
|
||||
{oncontextmenu}
|
||||
>
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
|
||||
const onMouseLeave = () => {
|
||||
mouseOver = false;
|
||||
onMouseEvent?.({ isMouseOver: false, selectedGroupIndex: groupIndex });
|
||||
};
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
|
||||
describe('CancellableTask', () => {
|
||||
describe('execute', () => {
|
||||
it('should execute task successfully and return LOADED', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async (_: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
expect(task.loading).toBe(false);
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call loadedCallback when task completes successfully', async () => {
|
||||
const loadedCallback = vi.fn();
|
||||
const task = new CancellableTask(loadedCallback);
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
expect(loadedCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should wait if task is already running', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = vi.fn(async () => {
|
||||
await taskPromise;
|
||||
});
|
||||
|
||||
const promise1 = task.execute(taskFn, true);
|
||||
const promise2 = task.execute(taskFn, true);
|
||||
|
||||
expect(task.loading).toBe(true);
|
||||
resolveTask!();
|
||||
|
||||
const [result1, result2] = await Promise.all([promise1, promise2]);
|
||||
|
||||
expect(result1).toBe('LOADED');
|
||||
expect(result2).toBe('WAITED');
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should pass AbortSignal to task function', async () => {
|
||||
const task = new CancellableTask();
|
||||
let capturedSignal: AbortSignal | null = null;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await Promise.resolve();
|
||||
capturedSignal = signal;
|
||||
};
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('should set cancellable flag correctly', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
expect(task.cancellable).toBe(true);
|
||||
const promise = task.execute(taskFn, false);
|
||||
expect(task.cancellable).toBe(false);
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should not allow transition from prevent cancel to allow cancel when task is running', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = vi.fn(async () => {
|
||||
await taskPromise;
|
||||
});
|
||||
|
||||
const promise1 = task.execute(taskFn, false);
|
||||
expect(task.cancellable).toBe(false);
|
||||
|
||||
const promise2 = task.execute(taskFn, true);
|
||||
expect(task.cancellable).toBe(false);
|
||||
|
||||
resolveTask!();
|
||||
await Promise.all([promise1, promise2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('should cancel a running task', async () => {
|
||||
const task = new CancellableTask();
|
||||
let taskStarted = false;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
taskStarted = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
|
||||
// Wait a bit to ensure task has started
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(taskStarted).toBe(true);
|
||||
|
||||
task.cancel();
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should call canceledCallback when task is canceled', async () => {
|
||||
const canceledCallback = vi.fn();
|
||||
const task = new CancellableTask(undefined, canceledCallback);
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
await promise;
|
||||
|
||||
expect(canceledCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not cancel if task is not cancellable', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
const promise = task.execute(taskFn, false);
|
||||
task.cancel();
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
});
|
||||
|
||||
it('should not cancel if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
expect(task.executed).toBe(true);
|
||||
|
||||
task.cancel();
|
||||
expect(task.executed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset task to initial state', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
expect(task.executed).toBe(true);
|
||||
|
||||
await task.reset();
|
||||
|
||||
expect(task.executed).toBe(false);
|
||||
expect(task.cancelToken).toBe(null);
|
||||
expect(task.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should cancel running task before resetting', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const resetPromise = task.reset();
|
||||
|
||||
await promise;
|
||||
await resetPromise;
|
||||
|
||||
expect(task.executed).toBe(false);
|
||||
expect(task.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow re-execution after reset', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
await task.reset();
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
expect(taskFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitUntilCompletion', () => {
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
const result = await task.waitUntilCompletion();
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
});
|
||||
|
||||
it('should return WAITED if task completes while waiting', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilCompletion();
|
||||
|
||||
resolveTask!();
|
||||
|
||||
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
|
||||
|
||||
expect(waitResult).toBe('WAITED');
|
||||
});
|
||||
|
||||
it('should return CANCELED if task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilCompletion();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
|
||||
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
|
||||
|
||||
expect(waitResult).toBe('CANCELED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitUntilExecution', () => {
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
const result = await task.waitUntilExecution();
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
});
|
||||
|
||||
it('should return WAITED if task completes successfully', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilExecution();
|
||||
|
||||
resolveTask!();
|
||||
|
||||
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
|
||||
|
||||
expect(waitResult).toBe('WAITED');
|
||||
});
|
||||
|
||||
it('should retry if task is canceled and wait for next execution', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const task = new CancellableTask();
|
||||
let attempt = 0;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
attempt++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted && attempt === 1) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
// Start first execution
|
||||
const executePromise1 = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilExecution();
|
||||
|
||||
// Cancel the first execution
|
||||
vi.advanceTimersByTime(10);
|
||||
task.cancel();
|
||||
vi.advanceTimersByTime(100);
|
||||
await executePromise1;
|
||||
|
||||
// Start second execution
|
||||
const executePromise2 = task.execute(taskFn, true);
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
const [executeResult, waitResult] = await Promise.all([executePromise2, waitPromise]);
|
||||
|
||||
expect(executeResult).toBe('LOADED');
|
||||
expect(waitResult).toBe('WAITED');
|
||||
expect(attempt).toBe(2);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return ERRORED when task throws non-abort error', async () => {
|
||||
const task = new CancellableTask();
|
||||
const error = new Error('Task failed');
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw error;
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('ERRORED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should call errorCallback when task throws non-abort error', async () => {
|
||||
const errorCallback = vi.fn();
|
||||
const task = new CancellableTask(undefined, undefined, errorCallback);
|
||||
const error = new Error('Task failed');
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw error;
|
||||
};
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
expect(errorCallback).toHaveBeenCalledTimes(1);
|
||||
expect(errorCallback).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('should return CANCELED when task throws AbortError', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow re-execution after error', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn1 = async () => {
|
||||
await Promise.resolve();
|
||||
throw new Error('Failed');
|
||||
};
|
||||
const taskFn2 = vi.fn(async () => {});
|
||||
|
||||
const result1 = await task.execute(taskFn1, true);
|
||||
expect(result1).toBe('ERRORED');
|
||||
|
||||
const result2 = await task.execute(taskFn2, true);
|
||||
expect(result2).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading property', () => {
|
||||
it('should return true when task is running', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
expect(task.loading).toBe(false);
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
expect(task.loading).toBe(true);
|
||||
|
||||
resolveTask!();
|
||||
await promise;
|
||||
|
||||
expect(task.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete promise', () => {
|
||||
it('should resolve when task completes successfully', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
const completePromise = task.complete;
|
||||
await task.execute(taskFn, true);
|
||||
await expect(completePromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject when task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const completePromise = task.complete;
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
await promise;
|
||||
|
||||
await expect(completePromise).rejects.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject when task errors', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw new Error('Failed');
|
||||
};
|
||||
|
||||
const completePromise = task.complete;
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
await expect(completePromise).rejects.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('abort signal handling', () => {
|
||||
it('should automatically call abort() on signal when task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
let capturedSignal: AbortSignal | null = null;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
capturedSignal = signal;
|
||||
// Simulate a long-running task
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
|
||||
// Wait a bit to ensure task has started
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(capturedSignal).not.toBeNull();
|
||||
expect(capturedSignal!.aborted).toBe(false);
|
||||
|
||||
// Cancel the task
|
||||
task.cancel();
|
||||
|
||||
// Verify the signal was aborted
|
||||
expect(capturedSignal!.aborted).toBe(true);
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('CANCELED');
|
||||
});
|
||||
|
||||
it('should detect if signal was aborted after task completes', async () => {
|
||||
const task = new CancellableTask();
|
||||
let controller: AbortController | null = null;
|
||||
const taskFn = async (_: AbortSignal) => {
|
||||
// Capture the controller to abort it externally
|
||||
controller = task.cancelToken;
|
||||
// Simulate some work
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
// Now abort before the function returns
|
||||
controller?.abort();
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle abort signal in async operations', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
// Simulate listening to abort signal during async operation
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
});
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('CANCELED');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,15 @@ export class CancellableTask {
|
||||
private canceledCallback?: () => void,
|
||||
private errorCallback?: (error: unknown) => void,
|
||||
) {
|
||||
this.init();
|
||||
this.complete = new Promise<void>((resolve, reject) => {
|
||||
this.loadedSignal = resolve;
|
||||
this.canceledSignal = reject;
|
||||
}).catch(
|
||||
() =>
|
||||
// if no-one waits on complete its rejected a uncaught rejection message is logged.
|
||||
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
|
||||
void 0,
|
||||
);
|
||||
}
|
||||
|
||||
get loading() {
|
||||
@@ -26,30 +34,11 @@ export class CancellableTask {
|
||||
if (this.executed) {
|
||||
return 'DONE';
|
||||
}
|
||||
// The `complete` promise resolves when executed, rejects when canceled/errored.
|
||||
try {
|
||||
const complete = this.complete;
|
||||
await complete;
|
||||
return 'WAITED';
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 'CANCELED';
|
||||
}
|
||||
|
||||
async waitUntilExecution() {
|
||||
// Keep retrying until the task completes successfully (not canceled)
|
||||
for (;;) {
|
||||
try {
|
||||
if (this.executed) {
|
||||
return 'DONE';
|
||||
}
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// if there is a cancel token, task is currently executing, so wait on the promise. If it
|
||||
// isn't, then the task is in new state, it hasn't been loaded, nor has it been executed.
|
||||
// in either case, we wait on the promise.
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
}
|
||||
|
||||
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
|
||||
@@ -91,14 +80,21 @@ export class CancellableTask {
|
||||
}
|
||||
|
||||
private init() {
|
||||
this.cancelToken = null;
|
||||
this.executed = false;
|
||||
// create a promise, and store its resolve/reject callbacks. The loadedSignal callback
|
||||
// will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal
|
||||
// callback will be called if the bucket is canceled before it was loaded, rejecting the
|
||||
// promise.
|
||||
this.complete = new Promise<void>((resolve, reject) => {
|
||||
this.cancelToken = null;
|
||||
this.executed = false;
|
||||
this.loadedSignal = resolve;
|
||||
this.canceledSignal = reject;
|
||||
});
|
||||
// Suppress unhandled rejection warning
|
||||
this.complete.catch(() => {});
|
||||
}).catch(
|
||||
() =>
|
||||
// if no-one waits on complete its rejected a uncaught rejection message is logged.
|
||||
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
|
||||
void 0,
|
||||
);
|
||||
}
|
||||
|
||||
// will reset this job back to the initial state (isLoaded=false, no errors, etc)
|
||||
|
||||
Reference in New Issue
Block a user