feat(v030): sync IPC + preload (configure / test / list-conflicts / resolve / status)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import type { BackupService } from '../services/BackupService.js';
|
||||
import type { ExportService } from '../services/ExportService.js';
|
||||
import type { ImportService } from '../services/ImportService.js';
|
||||
import type { SyncService } from '../services/SyncService.js';
|
||||
import { GitClient } from '../services/GitClient.js';
|
||||
import type { TelemetryService } from '../services/TelemetryService.js';
|
||||
import type { SettingsService } from '../services/SettingsService.js';
|
||||
import { collectAutostartState } from '../services/AutostartDiagnostic.js';
|
||||
@@ -281,4 +282,78 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
// v0.3.0 Cut E — sync IPC.
|
||||
|
||||
// settings:configure-sync — URL 저장 + git init + remote add (없으면).
|
||||
// null URL → 저장만 (init 안 함). 빈 문자열도 null 처리.
|
||||
ipcMain.handle('settings:configure-sync', async (_e, url: string | null) => {
|
||||
const trimmed = typeof url === 'string' ? url.trim() : '';
|
||||
const finalUrl = trimmed.length === 0 ? null : trimmed;
|
||||
|
||||
try {
|
||||
await deps.settings.setSyncRepoUrl(finalUrl);
|
||||
} catch (e) {
|
||||
return { ok: false as const, reason: `persist failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
if (finalUrl === null) return { ok: true as const };
|
||||
|
||||
// git init + remote add origin
|
||||
const syncDir = deps.syncSvc.getSyncDir();
|
||||
const git = new GitClient(syncDir);
|
||||
|
||||
if (!(await git.isRepo())) {
|
||||
const init = await git.run(['init']);
|
||||
if (init.exitCode !== 0) {
|
||||
return { ok: false as const, reason: `git init failed: ${init.stderr}` };
|
||||
}
|
||||
}
|
||||
if (!(await git.hasRemote())) {
|
||||
const add = await git.run(['remote', 'add', 'origin', finalUrl]);
|
||||
if (add.exitCode !== 0) {
|
||||
return { ok: false as const, reason: `remote add failed: ${add.stderr}` };
|
||||
}
|
||||
} else {
|
||||
// remote exists — update URL
|
||||
const set = await git.run(['remote', 'set-url', 'origin', finalUrl]);
|
||||
if (set.exitCode !== 0) {
|
||||
return { ok: false as const, reason: `remote set-url failed: ${set.stderr}` };
|
||||
}
|
||||
}
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// settings:test-sync-connection — git ls-remote 결과
|
||||
ipcMain.handle('settings:test-sync-connection', async () => {
|
||||
const syncDir = deps.syncSvc.getSyncDir();
|
||||
const git = new GitClient(syncDir);
|
||||
if (!(await git.isRepo())) return { ok: false as const, reason: 'not_initialized' };
|
||||
const r = await git.run(['ls-remote', 'origin']);
|
||||
if (r.exitCode !== 0) return { ok: false as const, reason: r.stderr || 'connection failed' };
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// sync:list-conflicts — SyncService 캐시 결과
|
||||
ipcMain.handle('sync:list-conflicts', () => deps.syncSvc.listConflicts());
|
||||
|
||||
// sync:resolve-conflict — local/remote 2 choice
|
||||
ipcMain.handle('sync:resolve-conflict', async (_e, noteId: string, choice: 'local' | 'remote') => {
|
||||
if (choice !== 'local' && choice !== 'remote') {
|
||||
return { ok: false as const, reason: 'invalid choice' };
|
||||
}
|
||||
return deps.syncSvc.resolveConflict(noteId, choice);
|
||||
});
|
||||
|
||||
// sync:get-status — lastAt + lastResult + nextAt 계산
|
||||
ipcMain.handle('sync:get-status', async () => {
|
||||
const last = deps.syncSvc.getLastStatus();
|
||||
let nextAt: string | null = null;
|
||||
if (await deps.settings.isAutoSyncEnabled()) {
|
||||
const intervalMin = await deps.settings.getSyncIntervalMin();
|
||||
const baseMs = last.lastAt ? new Date(last.lastAt).getTime() : Date.now();
|
||||
nextAt = new Date(baseMs + intervalMin * 60 * 1000).toISOString();
|
||||
}
|
||||
return { lastAt: last.lastAt, lastResult: last.lastResult, nextAt };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,6 +88,13 @@ const api: InklingApi = {
|
||||
// v0.2.11 Cut D — search + 회고 aggregate.
|
||||
search: (query, opts) => ipcRenderer.invoke('inbox:search', query, opts ?? {}),
|
||||
reviewAggregate: (period) => ipcRenderer.invoke('inbox:review-aggregate', period),
|
||||
// v0.3.0 Cut E — 양방향 sync.
|
||||
configureSync: (url: string | null) => ipcRenderer.invoke('settings:configure-sync', url),
|
||||
testSyncConnection: () => ipcRenderer.invoke('settings:test-sync-connection'),
|
||||
listConflicts: () => ipcRenderer.invoke('sync:list-conflicts'),
|
||||
resolveConflict: (noteId: string, choice: 'local' | 'remote') =>
|
||||
ipcRenderer.invoke('sync:resolve-conflict', noteId, choice),
|
||||
getSyncStatus: () => ipcRenderer.invoke('sync:get-status'),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,29 @@ export interface ReviewAggregate {
|
||||
dueProgress: { total: number; passed: number; pending: number };
|
||||
}
|
||||
|
||||
// v0.3.0 Cut E — 양방향 sync 결과 + conflict.
|
||||
export interface SyncConflict {
|
||||
noteId: string;
|
||||
localText: string;
|
||||
remoteText: string;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
changed?: boolean;
|
||||
localSha?: string | null;
|
||||
pushed?: boolean;
|
||||
importedCount?: number;
|
||||
conflicts?: SyncConflict[];
|
||||
}
|
||||
|
||||
export interface SyncStatusSnapshot {
|
||||
lastAt: string | null;
|
||||
lastResult: SyncStatus | null;
|
||||
nextAt: string | null;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
rawText: string;
|
||||
@@ -182,6 +205,12 @@ export interface InboxApi {
|
||||
// v0.2.11 Cut D — FTS5 search + 회고 aggregate.
|
||||
search(query: string, opts?: { limit?: number; status?: NoteStatus }): Promise<Note[]>;
|
||||
reviewAggregate(period: ReviewPeriod): Promise<ReviewAggregate>;
|
||||
// v0.3.0 Cut E — 양방향 sync.
|
||||
configureSync(url: string | null): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
testSyncConnection(): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
listConflicts(): Promise<SyncConflict[]>;
|
||||
resolveConflict(noteId: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
getSyncStatus(): Promise<SyncStatusSnapshot>;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
|
||||
250
tests/unit/sync-ipc.test.ts
Normal file
250
tests/unit/sync-ipc.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() }, dialog: {}, shell: {} } }));
|
||||
vi.mock('../../src/main/services/GitClient.js');
|
||||
|
||||
import electron from 'electron';
|
||||
import { GitClient } from '../../src/main/services/GitClient.js';
|
||||
import { registerSettingsApi } from '../../src/main/ipc/settingsApi.js';
|
||||
import type { SettingsIpcDeps } from '../../src/main/ipc/settingsApi.js';
|
||||
|
||||
function getHandler(channel: string): (...args: unknown[]) => unknown {
|
||||
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
|
||||
const call = handle.mock.calls.find((c) => c[0] === channel);
|
||||
if (!call) throw new Error(`channel ${channel} not registered`);
|
||||
return call[1] as (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function makeDeps() {
|
||||
const gitInstance = {
|
||||
isRepo: vi.fn(async () => false),
|
||||
hasRemote: vi.fn(async () => false),
|
||||
run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 }))
|
||||
};
|
||||
(GitClient as unknown as ReturnType<typeof vi.fn>).mockImplementation(function () { return gitInstance; });
|
||||
|
||||
const syncSvc = {
|
||||
getSyncDir: vi.fn(() => '/tmp/sync'),
|
||||
listConflicts: vi.fn(() => [] as { noteId: string; localText: string; remoteText: string }[]),
|
||||
resolveConflict: vi.fn(async () => ({ ok: true as const })),
|
||||
getLastStatus: vi.fn(() => ({ lastAt: null as string | null, lastResult: null as { ok: boolean } | null }))
|
||||
};
|
||||
const settings = {
|
||||
getSyncRepoUrl: vi.fn(async () => 'git@host:u/r.git'),
|
||||
setSyncRepoUrl: vi.fn(async () => {}),
|
||||
isAutoSyncEnabled: vi.fn(async () => false),
|
||||
getSyncIntervalMin: vi.fn(async () => 30),
|
||||
getAll: vi.fn(async () => ({})),
|
||||
setAiEnabled: vi.fn(async () => {}),
|
||||
setOnboardingCompleted: vi.fn(async () => {}),
|
||||
isAiEnabled: vi.fn(async () => true)
|
||||
};
|
||||
|
||||
const deps: Partial<SettingsIpcDeps> = {
|
||||
backup: { runDaily: vi.fn(async () => ({ snapshotted: false })) } as never,
|
||||
exportSvc: {} as never,
|
||||
importSvc: {} as never,
|
||||
syncSvc: syncSvc as never,
|
||||
telemetry: { exportTo: vi.fn(async () => ({ eventCount: 0 })) } as never,
|
||||
settings: settings as never,
|
||||
getInboxWindow: () => null
|
||||
};
|
||||
|
||||
return { gitInstance, syncSvc, settings, deps };
|
||||
}
|
||||
|
||||
describe('sync IPC channels', () => {
|
||||
beforeEach(() => {
|
||||
(electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('5 sync channels registered', () => {
|
||||
const { deps } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
|
||||
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
|
||||
const channels = handle.mock.calls.map((c) => c[0]);
|
||||
expect(channels).toContain('settings:configure-sync');
|
||||
expect(channels).toContain('settings:test-sync-connection');
|
||||
expect(channels).toContain('sync:list-conflicts');
|
||||
expect(channels).toContain('sync:resolve-conflict');
|
||||
expect(channels).toContain('sync:get-status');
|
||||
});
|
||||
|
||||
describe('settings:configure-sync', () => {
|
||||
it('null URL → setSyncRepoUrl(null), no git init', async () => {
|
||||
const { deps, settings, gitInstance } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:configure-sync');
|
||||
const r = await h({}, null);
|
||||
expect(settings.setSyncRepoUrl).toHaveBeenCalledWith(null);
|
||||
expect(gitInstance.run).not.toHaveBeenCalled();
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('empty string URL → treated as null', async () => {
|
||||
const { deps, settings, gitInstance } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:configure-sync');
|
||||
const r = await h({}, ' ');
|
||||
expect(settings.setSyncRepoUrl).toHaveBeenCalledWith(null);
|
||||
expect(gitInstance.run).not.toHaveBeenCalled();
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('valid URL → isRepo=false → git init + remote add', async () => {
|
||||
const { deps, gitInstance } = makeDeps();
|
||||
gitInstance.isRepo.mockResolvedValue(false);
|
||||
gitInstance.hasRemote.mockResolvedValue(false);
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:configure-sync');
|
||||
const r = await h({}, 'git@github.com:user/repo.git');
|
||||
expect(gitInstance.run).toHaveBeenCalledWith(['init']);
|
||||
expect(gitInstance.run).toHaveBeenCalledWith(['remote', 'add', 'origin', 'git@github.com:user/repo.git']);
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('valid URL → isRepo=true, hasRemote=true → remote set-url', async () => {
|
||||
const { deps, gitInstance } = makeDeps();
|
||||
gitInstance.isRepo.mockResolvedValue(true);
|
||||
gitInstance.hasRemote.mockResolvedValue(true);
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:configure-sync');
|
||||
const r = await h({}, 'git@github.com:user/new-repo.git');
|
||||
expect(gitInstance.run).toHaveBeenCalledWith(['remote', 'set-url', 'origin', 'git@github.com:user/new-repo.git']);
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('git init failure → ok: false', async () => {
|
||||
const { deps, gitInstance } = makeDeps();
|
||||
gitInstance.isRepo.mockResolvedValue(false);
|
||||
gitInstance.run.mockResolvedValue({ stdout: '', stderr: 'permission denied', exitCode: 1 });
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:configure-sync');
|
||||
const r = await h({}, 'git@github.com:user/repo.git');
|
||||
expect(r).toMatchObject({ ok: false, reason: expect.stringContaining('git init failed') });
|
||||
});
|
||||
|
||||
it('setSyncRepoUrl throws → ok: false', async () => {
|
||||
const { deps, settings } = makeDeps();
|
||||
settings.setSyncRepoUrl.mockRejectedValue(new Error('disk full'));
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:configure-sync');
|
||||
const r = await h({}, 'git@github.com:user/repo.git');
|
||||
expect(r).toMatchObject({ ok: false, reason: expect.stringContaining('persist failed') });
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings:test-sync-connection', () => {
|
||||
it('not initialized → ok: false, reason: not_initialized', async () => {
|
||||
const { deps, gitInstance } = makeDeps();
|
||||
gitInstance.isRepo.mockResolvedValue(false);
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:test-sync-connection');
|
||||
const r = await h({});
|
||||
expect(r).toEqual({ ok: false, reason: 'not_initialized' });
|
||||
});
|
||||
|
||||
it('ls-remote success → ok: true', async () => {
|
||||
const { deps, gitInstance } = makeDeps();
|
||||
gitInstance.isRepo.mockResolvedValue(true);
|
||||
gitInstance.run.mockResolvedValue({ stdout: 'abc123\trefs/heads/main', stderr: '', exitCode: 0 });
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:test-sync-connection');
|
||||
const r = await h({});
|
||||
expect(gitInstance.run).toHaveBeenCalledWith(['ls-remote', 'origin']);
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('ls-remote failure → ok: false', async () => {
|
||||
const { deps, gitInstance } = makeDeps();
|
||||
gitInstance.isRepo.mockResolvedValue(true);
|
||||
gitInstance.run.mockResolvedValue({ stdout: '', stderr: 'connection refused', exitCode: 128 });
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:test-sync-connection');
|
||||
const r = await h({});
|
||||
expect(r).toMatchObject({ ok: false, reason: 'connection refused' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync:list-conflicts', () => {
|
||||
it('returns syncSvc.listConflicts() result', () => {
|
||||
const { deps, syncSvc } = makeDeps();
|
||||
const conflicts = [{ noteId: 'abc', localText: 'local', remoteText: 'remote' }];
|
||||
syncSvc.listConflicts.mockReturnValue(conflicts);
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('sync:list-conflicts');
|
||||
const r = h({});
|
||||
expect(r).toEqual(conflicts);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync:resolve-conflict', () => {
|
||||
it('valid choice "local" → delegates to syncSvc.resolveConflict', async () => {
|
||||
const { deps, syncSvc } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('sync:resolve-conflict');
|
||||
const r = await h({}, 'note-1', 'local');
|
||||
expect(syncSvc.resolveConflict).toHaveBeenCalledWith('note-1', 'local');
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('valid choice "remote" → delegates to syncSvc.resolveConflict', async () => {
|
||||
const { deps, syncSvc } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('sync:resolve-conflict');
|
||||
await h({}, 'note-2', 'remote');
|
||||
expect(syncSvc.resolveConflict).toHaveBeenCalledWith('note-2', 'remote');
|
||||
});
|
||||
|
||||
it('invalid choice → ok: false, reason: invalid choice', async () => {
|
||||
const { deps, syncSvc } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('sync:resolve-conflict');
|
||||
const r = await h({}, 'note-1', 'both');
|
||||
expect(syncSvc.resolveConflict).not.toHaveBeenCalled();
|
||||
expect(r).toEqual({ ok: false, reason: 'invalid choice' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync:get-status', () => {
|
||||
it('auto-sync disabled → nextAt: null', async () => {
|
||||
const { deps, syncSvc, settings } = makeDeps();
|
||||
settings.isAutoSyncEnabled.mockResolvedValue(false);
|
||||
syncSvc.getLastStatus.mockReturnValue({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true } });
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('sync:get-status');
|
||||
const r = await h({});
|
||||
expect(r).toMatchObject({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true }, nextAt: null });
|
||||
});
|
||||
|
||||
it('auto-sync enabled → nextAt computed from lastAt + interval', async () => {
|
||||
const { deps, syncSvc, settings } = makeDeps();
|
||||
settings.isAutoSyncEnabled.mockResolvedValue(true);
|
||||
settings.getSyncIntervalMin.mockResolvedValue(30);
|
||||
syncSvc.getLastStatus.mockReturnValue({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true } });
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('sync:get-status');
|
||||
const r = (await h({})) as { lastAt: string; nextAt: string };
|
||||
const expectedNextAt = new Date(new Date('2026-05-09T10:00:00.000Z').getTime() + 30 * 60 * 1000).toISOString();
|
||||
expect(r.nextAt).toBe(expectedNextAt);
|
||||
});
|
||||
|
||||
it('no previous sync + auto-sync enabled → nextAt based on Date.now()', async () => {
|
||||
const { deps, syncSvc, settings } = makeDeps();
|
||||
settings.isAutoSyncEnabled.mockResolvedValue(true);
|
||||
settings.getSyncIntervalMin.mockResolvedValue(15);
|
||||
syncSvc.getLastStatus.mockReturnValue({ lastAt: null, lastResult: null });
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('sync:get-status');
|
||||
const before = Date.now();
|
||||
const r = (await h({})) as { lastAt: null; nextAt: string };
|
||||
const after = Date.now();
|
||||
const nextAtMs = new Date(r.nextAt).getTime();
|
||||
expect(nextAtMs).toBeGreaterThanOrEqual(before + 15 * 60 * 1000);
|
||||
expect(nextAtMs).toBeLessThanOrEqual(after + 15 * 60 * 1000);
|
||||
expect(r.lastAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user