Files
eleith 9691ea7bce
ci/woodpecker/pr/calendar Pipeline was successful
ci/woodpecker/pr/cli Pipeline was successful
ci/woodpecker/pr/config Pipeline was successful
ci/woodpecker/pr/db Pipeline was successful
ci/woodpecker/pr/jobs Pipeline was successful
ci/woodpecker/pr/video-chat Pipeline was successful
ci/woodpecker/pr/web Pipeline was successful
ci/woodpecker/pr/worker Pipeline was successful
ci/woodpecker/push/calendar Pipeline was successful
ci/woodpecker/push/cli Pipeline was successful
ci/woodpecker/push/config Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/jobs Pipeline was successful
ci/woodpecker/push/video-chat Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/worker Pipeline was successful
perf(web): generate avatar thumbnails automatically
2026-11-03 06:29:22 -08:00

157 lines
5.7 KiB
TypeScript

import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { parse, stringify } from 'yaml';
import { Renderer } from 'takumi-js/node';
import { avatarThumbnailUrl } from '../src/lib/avatar.ts';
import { expect, test, type Page } from './support/test.ts';
import { workerDirectory } from './support/paths.ts';
import { seedAppointment } from './support/seed.ts';
const SOURCE_PATH = '/public/avatar source test.png';
const ORIGINAL_URL = '/avatar/public/avatar%20source%20test.png';
const THUMBNAIL_URL = `${ORIGINAL_URL}?thumbnail=true`;
const DEFAULT_AVATAR_URL = '/avatar/assets/images/avatar.svg?thumbnail=true';
test.use({ javaScriptEnabled: false });
async function setAvatarSource(page: Page, sourcePath: string) {
const configPath = join(workerDirectory(), 'config/when.yaml');
const config = parse(await readFile(configPath, 'utf8'));
config.user.appearance = { ...config.user.appearance, avatar_path: sourcePath };
await writeFile(configPath, stringify(config));
const expectedUrl = avatarThumbnailUrl(sourcePath).replaceAll('&', '&');
await expect.poll(async () => (await page.request.get('/')).text()).toContain(expectedUrl);
}
async function withRasterAvatar(page: Page, run: (source: Buffer) => Promise<void>) {
const directory = workerDirectory();
const configPath = join(directory, 'config/when.yaml');
const originalConfig = await readFile(configPath, 'utf8');
const sourceFile = join(directory, 'public/avatar source test.png');
const source = await new Renderer().render(
{ type: 'container', style: { width: 800, height: 800, backgroundColor: '#166534' } },
{ width: 800, height: 800, format: 'png' }
);
await mkdir(join(directory, 'public'), { recursive: true });
try {
await writeFile(sourceFile, source);
await setAvatarSource(page, SOURCE_PATH);
await run(source);
} finally {
await writeFile(configPath, originalConfig);
await expect
.poll(async () => (await page.request.get('/')).text())
.toContain(DEFAULT_AVATAR_URL);
await rm(sourceFile, { force: true });
}
}
async function expectPageAvatars(page: Page, count: number) {
const avatars = page.getByRole('img', { name: 'E2E Fixture', exact: true, includeHidden: true });
await expect(avatars).toHaveCount(count);
for (const avatar of await avatars.all()) {
await expect(avatar).toHaveAttribute('src', THUMBNAIL_URL);
}
await expect
.poll(() =>
avatars.evaluateAll((images) =>
images.every((image) => {
const img = image as HTMLImageElement;
return img.complete && img.naturalWidth === 216 && img.naturalHeight === 216;
})
)
)
.toBe(true);
}
test('default SVG avatars stay vector images', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('img', { name: 'E2E Fixture', exact: true })).toHaveAttribute(
'src',
DEFAULT_AVATAR_URL
);
const response = await page.request.get(DEFAULT_AVATAR_URL);
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toBe('image/svg+xml');
});
test('original and thumbnail variants preserve their bytes and cache validators', async ({
page
}) => {
await withRasterAvatar(page, async (source) => {
const thumbnail = await page.request.get(THUMBNAIL_URL);
expect(thumbnail.status()).toBe(200);
expect(thumbnail.headers()['content-type']).toBe('image/webp');
expect((await thumbnail.body()).byteLength).toBeLessThan(source.byteLength);
const original = await page.request.get(ORIGINAL_URL);
expect(original.headers()['content-type']).toBe('image/png');
expect(await original.body()).toEqual(source);
const explicitOriginal = await page.request.get(`${ORIGINAL_URL}?thumbnail=false`);
expect(await explicitOriginal.body()).toEqual(source);
for (const [url, response] of [
[ORIGINAL_URL, original],
[THUMBNAIL_URL, thumbnail]
] as const) {
const notModified = await page.request.get(url, {
headers: { 'if-none-match': response.headers()['etag'] }
});
expect(notModified.status()).toBe(304);
}
});
});
test('pages download thumbnails without JavaScript on mobile and desktop', async ({ page }) => {
await withRasterAvatar(page, async () => {
const imageRequests: string[] = [];
page.on('request', (request) => {
if (request.resourceType() === 'image') imageRequests.push(new URL(request.url()).pathname);
});
const appointment = await seedAppointment();
const pages = [
{ path: '/', avatars: 1 },
{ path: '/schedule/chat', avatars: 2 },
{
path: `/appointment/${appointment.id}/reschedule?token=${appointment.cancel_token}`,
avatars: 2
}
];
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 900 });
for (const scenario of pages) {
await page.goto(scenario.path);
await expectPageAvatars(page, scenario.avatars);
}
}
expect(imageRequests).toContain(ORIGINAL_URL);
expect(imageRequests).not.toContain('/public/avatar%20source%20test.png');
});
});
test('avatar paths cannot select another public image or recurse into this route', async ({
page
}) => {
await withRasterAvatar(page, async (source) => {
const otherFile = join(workerDirectory(), 'public/not-configured.png');
try {
await writeFile(otherFile, source);
expect((await page.request.get('/public/not-configured.png')).status()).toBe(200);
for (const search of ['', '?thumbnail=true']) {
expect(
(await page.request.get(`/avatar/public/not-configured.png${search}`)).status()
).toBe(404);
}
expect((await page.request.get('/avatar')).status()).toBe(404);
await setAvatarSource(page, '/%61vatar');
for (const search of ['', '?thumbnail=true']) {
const rejected = await page.request.get(`/avatar/%61vatar${search}`, { timeout: 5_000 });
expect(rejected.status()).toBe(404);
}
} finally {
await rm(otherFile, { force: true });
}
});
});