n8n/packages/@n8n/fs-proxy/src/tools/screenshot/screenshot.test.ts
oleg 629826ca1d
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.14.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (25.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Util: Sync API Docs / sync-public-api (push) Waiting to run
feat: Instance AI and local gateway modules (no-changelog) (#27206)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Albert Alises <albert.alises@gmail.com>
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Dimitri Lavrenük <20122620+dlavrenuek@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Tuukka Kantola <Tuukkaa@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com>
Co-authored-by: Elias Meire <elias@meire.dev>
Co-authored-by: Dimitri Lavrenük <dimitri.lavrenuek@n8n.io>
Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
Co-authored-by: Mutasem Aldmour <mutasem@n8n.io>
2026-04-01 21:33:38 +03:00

280 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Monitor } from 'node-screenshots';
import { ScreenshotModule } from './index';
import { screenshotTool, screenshotRegionTool } from './screenshot';
jest.mock('node-screenshots');
const mockSharp = jest.fn<unknown, unknown[]>();
jest.mock('sharp', () => ({
__esModule: true,
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
default: (...args: unknown[]) => mockSharp(...args),
}));
const MockMonitor = Monitor as jest.MockedClass<typeof Monitor>;
const DUMMY_CONTEXT = { dir: '/test/base' };
interface MockImage {
width: number;
height: number;
toRaw: jest.Mock;
crop: jest.Mock;
}
function makeMockImage(width = 1920, height = 1080, rawData = 'fake-raw-bytes'): MockImage {
const image: MockImage = {
width,
height,
toRaw: jest.fn().mockResolvedValue(Buffer.from(rawData)),
crop: jest.fn(),
};
// Default crop returns a new cropped image
// eslint-disable-next-line @typescript-eslint/promise-function-async
image.crop.mockImplementation((_x: number, _y: number, w: number, h: number) =>
Promise.resolve({
width: w,
height: h,
toRaw: jest.fn().mockResolvedValue(Buffer.from(`cropped-${w}x${h}`)),
crop: jest.fn(),
}),
);
return image;
}
interface MockMonitorInstance {
isPrimary: jest.Mock;
x: jest.Mock;
y: jest.Mock;
width: jest.Mock;
height: jest.Mock;
scaleFactor: jest.Mock;
captureImage: jest.Mock;
}
function makeMockMonitor(opts: {
isPrimary?: boolean;
x?: number;
y?: number;
width?: number;
height?: number;
scaleFactor?: number;
image?: MockImage;
}): MockMonitorInstance {
const image = opts.image ?? makeMockImage();
return {
isPrimary: jest.fn().mockReturnValue(opts.isPrimary ?? false),
x: jest.fn().mockReturnValue(opts.x ?? 0),
y: jest.fn().mockReturnValue(opts.y ?? 0),
width: jest.fn().mockReturnValue(opts.width ?? 1920),
height: jest.fn().mockReturnValue(opts.height ?? 1080),
scaleFactor: jest.fn().mockReturnValue(opts.scaleFactor ?? 1.0),
captureImage: jest.fn().mockResolvedValue(image),
};
}
beforeEach(() => {
// sharp(buffer, opts)[.resize()].jpeg().toBuffer() → fake JPEG
const mockToBuffer = jest.fn().mockResolvedValue(Buffer.from('fake-jpeg'));
const mockJpeg = jest.fn().mockReturnValue({ toBuffer: mockToBuffer });
const mockResize = jest.fn();
const pipeline = { resize: mockResize, jpeg: mockJpeg };
mockResize.mockReturnValue(pipeline);
mockSharp.mockReturnValue(pipeline);
});
describe('screen_screenshot tool', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns base64 JPEG as media content for primary monitor', async () => {
const monitor = makeMockMonitor({ isPrimary: true, width: 1920, height: 1080 });
(MockMonitor.all as jest.Mock).mockReturnValue([monitor]);
const result = await screenshotTool.execute({}, DUMMY_CONTEXT);
expect(result.content).toHaveLength(1);
const imageBlock = result.content[0];
expect(imageBlock.type).toBe('image');
expect(imageBlock).toHaveProperty('data', Buffer.from('fake-jpeg').toString('base64'));
expect(imageBlock).toHaveProperty('mimeType', 'image/jpeg');
});
it('uses the primary monitor when multiple monitors are available', async () => {
const secondary = makeMockMonitor({ isPrimary: false, x: 1920 });
const primary = makeMockMonitor({ isPrimary: true, x: 0 });
(MockMonitor.all as jest.Mock).mockReturnValue([secondary, primary]);
await screenshotTool.execute({}, DUMMY_CONTEXT);
expect(primary.captureImage).toHaveBeenCalled();
expect(secondary.captureImage).not.toHaveBeenCalled();
});
it('throws when no monitors are available', async () => {
(MockMonitor.all as jest.Mock).mockReturnValue([]);
await expect(screenshotTool.execute({}, DUMMY_CONTEXT)).rejects.toThrow(
'No monitors available',
);
});
it('resizes the image to logical dimensions on HiDPI (Retina 2x) displays', async () => {
// Physical image is 2x the logical monitor dimensions
const image = makeMockImage(3840, 2160);
const monitor = makeMockMonitor({
isPrimary: true,
width: 1920,
height: 1080,
scaleFactor: 2.0,
image,
});
(MockMonitor.all as jest.Mock).mockReturnValue([monitor]);
await screenshotTool.execute({}, DUMMY_CONTEXT);
const pipeline = mockSharp.mock.results[0].value as { resize: jest.Mock };
expect(pipeline.resize).toHaveBeenCalledWith(1920, 1080);
});
it('downscales to max 1024px when physical dimensions match logical dimensions', async () => {
const monitor = makeMockMonitor({
isPrimary: true,
width: 1920,
height: 1080,
scaleFactor: 1.0,
});
(MockMonitor.all as jest.Mock).mockReturnValue([monitor]);
await screenshotTool.execute({}, DUMMY_CONTEXT);
const pipeline = mockSharp.mock.results[0].value as { resize: jest.Mock };
// No HiDPI resize, but LLM downscale kicks in (1920x1080 → 1024x576)
expect(pipeline.resize).toHaveBeenCalledWith(1024, 576);
});
});
describe('screen_screenshot_region tool', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns cropped image data as media content', async () => {
const monitor = makeMockMonitor({ isPrimary: true, x: 0, y: 0, width: 1920, height: 1080 });
(MockMonitor.all as jest.Mock).mockReturnValue([monitor]);
const result = await screenshotRegionTool.execute(
{ x: 100, y: 200, width: 400, height: 300 },
DUMMY_CONTEXT,
);
expect(result.content).toHaveLength(1);
const imageBlock = result.content[0];
expect(imageBlock.type).toBe('image');
expect(imageBlock).toHaveProperty('mimeType', 'image/jpeg');
expect(imageBlock).toHaveProperty('data');
});
it('translates absolute screen coords to monitor-relative coordinates', async () => {
const image = makeMockImage(2560, 1440);
const monitor = makeMockMonitor({
isPrimary: true,
x: 1920,
y: 100,
width: 2560,
height: 1440,
image,
});
(MockMonitor.all as jest.Mock).mockReturnValue([monitor]);
await screenshotRegionTool.execute({ x: 2000, y: 200, width: 300, height: 200 }, DUMMY_CONTEXT);
// relX = 2000 - 1920 = 80, relY = 200 - 100 = 100
expect(image.crop).toHaveBeenCalledWith(80, 100, 300, 200);
});
it('clamps relX/relY to zero when coordinates fall before monitor origin', async () => {
const image = makeMockImage(1920, 1080);
const monitor = makeMockMonitor({
isPrimary: true,
x: 500,
y: 500,
width: 1920,
height: 1080,
image,
});
(MockMonitor.all as jest.Mock).mockReturnValue([monitor]);
await screenshotRegionTool.execute({ x: 100, y: 100, width: 200, height: 150 }, DUMMY_CONTEXT);
// relX = max(0, 100 - 500) = 0, relY = max(0, 100 - 500) = 0
expect(image.crop).toHaveBeenCalledWith(0, 0, expect.any(Number), expect.any(Number));
});
it('scales crop coordinates to physical pixels on HiDPI displays', async () => {
// Retina 2x: logical 1920x1080, physical 3840x2160
const image = makeMockImage(3840, 2160);
const monitor = makeMockMonitor({
isPrimary: true,
x: 0,
y: 0,
width: 1920,
height: 1080,
scaleFactor: 2.0,
image,
});
(MockMonitor.all as jest.Mock).mockReturnValue([monitor]);
// Input in logical pixels: x=100, y=200, w=400, h=300
await screenshotRegionTool.execute({ x: 100, y: 200, width: 400, height: 300 }, DUMMY_CONTEXT);
// Crop must be in physical pixels (×2)
expect(image.crop).toHaveBeenCalledWith(200, 400, 800, 600);
});
it('resizes cropped image back to logical dimensions on HiDPI displays', async () => {
const image = makeMockImage(3840, 2160);
const monitor = makeMockMonitor({
isPrimary: true,
x: 0,
y: 0,
width: 1920,
height: 1080,
scaleFactor: 2.0,
image,
});
(MockMonitor.all as jest.Mock).mockReturnValue([monitor]);
await screenshotRegionTool.execute({ x: 100, y: 200, width: 400, height: 300 }, DUMMY_CONTEXT);
// Cropped image (800×600 physical) must be resized to logical 400×300
const pipeline = mockSharp.mock.results[0].value as { resize: jest.Mock };
expect(pipeline.resize).toHaveBeenCalledWith(400, 300);
});
});
describe('ScreenshotModule.isSupported', () => {
afterEach(() => {
jest.clearAllMocks();
});
it.each([
['has monitors', [{}], true],
['returns empty array', [], false],
])('returns %s -> %s', async (_label, monitorList, expected) => {
(MockMonitor.all as jest.Mock).mockReturnValue(monitorList);
await expect(ScreenshotModule.isSupported()).resolves.toBe(expected);
});
it('returns false when Monitor.all() throws', async () => {
(MockMonitor.all as jest.Mock).mockImplementation(() => {
throw new Error('Display server unavailable');
});
await expect(ScreenshotModule.isSupported()).resolves.toBe(false);
});
});