feat: implement launch at startup functionality in zn-ai
- Added a new setting for "launch at startup" in the GeneralSettingsPanel. - Integrated the setting with the existing settings store and IPC mechanisms. - Implemented platform-specific logic for enabling/disabling startup behavior in the main process. - Created a new service for managing launch at startup settings, including Linux desktop entry creation. - Added unit tests for the new functionality and ensured existing tests are updated accordingly. - Updated i18n messages for the new setting in English, Chinese, and Japanese.
This commit is contained in:
108
tests/general-settings-panel.test.tsx
Normal file
108
tests/general-settings-panel.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setLocale } from '../src/i18n';
|
||||
import GeneralSettingsPanel from '../src/pages/Setting/components/GeneralSettingsPanel';
|
||||
import type { GatewaySettingState } from '../src/pages/Setting/useGatewaySettingState';
|
||||
import type { SettingUpdateState } from '../src/pages/Setting/useSettingUpdateState';
|
||||
|
||||
function createGatewayState(): GatewaySettingState {
|
||||
return {
|
||||
status: 'connected',
|
||||
loading: false,
|
||||
showLogs: false,
|
||||
gatewayAutoStart: true,
|
||||
gateway: {
|
||||
ok: true,
|
||||
status: 'connected',
|
||||
initialized: true,
|
||||
mode: null,
|
||||
port: 18789,
|
||||
pid: null,
|
||||
},
|
||||
logContent: '',
|
||||
statusLoading: false,
|
||||
logLoading: false,
|
||||
restarting: false,
|
||||
autoStartUpdating: false,
|
||||
error: null,
|
||||
lastFetchedAt: null,
|
||||
refreshStatus: async () => {},
|
||||
restartGateway: async () => {},
|
||||
toggleLogs: async () => {},
|
||||
loadLogs: async () => {},
|
||||
viewLogs: async () => {},
|
||||
setGatewayAutoStart: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function createUpdateState(): SettingUpdateState {
|
||||
return {
|
||||
status: 'idle',
|
||||
currentVersion: '1.0.0',
|
||||
updateInfo: null,
|
||||
progress: null,
|
||||
error: null,
|
||||
autoCheckUpdate: true,
|
||||
autoDownloadUpdate: false,
|
||||
checkUpdate: async () => {},
|
||||
downloadUpdate: async () => {},
|
||||
installUpdate: async () => {},
|
||||
setAutoCheckUpdate: () => {},
|
||||
setAutoDownloadUpdate: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('GeneralSettingsPanel', () => {
|
||||
beforeEach(() => {
|
||||
setLocale('zh');
|
||||
});
|
||||
|
||||
it('renders the launch-at-startup setting after language and before gateway', () => {
|
||||
render(
|
||||
<GeneralSettingsPanel
|
||||
themeMode="system"
|
||||
language="zh"
|
||||
launchAtStartup={false}
|
||||
onThemeChange={async () => {}}
|
||||
onLanguageChange={async () => {}}
|
||||
onLaunchAtStartupChange={async () => {}}
|
||||
gatewayState={createGatewayState()}
|
||||
updateState={createUpdateState()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const languageLabel = screen.getByText('语言');
|
||||
const launchLabel = screen.getByText('开机自动启动');
|
||||
const gatewayLabel = screen.getByText('网关');
|
||||
|
||||
expect(languageLabel.compareDocumentPosition(launchLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(launchLabel.compareDocumentPosition(gatewayLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls the launch-at-startup handler when the switch is toggled', () => {
|
||||
const handleLaunchAtStartupChange = vi.fn();
|
||||
|
||||
render(
|
||||
<GeneralSettingsPanel
|
||||
themeMode="system"
|
||||
language="zh"
|
||||
launchAtStartup={false}
|
||||
onThemeChange={async () => {}}
|
||||
onLanguageChange={async () => {}}
|
||||
onLaunchAtStartupChange={handleLaunchAtStartupChange}
|
||||
gatewayState={createGatewayState()}
|
||||
updateState={createUpdateState()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const launchLabel = screen.getByText('开机自动启动');
|
||||
const launchRow = launchLabel.parentElement?.parentElement;
|
||||
expect(launchRow).toBeTruthy();
|
||||
|
||||
const launchSwitch = within(launchRow as HTMLElement).getByRole('switch');
|
||||
fireEvent.click(launchSwitch);
|
||||
|
||||
expect(handleLaunchAtStartupChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
155
tests/launch-at-startup.test.ts
Normal file
155
tests/launch-at-startup.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
const {
|
||||
configManagerMock,
|
||||
electronAppMock,
|
||||
loggerMock,
|
||||
mkdirMock,
|
||||
rmMock,
|
||||
setLoginItemSettingsMock,
|
||||
testHome,
|
||||
writeFileMock,
|
||||
} = vi.hoisted(() => {
|
||||
const suffix = Math.random().toString(36).slice(2);
|
||||
const setLoginItemSettingsMock = vi.fn();
|
||||
const mkdirMock = vi.fn();
|
||||
const rmMock = vi.fn();
|
||||
const writeFileMock = vi.fn();
|
||||
const configManagerMock = {
|
||||
get: vi.fn(),
|
||||
};
|
||||
const loggerMock = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const electronAppMock = {
|
||||
isPackaged: true,
|
||||
getName: () => 'zn-ai',
|
||||
getPath: (name: string) => (name === 'home' ? `/tmp/zn-ai-launch-startup-${suffix}` : '/tmp'),
|
||||
setLoginItemSettings: setLoginItemSettingsMock,
|
||||
};
|
||||
|
||||
return {
|
||||
configManagerMock,
|
||||
electronAppMock,
|
||||
loggerMock,
|
||||
mkdirMock,
|
||||
rmMock,
|
||||
setLoginItemSettingsMock,
|
||||
testHome: `/tmp/zn-ai-launch-startup-${suffix}`,
|
||||
writeFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: electronAppMock,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/service/config-service', () => ({
|
||||
default: configManagerMock,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/service/logger', () => ({
|
||||
logManager: loggerMock,
|
||||
default: loggerMock,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
mkdir: mkdirMock,
|
||||
rm: rmMock,
|
||||
writeFile: writeFileMock,
|
||||
}));
|
||||
|
||||
vi.mock('node:path', () => ({
|
||||
dirname: (path: string) => path.slice(0, path.lastIndexOf('/')) || '/',
|
||||
join: (...parts: string[]) => parts.join('/').replace(/\/+/g, '/'),
|
||||
}));
|
||||
|
||||
function setPlatform(platform: string): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform, writable: true });
|
||||
}
|
||||
|
||||
describe('launch-at-startup service', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
configManagerMock.get.mockReturnValue(false);
|
||||
electronAppMock.isPackaged = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true });
|
||||
});
|
||||
|
||||
it('uses login item settings on Windows', async () => {
|
||||
setPlatform('win32');
|
||||
const { applyLaunchAtStartupSetting } = await import('@electron/service/launch-at-startup');
|
||||
|
||||
await applyLaunchAtStartupSetting(true);
|
||||
|
||||
expect(setLoginItemSettingsMock).toHaveBeenCalledWith({
|
||||
openAtLogin: true,
|
||||
openAsHidden: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses login item settings on macOS', async () => {
|
||||
setPlatform('darwin');
|
||||
const { applyLaunchAtStartupSetting } = await import('@electron/service/launch-at-startup');
|
||||
|
||||
await applyLaunchAtStartupSetting(false);
|
||||
|
||||
expect(setLoginItemSettingsMock).toHaveBeenCalledWith({
|
||||
openAtLogin: false,
|
||||
openAsHidden: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates and removes a Linux autostart desktop entry', async () => {
|
||||
setPlatform('linux');
|
||||
const { applyLaunchAtStartupSetting } = await import('@electron/service/launch-at-startup');
|
||||
const autostartPath = `${testHome}/.config/autostart/zn-ai.desktop`;
|
||||
|
||||
await applyLaunchAtStartupSetting(true);
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith(`${testHome}/.config/autostart`, { recursive: true });
|
||||
expect(writeFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(writeFileMock).toHaveBeenCalledWith(
|
||||
autostartPath,
|
||||
expect.stringContaining('[Desktop Entry]'),
|
||||
'utf8',
|
||||
);
|
||||
expect(writeFileMock.mock.calls[0]?.[1]).toContain('Name=zn-ai');
|
||||
expect(writeFileMock.mock.calls[0]?.[1]).toContain('Exec=');
|
||||
|
||||
await applyLaunchAtStartupSetting(false);
|
||||
|
||||
expect(rmMock).toHaveBeenCalledWith(autostartPath, { force: true });
|
||||
});
|
||||
|
||||
it('syncs the persisted setting from config on startup', async () => {
|
||||
setPlatform('win32');
|
||||
configManagerMock.get.mockReturnValue(true);
|
||||
const { syncLaunchAtStartupSettingFromConfig } = await import('@electron/service/launch-at-startup');
|
||||
|
||||
await syncLaunchAtStartupSettingFromConfig();
|
||||
|
||||
expect(configManagerMock.get).toHaveBeenCalledWith('launchAtStartup');
|
||||
expect(setLoginItemSettingsMock).toHaveBeenCalledWith({
|
||||
openAtLogin: true,
|
||||
openAsHidden: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not throw on unsupported platforms', async () => {
|
||||
setPlatform('freebsd');
|
||||
const { applyLaunchAtStartupSetting } = await import('@electron/service/launch-at-startup');
|
||||
|
||||
await expect(applyLaunchAtStartupSetting(true)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user