Files
2026-07-13 13:20:22 +08:00

223 lines
8.5 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*
* Node-environment tests for feedbackBridge's IPC handlers.
* Covers the new feedback:capture-screenshot handler (main-process side).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { gunzipSync } from 'node:zlib';
import { app } from 'electron';
import { collectFeedbackLogAttachment } from '@/process/feedback/logs';
// Table of handlers registered via ipcMain.handle during module import.
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
type FakeWebContents = {
capturePage?: () => Promise<{ toPNG: () => Buffer }>;
};
type FakeWindow = {
isDestroyed: () => boolean;
webContents: FakeWebContents;
};
let currentWindow: FakeWindow | null = null;
vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, fn);
},
on: vi.fn(),
},
app: {
getPath: vi.fn(() => '/tmp/aionui-test-logs-nonexistent'),
getVersion: vi.fn(() => '0.0.0'),
},
BrowserWindow: {
fromWebContents: vi.fn(() => currentWindow),
},
}));
beforeEach(async () => {
handlers.clear();
currentWindow = null;
vi.resetModules();
// Importing registers the ipcMain.handle callbacks into our map.
await import('@/process/bridge/feedbackBridge');
});
afterEach(() => {
vi.clearAllMocks();
});
describe('feedbackBridge — capture-screenshot', () => {
it('registers the feedback:capture-screenshot channel on import', () => {
expect(handlers.has('feedback:capture-screenshot')).toBe(true);
});
it('returns png bytes and a timestamped filename on success', async () => {
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x01, 0x02, 0x03]);
currentWindow = {
isDestroyed: () => false,
webContents: {
capturePage: vi.fn(async () => ({ toPNG: () => pngBytes })),
},
};
const handler = handlers.get('feedback:capture-screenshot')!;
const result = (await handler({ sender: {} })) as { filename: string; data: number[] } | null;
expect(result).not.toBeNull();
expect(result!.filename).toMatch(/^screenshot-.*\.png$/);
expect(result!.data).toEqual(Array.from(pngBytes));
});
it('returns null when no owning BrowserWindow is resolved', async () => {
currentWindow = null;
const handler = handlers.get('feedback:capture-screenshot')!;
const result = await handler({ sender: {} });
expect(result).toBeNull();
});
it('returns null when the owning BrowserWindow is destroyed', async () => {
currentWindow = {
isDestroyed: () => true,
webContents: {
capturePage: vi.fn(),
},
};
const handler = handlers.get('feedback:capture-screenshot')!;
const result = await handler({ sender: {} });
expect(result).toBeNull();
expect(currentWindow.webContents.capturePage).not.toHaveBeenCalled();
});
it('returns null when capturePage yields an empty buffer', async () => {
currentWindow = {
isDestroyed: () => false,
webContents: {
capturePage: vi.fn(async () => ({ toPNG: () => Buffer.alloc(0) })),
},
};
const handler = handlers.get('feedback:capture-screenshot')!;
const result = await handler({ sender: {} });
expect(result).toBeNull();
});
it('returns null and does not throw when capturePage rejects', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
currentWindow = {
isDestroyed: () => false,
webContents: {
capturePage: vi.fn(async () => {
throw new Error('capture refused');
}),
},
};
const handler = handlers.get('feedback:capture-screenshot')!;
const result = await handler({ sender: {} });
expect(result).toBeNull();
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
});
describe('feedback logs', () => {
it('collects top-level frontend logs and nested backend logs through the IPC handler', async () => {
const logsDir = mkdtempSync(path.join(tmpdir(), 'aionui-feedback-bridge-'));
try {
const backendLogsDir = path.join(logsDir, 'logs');
mkdirSync(backendLogsDir);
writeFileSync(path.join(logsDir, '2026-05-25.log'), 'frontend renderer log\n');
writeFileSync(path.join(backendLogsDir, '2026-05-25.log'), 'backend process log\n');
writeFileSync(path.join(backendLogsDir, '2026-05-24.log'), 'second day backend log\n');
writeFileSync(path.join(backendLogsDir, '2026-05-23.log'), 'third day backend log\n');
writeFileSync(path.join(backendLogsDir, '2026-05-22.log'), 'too old backend log\n');
vi.mocked(app.getPath).mockImplementation((name: string) => {
if (name === 'logs') return logsDir;
return path.join(logsDir, 'userData');
});
const handler = handlers.get('feedback:collect-logs')!;
const result = (await handler({})) as { filename: string; data: number[] } | null;
expect(result).not.toBeNull();
const content = gunzipSync(Buffer.from(result!.data)).toString('utf8');
expect(content).toContain('frontend renderer log');
expect(content).toContain('backend process log');
expect(content).toContain('second day backend log');
expect(content).toContain('third day backend log');
expect(content).not.toContain('too old backend log');
} finally {
rmSync(logsDir, { recursive: true, force: true });
}
});
it('collects the same recent three log days used by user feedback reports', () => {
const logsDir = mkdtempSync(path.join(tmpdir(), 'aionui-feedback-logs-'));
try {
writeFileSync(path.join(logsDir, '2026-05-25.log'), 'today frontend\n');
writeFileSync(path.join(logsDir, '2026-05-25.aioncore.log'), 'today backend\n');
writeFileSync(path.join(logsDir, '2026-05-24.aionrs.log'), 'yesterday rust\n');
writeFileSync(path.join(logsDir, '2026-05-23.log'), 'third day frontend\n');
writeFileSync(path.join(logsDir, '2026-05-22.log'), 'too old frontend\n');
writeFileSync(path.join(logsDir, '2026-05-25.txt'), 'not a log\n');
const attachment = collectFeedbackLogAttachment(logsDir);
expect(attachment).not.toBeNull();
expect(attachment!.filename).toBe('logs.gz');
expect(attachment!.contentType).toBe('application/gzip');
const content = gunzipSync(attachment!.data).toString('utf8');
expect(content).toContain('today frontend');
expect(content).toContain('today backend');
expect(content).toContain('yesterday rust');
expect(content).toContain('third day frontend');
expect(content).not.toContain('too old frontend');
expect(content).not.toContain('not a log');
} finally {
rmSync(logsDir, { recursive: true, force: true });
}
});
it('collects recent logs from dated year/month/day directories', () => {
const logsDir = mkdtempSync(path.join(tmpdir(), 'aionui-feedback-dated-logs-'));
try {
const recentDir = path.join(logsDir, '2026', '07', '02');
const previousDir = path.join(logsDir, '2026', '07', '01');
const oldDir = path.join(logsDir, '2026', '06', '30');
mkdirSync(recentDir, { recursive: true });
mkdirSync(previousDir, { recursive: true });
mkdirSync(oldDir, { recursive: true });
writeFileSync(path.join(recentDir, '2026-07-02.log'), 'today frontend nested\n');
writeFileSync(path.join(recentDir, '2026-07-02.aioncore.log'), 'today backend nested\n');
writeFileSync(path.join(previousDir, '2026-07-01.aionrs.log'), 'yesterday rust nested\n');
writeFileSync(path.join(oldDir, '2026-06-30.log'), 'third day frontend nested\n');
writeFileSync(path.join(logsDir, '2026-06-29.log'), 'too old flat\n');
const attachment = collectFeedbackLogAttachment(logsDir);
expect(attachment).not.toBeNull();
const content = gunzipSync(attachment!.data).toString('utf8');
expect(content).toContain('today frontend nested');
expect(content).toContain('today backend nested');
expect(content).toContain('yesterday rust nested');
expect(content).toContain('third day frontend nested');
expect(content).not.toContain('too old flat');
expect(content).toContain('2026/07/02/2026-07-02.aioncore.log');
} finally {
rmSync(logsDir, { recursive: true, force: true });
}
});
});