chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
/**
* Unit tests for desktop/clipboard.ts — pasteText flow, backup/restore, key order.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockRead = vi.fn();
const mockWrite = vi.fn();
vi.mock('clipboardy', () => ({
default: {
read: () => mockRead(),
write: (text: string) => mockWrite(text),
},
}));
const mockPressKey = vi.fn();
const mockReleaseKey = vi.fn();
vi.mock('@nut-tree-fork/nut-js', () => ({
keyboard: {
pressKey: mockPressKey,
releaseKey: mockReleaseKey,
},
Key: {
Meta: 100,
Control: 101,
V: 102,
},
}));
const mockGetPlatformPasteKeys = vi.fn();
vi.mock('../../src/utils/platform.js', () => ({
getPlatformPasteKeys: () => mockGetPlatformPasteKeys(),
}));
import { pasteText, readClipboard, writeClipboard } from '../../src/desktop/clipboard.js';
describe('readClipboard', () => {
beforeEach(() => vi.clearAllMocks());
it('reads clipboard content', async () => {
mockRead.mockResolvedValue('existing content');
const result = await readClipboard();
expect(result).toBe('existing content');
});
});
describe('writeClipboard', () => {
beforeEach(() => vi.clearAllMocks());
it('writes text to clipboard', async () => {
mockWrite.mockResolvedValue(undefined);
await writeClipboard('new text');
expect(mockWrite).toHaveBeenCalledWith('new text');
});
});
describe('pasteText', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetPlatformPasteKeys.mockReturnValue(['Meta', 'V']);
mockRead.mockResolvedValue('backup-content');
mockWrite.mockResolvedValue(undefined);
mockPressKey.mockResolvedValue(undefined);
mockReleaseKey.mockResolvedValue(undefined);
});
it('follows full paste flow: backup → write → press → wait → restore', async () => {
await pasteText('target text');
// 1. Backup clipboard
expect(mockRead).toHaveBeenCalledTimes(1);
// 2. Write target text
expect(mockWrite).toHaveBeenNthCalledWith(1, 'target text');
// 3. Press paste keys
expect(mockPressKey).toHaveBeenCalledWith(100, 102); // Meta, V
// 4. Release in REVERSE order (V first, then Meta)
expect(mockReleaseKey).toHaveBeenCalledWith(102, 100); // V, Meta
// 5. Restore original clipboard
expect(mockWrite).toHaveBeenNthCalledWith(2, 'backup-content');
});
it('continues if backup read fails', async () => {
mockRead.mockRejectedValue(new Error('clipboard not available'));
await pasteText('text');
// Should still write and paste
expect(mockWrite).toHaveBeenCalledWith('text');
expect(mockPressKey).toHaveBeenCalled();
});
it('does not throw if restore fails', async () => {
mockRead.mockResolvedValue('backup');
// First write succeeds, second (restore) fails
mockWrite
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('restore failed'));
// Should not throw
await expect(pasteText('text')).resolves.toBeUndefined();
});
it('does not attempt restore if backup was undefined', async () => {
mockRead.mockRejectedValue(new Error('no clipboard'));
await pasteText('text');
// Only one write call (the target text), no restore
expect(mockWrite).toHaveBeenCalledTimes(1);
expect(mockWrite).toHaveBeenCalledWith('text');
});
it('uses platform-specific paste keys', async () => {
mockGetPlatformPasteKeys.mockReturnValue(['Control', 'V']);
await pasteText('text');
expect(mockPressKey).toHaveBeenCalledWith(101, 102); // Control, V
expect(mockReleaseKey).toHaveBeenCalledWith(102, 101); // V, Control (reversed)
});
});

View File

@@ -0,0 +1,294 @@
/**
* Unit tests for desktop/display.ts — macOS system_profiler parsing,
* nut.js fallback with scaleFactor detection, getDisplay, getPrimaryDisplay.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// --- Mock platform ---
const mockGetPlatform = vi.fn();
vi.mock('../../src/utils/platform.js', () => ({
getPlatform: () => mockGetPlatform(),
}));
// --- Mock logger ---
vi.mock('../../src/utils/logger.js', () => ({
logInfo: vi.fn(),
logWarn: vi.fn(),
logError: vi.fn(),
logDebug: vi.fn(),
}));
// --- Mock child_process (for macOS system_profiler) ---
const mockExecSync = vi.fn();
vi.mock('child_process', () => ({
execSync: (...args: any[]) => mockExecSync(...args),
}));
// --- Mock nut.js (for fallback) ---
const mockScreenWidth = vi.fn();
const mockScreenHeight = vi.fn();
const mockGrabRegion = vi.fn();
vi.mock('@nut-tree-fork/nut-js', () => {
class MockRegion {
constructor(
public left: number,
public top: number,
public width: number,
public height: number,
) {}
}
return {
screen: {
width: () => mockScreenWidth(),
height: () => mockScreenHeight(),
grabRegion: (r: any) => mockGrabRegion(r),
},
Region: MockRegion,
};
});
import { listDisplays, getDisplay, getPrimaryDisplay } from '../../src/desktop/display.js';
// --- Helpers: system_profiler JSON fixtures ---
function macSingleDisplay(opts: {
resolution?: string;
pixels?: string;
retina?: string;
name?: string;
main?: string;
} = {}) {
return JSON.stringify({
SPDisplaysDataType: [
{
spdisplays_ndrvs: [
{
_spdisplays_resolution: opts.resolution ?? '1440 x 900 @ 60.00Hz',
_spdisplays_pixels: opts.pixels ?? '2880 x 1800',
spdisplays_retina: opts.retina ?? 'spdisplays_yes',
_name: opts.name ?? 'Built-in Retina Display',
spdisplays_main: opts.main ?? 'spdisplays_yes',
},
],
},
],
});
}
function macDualDisplay() {
return JSON.stringify({
SPDisplaysDataType: [
{
spdisplays_ndrvs: [
{
_spdisplays_resolution: '1440 x 900 @ 60.00Hz',
_spdisplays_pixels: '2880 x 1800',
spdisplays_retina: 'spdisplays_yes',
_name: 'Built-in Retina Display',
spdisplays_main: 'spdisplays_yes',
},
{
_spdisplays_resolution: '2560 x 1440',
_spdisplays_pixels: '2560 x 1440',
spdisplays_retina: 'spdisplays_no',
_name: 'External Monitor',
},
],
},
],
});
}
describe('listDisplays', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// --- macOS via system_profiler ---
describe('macOS (system_profiler)', () => {
beforeEach(() => {
mockGetPlatform.mockReturnValue('macos');
});
it('parses single Retina display', async () => {
mockExecSync.mockReturnValue(macSingleDisplay());
const displays = await listDisplays();
expect(displays).toHaveLength(1);
expect(displays[0]).toMatchObject({
index: 0,
label: 'Built-in Retina Display',
width: 1440,
height: 900,
scaleFactor: 2,
isPrimary: true,
origin: { x: 0, y: 0 },
});
});
it('parses dual display setup', async () => {
mockExecSync.mockReturnValue(macDualDisplay());
const displays = await listDisplays();
expect(displays).toHaveLength(2);
expect(displays[0].label).toBe('Built-in Retina Display');
expect(displays[0].isPrimary).toBe(true);
expect(displays[0].scaleFactor).toBe(2);
expect(displays[1].label).toBe('External Monitor');
expect(displays[1].width).toBe(2560);
expect(displays[1].height).toBe(1440);
expect(displays[1].scaleFactor).toBe(1);
});
it('detects Retina via flag when pixel resolution missing', async () => {
mockExecSync.mockReturnValue(macSingleDisplay({
pixels: '', // no pixel resolution
retina: 'spdisplays_yes',
resolution: '1440 x 900',
}));
const displays = await listDisplays();
expect(displays).toHaveLength(1);
expect(displays[0].scaleFactor).toBe(2); // falls back to retina flag
});
it('defaults scaleFactor 1 for non-Retina without pixel info', async () => {
mockExecSync.mockReturnValue(macSingleDisplay({
pixels: '',
retina: 'spdisplays_no',
resolution: '1920 x 1080',
}));
const displays = await listDisplays();
expect(displays[0].scaleFactor).toBe(1);
});
it('falls back to nut.js when system_profiler throws', async () => {
mockExecSync.mockImplementation(() => { throw new Error('command not found'); });
mockScreenWidth.mockResolvedValue(1920);
mockScreenHeight.mockResolvedValue(1080);
mockGrabRegion.mockResolvedValue({ width: 10 });
const displays = await listDisplays();
expect(displays).toHaveLength(1);
expect(displays[0].width).toBe(1920);
expect(displays[0].scaleFactor).toBe(1);
});
it('falls back when system_profiler returns empty displays', async () => {
mockExecSync.mockReturnValue(JSON.stringify({ SPDisplaysDataType: [] }));
mockScreenWidth.mockResolvedValue(1920);
mockScreenHeight.mockResolvedValue(1080);
mockGrabRegion.mockResolvedValue({ width: 20 });
const displays = await listDisplays();
expect(displays).toHaveLength(1);
expect(displays[0].scaleFactor).toBe(2); // 20/10 = 2
});
it('uses display name from _name field', async () => {
mockExecSync.mockReturnValue(macSingleDisplay({ name: 'LG UltraFine' }));
const displays = await listDisplays();
expect(displays[0].label).toBe('LG UltraFine');
});
});
// --- Windows/Linux fallback ---
describe('fallback (nut.js)', () => {
beforeEach(() => {
mockGetPlatform.mockReturnValue('windows');
});
it('returns single display with correct dimensions', async () => {
mockScreenWidth.mockResolvedValue(1920);
mockScreenHeight.mockResolvedValue(1080);
mockGrabRegion.mockResolvedValue({ width: 10 });
const displays = await listDisplays();
expect(displays).toHaveLength(1);
expect(displays[0]).toMatchObject({
index: 0,
label: 'Primary',
width: 1920,
height: 1080,
scaleFactor: 1,
isPrimary: true,
origin: { x: 0, y: 0 },
});
});
it('detects HiDPI scaleFactor from capture', async () => {
mockScreenWidth.mockResolvedValue(1920);
mockScreenHeight.mockResolvedValue(1080);
mockGrabRegion.mockResolvedValue({ width: 20 }); // 20/10 = 2x
const displays = await listDisplays();
expect(displays[0].scaleFactor).toBe(2);
});
it('defaults scaleFactor to 1 when capture fails', async () => {
mockScreenWidth.mockResolvedValue(1920);
mockScreenHeight.mockResolvedValue(1080);
mockGrabRegion.mockRejectedValue(new Error('no display'));
const displays = await listDisplays();
expect(displays[0].scaleFactor).toBe(1);
});
it('works on Linux', async () => {
mockGetPlatform.mockReturnValue('linux');
mockScreenWidth.mockResolvedValue(2560);
mockScreenHeight.mockResolvedValue(1440);
mockGrabRegion.mockResolvedValue({ width: 10 });
const displays = await listDisplays();
expect(displays[0].width).toBe(2560);
expect(displays[0].height).toBe(1440);
});
});
});
describe('getDisplay', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetPlatform.mockReturnValue('macos');
mockExecSync.mockReturnValue(macDualDisplay());
});
it('returns display by index', async () => {
const d = await getDisplay(1);
expect(d.index).toBe(1);
expect(d.label).toBe('External Monitor');
});
it('throws DesktopError for out-of-range index', async () => {
await expect(getDisplay(5)).rejects.toThrow('Display index 5 not found');
});
});
describe('getPrimaryDisplay', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetPlatform.mockReturnValue('macos');
});
it('returns the primary display', async () => {
mockExecSync.mockReturnValue(macDualDisplay());
const d = await getPrimaryDisplay();
expect(d.isPrimary).toBe(true);
expect(d.label).toBe('Built-in Retina Display');
});
it('falls back to first display if none marked primary', async () => {
// Single display with no main flag — first display still returned
mockExecSync.mockReturnValue(macSingleDisplay({ main: '' }));
const d = await getPrimaryDisplay();
// First display is always treated as primary when displays.length === 0 before push
expect(d.index).toBe(0);
});
});

View File

@@ -0,0 +1,133 @@
/**
* Unit tests for desktop/imageSearch.ts — findImage, waitForImage.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock fs
const mockWriteFile = vi.fn();
const mockUnlink = vi.fn();
vi.mock('fs', () => ({
promises: {
writeFile: (...args: any[]) => mockWriteFile(...args),
unlink: (...args: any[]) => mockUnlink(...args),
},
}));
// Mock nut.js
const mockFind = vi.fn();
const mockLoadImage = vi.fn();
const mockScreenConfig = { confidence: 0.9 };
vi.mock('@nut-tree-fork/nut-js', () => ({
screen: {
find: (...args: any[]) => mockFind(...args),
config: mockScreenConfig,
},
loadImage: (...args: any[]) => mockLoadImage(...args),
}));
import { findImage, waitForImage } from '../../src/desktop/imageSearch.js';
describe('findImage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockWriteFile.mockResolvedValue(undefined);
mockUnlink.mockResolvedValue(undefined);
mockLoadImage.mockResolvedValue('template-obj');
});
it('returns found=true with region when match found', async () => {
mockFind.mockResolvedValue({ left: 10, top: 20, width: 50, height: 30 });
const result = await findImage('dGVzdA==', 0.85);
expect(result.found).toBe(true);
expect(result.region).toEqual({ x: 10, y: 20, width: 50, height: 30 });
expect(result.confidence).toBe(0.85);
expect(mockScreenConfig.confidence).toBe(0.85);
});
it('returns found=false when no match', async () => {
mockFind.mockRejectedValue(new Error('no match'));
const result = await findImage('dGVzdA==');
expect(result.found).toBe(false);
expect(result.region).toBeUndefined();
});
it('writes template to temp file and cleans up', async () => {
mockFind.mockResolvedValue({ left: 0, top: 0, width: 10, height: 10 });
await findImage('dGVzdA==');
expect(mockWriteFile).toHaveBeenCalledTimes(1);
expect(mockUnlink).toHaveBeenCalledTimes(1);
});
it('cleans up temp file even when match fails', async () => {
mockFind.mockRejectedValue(new Error('no match'));
await findImage('dGVzdA==');
expect(mockUnlink).toHaveBeenCalledTimes(1);
});
it('uses default confidence of 0.9', async () => {
mockFind.mockResolvedValue({ left: 0, top: 0, width: 10, height: 10 });
const result = await findImage('dGVzdA==');
expect(result.confidence).toBe(0.9);
});
});
describe('waitForImage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockWriteFile.mockResolvedValue(undefined);
mockUnlink.mockResolvedValue(undefined);
mockLoadImage.mockResolvedValue('template-obj');
});
it('returns found=true with elapsed when image found immediately', async () => {
mockFind.mockResolvedValue({ left: 5, top: 10, width: 20, height: 15 });
const result = await waitForImage('dGVzdA==', 5000, 0.8);
expect(result.found).toBe(true);
expect(result.region).toEqual({ x: 5, y: 10, width: 20, height: 15 });
expect(result.elapsed).toBeDefined();
expect(result.elapsed).toBeGreaterThanOrEqual(0);
});
it('returns found=false with elapsed after timeout', async () => {
mockFind.mockRejectedValue(new Error('no match'));
// Use a very short timeout to avoid slow tests
const result = await waitForImage('dGVzdA==', 100, 0.9);
expect(result.found).toBe(false);
expect(result.elapsed).toBeGreaterThanOrEqual(100);
});
it('cleans up temp file after completion', async () => {
mockFind.mockResolvedValue({ left: 0, top: 0, width: 10, height: 10 });
await waitForImage('dGVzdA==', 5000);
expect(mockUnlink).toHaveBeenCalledTimes(1);
});
it('cleans up temp file even after timeout', async () => {
mockFind.mockRejectedValue(new Error('no match'));
await waitForImage('dGVzdA==', 100);
expect(mockUnlink).toHaveBeenCalledTimes(1);
});
it('writes temp file only once for reuse across polls', async () => {
mockFind.mockRejectedValue(new Error('no match'));
await waitForImage('dGVzdA==', 100);
// Only 1 writeFile call despite potentially multiple polls
expect(mockWriteFile).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,111 @@
/**
* Unit tests for desktop/keyboard.ts — typeText CJK routing, pressKey, hotkey.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockType = vi.fn();
const mockPressKey = vi.fn();
const mockReleaseKey = vi.fn();
vi.mock('@nut-tree-fork/nut-js', () => ({
keyboard: {
type: mockType,
pressKey: mockPressKey,
releaseKey: mockReleaseKey,
},
Key: {
Enter: 'Enter_Val',
Tab: 'Tab_Val',
Escape: 'Escape_Val',
LeftSuper: 'LeftSuper_Val', // Meta/Command/Cmd alias
LeftControl: 'LeftControl_Val',
LeftAlt: 'LeftAlt_Val',
LeftShift: 'LeftShift_Val',
C: 'C_Val',
V: 'V_Val',
},
}));
const mockPasteText = vi.fn();
vi.mock('../../src/desktop/clipboard.js', () => ({
pasteText: (...args: any[]) => mockPasteText(...args),
}));
import { typeText, pressKey, hotkey } from '../../src/desktop/keyboard.js';
describe('typeText', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('uses nut.js type for short ASCII text', async () => {
await typeText('hello');
expect(mockType).toHaveBeenCalledWith('hello');
expect(mockPasteText).not.toHaveBeenCalled();
});
it('routes CJK text to clipboard paste', async () => {
await typeText('你好世界');
expect(mockPasteText).toHaveBeenCalledWith('你好世界');
expect(mockType).not.toHaveBeenCalled();
});
it('routes non-ASCII text to clipboard paste', async () => {
await typeText('café');
expect(mockPasteText).toHaveBeenCalledWith('café');
expect(mockType).not.toHaveBeenCalled();
});
it('routes long text (>50 chars) to clipboard paste', async () => {
const longText = 'a'.repeat(51);
await typeText(longText);
expect(mockPasteText).toHaveBeenCalledWith(longText);
expect(mockType).not.toHaveBeenCalled();
});
it('uses nut.js type for exactly 50 ASCII chars', async () => {
const text = 'a'.repeat(50);
await typeText(text);
expect(mockType).toHaveBeenCalledWith(text);
expect(mockPasteText).not.toHaveBeenCalled();
});
it('uses nut.js type for empty string', async () => {
await typeText('');
expect(mockType).toHaveBeenCalledWith('');
expect(mockPasteText).not.toHaveBeenCalled();
});
});
describe('pressKey', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('presses and releases a valid key', async () => {
await pressKey('Enter');
expect(mockPressKey).toHaveBeenCalledWith('Enter_Val');
expect(mockReleaseKey).toHaveBeenCalledWith('Enter_Val');
});
it('throws DesktopError for unknown key', async () => {
await expect(pressKey('UnknownKey')).rejects.toThrow('Unknown key: UnknownKey');
});
});
describe('hotkey', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('uses keyboard.type() for hotkey combinations', async () => {
await hotkey(['Meta', 'C']);
// Meta is aliased to LeftSuper
expect(mockType).toHaveBeenCalledWith('LeftSuper_Val', 'C_Val');
});
it('throws DesktopError for unknown key in combination', async () => {
await expect(hotkey(['Meta', 'UnknownKey'])).rejects.toThrow('Unknown key: UnknownKey');
});
});

View File

@@ -0,0 +1,212 @@
/**
* Unit tests for desktop/mouse.ts — click, doubleClick, moveTo, drag, scroll, getPosition.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockMove = vi.fn();
const mockClick = vi.fn();
const mockDoubleClick = vi.fn();
const mockPressButton = vi.fn();
const mockReleaseButton = vi.fn();
const mockScrollDown = vi.fn();
const mockScrollUp = vi.fn();
const mockScrollLeft = vi.fn();
const mockScrollRight = vi.fn();
const mockGetPosition = vi.fn();
const mockStraightTo = vi.fn();
vi.mock('@nut-tree-fork/nut-js', () => {
class MockPoint {
constructor(public x: number, public y: number) {}
}
return {
mouse: {
move: (...args: any[]) => mockMove(...args),
click: (...args: any[]) => mockClick(...args),
doubleClick: (...args: any[]) => mockDoubleClick(...args),
pressButton: (...args: any[]) => mockPressButton(...args),
releaseButton: (...args: any[]) => mockReleaseButton(...args),
scrollDown: (...args: any[]) => mockScrollDown(...args),
scrollUp: (...args: any[]) => mockScrollUp(...args),
scrollLeft: (...args: any[]) => mockScrollLeft(...args),
scrollRight: (...args: any[]) => mockScrollRight(...args),
getPosition: () => mockGetPosition(),
},
Button: { LEFT: 'LEFT', RIGHT: 'RIGHT', MIDDLE: 'MIDDLE' },
Point: MockPoint,
straightTo: (p: any) => mockStraightTo(p),
};
});
import { click, doubleClick, moveTo, drag, scroll, getPosition } from '../../src/desktop/mouse.js';
describe('click', () => {
beforeEach(() => {
vi.clearAllMocks();
mockMove.mockResolvedValue(undefined);
mockClick.mockResolvedValue(undefined);
mockStraightTo.mockImplementation((p: any) => p);
});
it('moves to position and clicks left button by default', async () => {
await click(100, 200);
expect(mockStraightTo).toHaveBeenCalledWith(expect.objectContaining({ x: 100, y: 200 }));
expect(mockMove).toHaveBeenCalled();
expect(mockClick).toHaveBeenCalledWith('LEFT');
});
it('supports right button', async () => {
await click(50, 60, 'right');
expect(mockClick).toHaveBeenCalledWith('RIGHT');
});
it('supports middle button', async () => {
await click(50, 60, 'middle');
expect(mockClick).toHaveBeenCalledWith('MIDDLE');
});
it('wraps errors as DesktopError', async () => {
mockMove.mockRejectedValue(new Error('screen locked'));
await expect(click(0, 0)).rejects.toThrow('mouse.click');
});
});
describe('doubleClick', () => {
beforeEach(() => {
vi.clearAllMocks();
mockMove.mockResolvedValue(undefined);
mockDoubleClick.mockResolvedValue(undefined);
mockStraightTo.mockImplementation((p: any) => p);
});
it('moves and double-clicks', async () => {
await doubleClick(300, 400);
expect(mockMove).toHaveBeenCalled();
expect(mockDoubleClick).toHaveBeenCalledWith('LEFT');
});
it('wraps errors as DesktopError', async () => {
mockDoubleClick.mockRejectedValue(new Error('fail'));
await expect(doubleClick(0, 0)).rejects.toThrow('mouse.doubleClick');
});
});
describe('moveTo', () => {
beforeEach(() => {
vi.clearAllMocks();
mockMove.mockResolvedValue(undefined);
mockStraightTo.mockImplementation((p: any) => p);
});
it('moves mouse to coordinates', async () => {
await moveTo(500, 600);
expect(mockStraightTo).toHaveBeenCalledWith(expect.objectContaining({ x: 500, y: 600 }));
expect(mockMove).toHaveBeenCalled();
});
it('wraps errors as DesktopError', async () => {
mockMove.mockRejectedValue(new Error('fail'));
await expect(moveTo(0, 0)).rejects.toThrow('mouse.moveTo');
});
});
describe('drag', () => {
beforeEach(() => {
vi.clearAllMocks();
mockMove.mockResolvedValue(undefined);
mockPressButton.mockResolvedValue(undefined);
mockReleaseButton.mockResolvedValue(undefined);
mockStraightTo.mockImplementation((p: any) => p);
});
it('moves to start, presses, moves to end, releases', async () => {
await drag(10, 20, 100, 200);
expect(mockMove).toHaveBeenCalledTimes(2);
expect(mockPressButton).toHaveBeenCalledWith('LEFT');
expect(mockReleaseButton).toHaveBeenCalledWith('LEFT');
// First move to start
const firstMove = mockStraightTo.mock.calls[0][0];
expect(firstMove.x).toBe(10);
expect(firstMove.y).toBe(20);
// Second move to end
const secondMove = mockStraightTo.mock.calls[1][0];
expect(secondMove.x).toBe(100);
expect(secondMove.y).toBe(200);
});
it('supports right button drag', async () => {
await drag(0, 0, 50, 50, 'right');
expect(mockPressButton).toHaveBeenCalledWith('RIGHT');
expect(mockReleaseButton).toHaveBeenCalledWith('RIGHT');
});
it('wraps errors as DesktopError', async () => {
mockPressButton.mockRejectedValue(new Error('fail'));
await expect(drag(0, 0, 10, 10)).rejects.toThrow('mouse.drag');
});
});
describe('scroll', () => {
beforeEach(() => {
vi.clearAllMocks();
mockMove.mockResolvedValue(undefined);
mockScrollDown.mockResolvedValue(undefined);
mockScrollUp.mockResolvedValue(undefined);
mockScrollLeft.mockResolvedValue(undefined);
mockScrollRight.mockResolvedValue(undefined);
mockStraightTo.mockImplementation((p: any) => p);
});
it('scrolls down with positive deltaY', async () => {
await scroll(100, 200, 5);
expect(mockMove).toHaveBeenCalled();
expect(mockScrollDown).toHaveBeenCalledWith(5);
expect(mockScrollUp).not.toHaveBeenCalled();
});
it('scrolls up with negative deltaY', async () => {
await scroll(100, 200, -3);
expect(mockScrollUp).toHaveBeenCalledWith(3);
expect(mockScrollDown).not.toHaveBeenCalled();
});
it('does not scroll vertically when deltaY is 0', async () => {
await scroll(100, 200, 0);
expect(mockScrollDown).not.toHaveBeenCalled();
expect(mockScrollUp).not.toHaveBeenCalled();
});
it('scrolls right with positive deltaX', async () => {
await scroll(100, 200, 0, 4);
expect(mockScrollRight).toHaveBeenCalledWith(4);
});
it('scrolls left with negative deltaX', async () => {
await scroll(100, 200, 0, -2);
expect(mockScrollLeft).toHaveBeenCalledWith(2);
});
it('wraps errors as DesktopError', async () => {
mockMove.mockRejectedValue(new Error('fail'));
await expect(scroll(0, 0, 1)).rejects.toThrow('mouse.scroll');
});
});
describe('getPosition', () => {
beforeEach(() => vi.clearAllMocks());
it('returns cursor position', async () => {
mockGetPosition.mockResolvedValue({ x: 350, y: 450 });
const pos = await getPosition();
expect(pos).toEqual({ x: 350, y: 450 });
});
it('wraps errors as DesktopError', async () => {
mockGetPosition.mockRejectedValue(new Error('fail'));
await expect(getPosition()).rejects.toThrow('mouse.getPosition');
});
});

View File

@@ -0,0 +1,156 @@
/**
* T2.3 — Screenshot pipeline unit tests (with mocked nut.js and sharp)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockGrabRegion = vi.fn();
const mockRegion = vi.fn().mockImplementation((left: number, top: number, width: number, height: number) => ({
left, top, width, height,
}));
const mockResize = vi.fn();
const mockJpeg = vi.fn();
const mockToBuffer = vi.fn().mockResolvedValue(Buffer.from('fake-jpeg-data'));
// Build a chainable mock that supports .clone()
function makeChain() {
const chain: any = {
resize: mockResize,
jpeg: mockJpeg,
toBuffer: mockToBuffer,
clone: vi.fn(() => makeChain()),
};
mockResize.mockReturnValue(chain);
mockJpeg.mockReturnValue(chain);
return chain;
}
// Mock sharp
vi.mock('sharp', () => {
return {
default: vi.fn(() => makeChain()),
};
});
// Mock display module
vi.mock('../../src/desktop/display.js', () => ({
getDisplay: vi.fn(),
}));
// Mock nut.js — must handle dynamic import()
vi.mock('@nut-tree-fork/nut-js', () => ({
screen: { grabRegion: mockGrabRegion },
Region: mockRegion,
}));
import { captureScreenshot } from '../../src/desktop/screenshot.js';
import { getDisplay } from '../../src/desktop/display.js';
import sharp from 'sharp';
const mockGetDisplay = vi.mocked(getDisplay);
const mockSharp = vi.mocked(sharp);
function setupDisplay(width: number, height: number, scaleFactor: number = 1) {
mockGetDisplay.mockResolvedValue({
index: 0,
label: 'Test',
width,
height,
scaleFactor,
isPrimary: true,
origin: { x: 0, y: 0 },
});
}
function setupCapture(physicalWidth: number, physicalHeight: number) {
mockGrabRegion.mockResolvedValue({
width: physicalWidth,
height: physicalHeight,
data: Buffer.alloc(physicalWidth * physicalHeight * 4),
});
}
describe('captureScreenshot', () => {
beforeEach(() => {
vi.clearAllMocks();
// Re-set defaults after clearAllMocks
mockToBuffer.mockResolvedValue(Buffer.from('fake-jpeg-data'));
});
it('1080p display: no resize needed', async () => {
setupDisplay(1920, 1080, 1);
setupCapture(1920, 1080);
const result = await captureScreenshot(0, 75);
expect(result.physicalWidth).toBe(1920);
expect(result.physicalHeight).toBe(1080);
expect(result.logicalWidth).toBe(1920);
expect(result.logicalHeight).toBe(1080);
expect(result.imageWidth).toBe(1920);
expect(result.imageHeight).toBe(1080);
expect(result.mimeType).toBe('image/jpeg');
expect(result.displayIndex).toBe(0);
expect(mockResize).toHaveBeenCalledWith(1920, 1080, { kernel: 'lanczos3' });
});
it('Retina: 2880x1800 physical, scaleFactor=2 → 1440x900 (no further resize)', async () => {
setupDisplay(1440, 900, 2);
setupCapture(2880, 1800);
const result = await captureScreenshot(0, 75);
expect(result.physicalWidth).toBe(2880);
expect(result.physicalHeight).toBe(1800);
expect(result.logicalWidth).toBe(1440);
expect(result.logicalHeight).toBe(900);
expect(result.imageWidth).toBe(1440);
expect(result.imageHeight).toBe(900);
expect(result.scaleFactor).toBe(2);
});
it('4K: 3840x2160 physical, scaleFactor=1 → resize to 1920x1080', async () => {
setupDisplay(3840, 2160, 1);
setupCapture(3840, 2160);
const result = await captureScreenshot(0, 75);
expect(result.physicalWidth).toBe(3840);
expect(result.logicalWidth).toBe(3840);
expect(result.imageWidth).toBe(1920);
expect(result.imageHeight).toBe(1080);
});
it('2K: 2560x1440 physical, scaleFactor=1 → resize to 1920x1080', async () => {
setupDisplay(2560, 1440, 1);
setupCapture(2560, 1440);
const result = await captureScreenshot(0, 75);
expect(result.logicalWidth).toBe(2560);
expect(result.imageWidth).toBe(1920);
expect(result.imageHeight).toBe(1080);
});
it('returns valid base64 string', async () => {
setupDisplay(1920, 1080, 1);
setupCapture(1920, 1080);
const result = await captureScreenshot(0, 75);
expect(typeof result.image).toBe('string');
expect(result.image.length).toBeGreaterThan(0);
expect(() => Buffer.from(result.image, 'base64')).not.toThrow();
});
it('passes jpeg quality to sharp', async () => {
setupDisplay(1920, 1080, 1);
setupCapture(1920, 1080);
await captureScreenshot(0, 50);
expect(mockJpeg).toHaveBeenCalledWith({ quality: 50 });
});
});