chore: initialize qiming workspace repository
This commit is contained in:
@@ -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)
|
||||
});
|
||||
});
|
||||
294
qimingclaw/crates/agent-gui-server/tests/desktop/display.test.ts
Normal file
294
qimingclaw/crates/agent-gui-server/tests/desktop/display.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
212
qimingclaw/crates/agent-gui-server/tests/desktop/mouse.test.ts
Normal file
212
qimingclaw/crates/agent-gui-server/tests/desktop/mouse.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user