Files
immich/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts
T
Min Idzelis 8764a1894b feat: adaptive progressive image loading for photo viewer (#26636)
* feat(web): adaptive progressive image loading for photo viewer

Replace ImageManager with a new AdaptiveImageLoader that progressively
loads images through quality tiers (thumbnail → preview → original).

New components and utilities:
- AdaptiveImage: layered image renderer with thumbhash, thumbnail,
  preview, and original layers with visibility managed by load state
- AdaptiveImageLoader: state machine driving the quality progression
  with per-quality callbacks and error handling
- ImageLayer/Image: low-level image elements with load/error lifecycle
- PreloadManager: preloads adjacent assets for instant navigation
- AlphaBackground/DelayedLoadingSpinner: loading state UI

Zoom is handled via a derived CSS transform applied to the content
wrapper in AdaptiveImage, with the zoom library (zoomTarget: null)
only tracking state without manipulating the DOM directly.

Also adds scaleToCover to container-utils and getAssetUrls to utils.

* fix: don't partially render images in firefox

* add passive loading indicator to asset-viewer

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-11 09:48:46 -05:00

87 lines
3.1 KiB
TypeScript

import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('broken-asset responsiveness', () => {
const fixture = setupAssetViewerFixture(889);
let mockStack: MockStack;
test.beforeAll(async () => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const brokenAssets = [
createMockStackAsset(fixture.adminUserId),
createMockStackAsset(fixture.adminUserId),
createMockStackAsset(fixture.adminUserId),
];
mockStack = createMockStack(primaryAssetDto, brokenAssets);
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
});
test('broken asset in stack strip hides icon at small size', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
await expect(brokenAssets.first()).toBeVisible();
await expect(brokenAssets).toHaveCount(mockStack.brokenAssetIds.size);
for (const brokenAsset of await brokenAssets.all()) {
await expect(brokenAsset.locator('svg')).not.toBeVisible();
}
});
test('broken asset in stack strip uses text-xs class', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
await expect(brokenAssets.first()).toBeVisible();
for (const brokenAsset of await brokenAssets.all()) {
const messageSpan = brokenAsset.locator('span');
await expect(messageSpan).toHaveClass(/text-xs/);
}
});
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
await context.route(
(url) =>
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) ||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
async (route) => {
return route.fulfill({ status: 404 });
},
);
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await page.waitForSelector('#immich-asset-viewer');
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first();
await expect(viewerBrokenAsset).toBeVisible();
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
const messageSpan = viewerBrokenAsset.locator('span');
await expect(messageSpan).toHaveClass(/text-base/);
});
});