Files
redefined-designs/backend/tests/unit/imageRotation.test.ts
T
bermudalambandClaude Opus 5 3659608abe test(images): release the sharp handle before teardown (#301)
libvips caches input file mappings in memory after a pipeline finishes. On Windows, this keeps an open handle on the input file, and the OS refuses to delete a file with an open handle. The animated-WebP test triggers a pipeline rejection (correctly refusing a multi-page rotation), so the mapping stays in cache and afterEach cannot remove the test directory.

Disabling the cache costs these tests nothing: each file is read exactly once during its test, so there is no reuse to cache. With caching disabled, Windows can delete the input files and afterEach succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:40:18 -05:00

162 lines
5.6 KiB
TypeScript

import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import sharp from 'sharp';
import { rotateInPlace } from '../../src/imageProcessing';
// libvips keeps an input file mapped in its operation cache after a pipeline
// finishes, and Windows will not delete a file that still has an open handle —
// so the animated-WebP case, which is the one that ends in a rejection, left
// afterEach unable to remove its own temporary directory. Disabling the cache
// costs these six tests nothing: every one of them reads its file exactly once.
sharp.cache(false);
let dir = '';
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rotate-'));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
/**
* A square image, left half red and right half blue.
*
* Square on purpose. Dimensions swap whichever way a quarter turn goes, so a
* rectangle proves that something rotated but says nothing about which way —
* only the pixels can say that, and this is the smallest picture that makes
* the direction visible.
*/
async function halvesPng(): Promise<Buffer> {
const red = await sharp({
create: { width: 100, height: 200, channels: 3, background: { r: 255, g: 0, b: 0 } }
})
.png()
.toBuffer();
const blue = await sharp({
create: { width: 100, height: 200, channels: 3, background: { r: 0, g: 0, b: 255 } }
})
.png()
.toBuffer();
return sharp({
create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 0, b: 0 } }
})
.composite([
{ input: red, left: 0, top: 0 },
{ input: blue, left: 100, top: 0 }
])
.png()
.toBuffer();
}
/** The colour a little way in from the top-left corner, clear of any edge. */
async function topLeft(file: string): Promise<string> {
const { data, info } = await sharp(file).raw().toBuffer({ resolveWithObject: true });
const at = (10 * info.width + 10) * info.channels;
return [data[at], data[at + 1], data[at + 2]].join(',');
}
/**
* A two-frame animated WebP, built rather than committed as a binary fixture so
* what it contains is readable here. `pageHeight` is what makes libvips treat
* one tall buffer as a strip of frames.
*/
async function animatedWebp(): Promise<Buffer> {
const width = 40;
const height = 40;
const raw = Buffer.alloc(width * height * 2 * 4);
for (let i = 0; i < width * height * 4; i += 4) {
raw[i] = 255;
raw[i + 3] = 255;
}
for (let i = width * height * 4; i < raw.length; i += 4) {
raw[i + 2] = 255;
raw[i + 3] = 255;
}
return sharp(raw, { raw: { width, height: height * 2, channels: 4, pageHeight: height } })
.webp()
.toBuffer();
}
describe('rotateInPlace', () => {
it('turns a landscape photo into a portrait one', async () => {
const file = path.join(dir, 'wide.jpg');
await fs.writeFile(
file,
await sharp({
create: { width: 400, height: 200, channels: 3, background: { r: 20, g: 60, b: 120 } }
})
.jpeg()
.toBuffer()
);
await rotateInPlace(file, 'image/jpeg', 'right');
const after = await sharp(file).metadata();
expect(after.width).toBe(200);
expect(after.height).toBe(400);
});
// The direction mapping, which is the one thing here that can be exactly
// backwards while every dimension assertion still passes. Turning clockwise
// moves the left edge to the top, so the red half ends up along the top.
it('turns right clockwise', async () => {
const file = path.join(dir, 'halves.png');
await fs.writeFile(file, await halvesPng());
await rotateInPlace(file, 'image/png', 'right');
expect(await topLeft(file)).toBe('255,0,0');
});
it('turns left anticlockwise', async () => {
const file = path.join(dir, 'halves.png');
await fs.writeFile(file, await halvesPng());
await rotateInPlace(file, 'image/png', 'left');
expect(await topLeft(file)).toBe('0,0,255');
});
// Format is preserved for the reason #226 preserved it: image_path carries
// the extension, so changing the format here would leave the row pointing at
// a file that no longer exists.
it('keeps the file in its own format', async () => {
const file = path.join(dir, 'halves.png');
await fs.writeFile(file, await halvesPng());
await rotateInPlace(file, 'image/png', 'left');
expect((await sharp(file).metadata()).format).toBe('png');
});
// The whole reason for the temp-file-and-rename: a failure must leave the
// photo exactly as it was, and must not leave a stray file behind either.
it('leaves the file untouched when sharp cannot read it', async () => {
const file = path.join(dir, 'broken.jpg');
await fs.writeFile(file, 'not an image');
await expect(rotateInPlace(file, 'image/jpeg', 'right')).rejects.toThrow();
expect(await fs.readFile(file, 'utf8')).toBe('not an image');
expect(await fs.readdir(dir)).toEqual(['broken.jpg']);
});
// The trap this guards. Reading an animated WebP without `animated: true`
// succeeds and yields the first frame alone, so a rotation that omitted the
// flag would write back a still and destroy the uploader's animation while
// reporting success. With the flag, sharp refuses — which is the correct
// answer, because a quarter turn of a multi-page image is not something it
// can do.
it('refuses an animated WebP rather than flattening it', async () => {
const file = path.join(dir, 'moving.webp');
await fs.writeFile(file, await animatedWebp());
await expect(rotateInPlace(file, 'image/webp', 'right')).rejects.toThrow(/multi-page/i);
expect((await sharp(file, { animated: true }).metadata()).pages).toBe(2);
});
});