feat(sync-ui): main-process runtime, window, tray entry, CLI wiring for sync window

This commit is contained in:
2026-07-11 18:07:48 -07:00
parent 0a3f76c0a8
commit 97aaf44b3c
37 changed files with 1351 additions and 7 deletions
+269
View File
@@ -0,0 +1,269 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
import type { SyncLauncherRunHandle } from './sync-launcher-client';
import { createSyncUiRuntime, type SyncUiRuntimeDeps } from './sync-ui-runtime';
interface LauncherCall {
args: string[];
onEvent: (event: SyncProgressEvent) => void;
finish: (result: { ok: boolean; error: string | null }) => void;
cancelled: boolean;
}
function makeTestRig(root: string) {
const launcherCalls: LauncherCall[] = [];
const sent: Array<{ channel: string; payload: unknown }> = [];
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
const windowStub = {
isDestroyed: () => false,
webContents: {
send: (channel: string, payload: unknown) => sent.push({ channel, payload }),
},
};
const deps: SyncUiRuntimeDeps = {
ipcMain: {
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, listener);
},
},
hostsFilePath: path.join(root, 'sync-hosts.json'),
snapshotsDir: path.join(root, 'snapshots'),
getDbPath: () => path.join(root, 'immersion.sqlite'),
resolveLauncherCommand: () => ({ command: ['subminer'], error: null }),
runLauncher: (options): SyncLauncherRunHandle => {
let finish: (result: { ok: boolean; error: string | null }) => void = () => {};
const done = new Promise<{ ok: boolean; error: string | null }>((resolve) => {
finish = resolve;
});
const call: LauncherCall = {
args: options.args,
onEvent: options.onEvent,
finish,
cancelled: false,
};
launcherCalls.push(call);
return {
cancel: () => {
call.cancelled = true;
finish({ ok: false, error: 'Sync cancelled.' });
},
done,
};
},
getWindow: () => windowStub,
pickSnapshotFile: async () => null,
revealPath: () => {},
nowMs: () => 1700000000000,
};
const runtime = createSyncUiRuntime(deps);
runtime.registerHandlers();
const invoke = (channel: string, ...args: unknown[]): unknown => {
const handler = handlers.get(channel);
assert.ok(handler, `missing handler for ${channel}`);
return handler({}, ...args);
};
return { runtime, invoke, launcherCalls, sent, handlers };
}
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-ui-test-'));
const cleanup = () => fs.rmSync(dir, { recursive: true, force: true });
const result = fn(dir);
if (result instanceof Promise) return result.finally(cleanup);
cleanup();
return result;
}
test('registerHandlers registers every sync-ui request channel', () =>
withTempDir((root) => {
const { handlers } = makeTestRig(root);
for (const channel of [
'sync-ui:get-snapshot',
'sync-ui:save-host',
'sync-ui:remove-host',
'sync-ui:set-auto-sync-interval',
'sync-ui:run-sync',
'sync-ui:cancel-run',
'sync-ui:check-host',
'sync-ui:create-snapshot',
'sync-ui:merge-snapshot-file',
'sync-ui:delete-snapshot',
'sync-ui:reveal-snapshot',
'sync-ui:pick-snapshot-file',
]) {
assert.ok(handlers.has(channel), `missing ${channel}`);
}
}));
test('save/remove host round-trips through the hosts file', () =>
withTempDir(async (root) => {
const { invoke } = makeTestRig(root);
const saved = (await invoke('sync-ui:save-host', {
host: 'user@laptop',
label: 'Laptop',
direction: 'pull',
autoSync: true,
})) as { hosts: Array<{ host: string; direction: string; autoSync: boolean }> };
assert.equal(saved.hosts.length, 1);
assert.equal(saved.hosts[0]!.direction, 'pull');
assert.equal(saved.hosts[0]!.autoSync, true);
const removed = (await invoke('sync-ui:remove-host', 'user@laptop')) as {
hosts: unknown[];
};
assert.equal(removed.hosts.length, 0);
}));
test('save-host rejects invalid hosts', () =>
withTempDir(async (root) => {
const { invoke } = makeTestRig(root);
await assert.rejects(
async () => invoke('sync-ui:save-host', { host: '-oProxyCommand=evil' }),
/Invalid sync host/,
);
}));
test('run-sync spawns the launcher, forwards progress, and rejects concurrent runs', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls, sent } = makeTestRig(root);
const start = (await invoke('sync-ui:run-sync', {
host: 'media-box',
direction: 'push',
force: true,
})) as { started: boolean; runId: number };
assert.equal(start.started, true);
assert.deepEqual(launcherCalls[0]!.args, [
'sync',
'media-box',
'--push',
'--force',
'--json',
]);
const second = (await invoke('sync-ui:run-sync', { host: 'other' })) as {
started: boolean;
reason: string | null;
};
assert.equal(second.started, false);
assert.match(second.reason ?? '', /already running/i);
launcherCalls[0]!.onEvent({ type: 'stage', stage: 'snapshot-local', message: 'Snap' });
const progress = sent.filter((entry) => entry.channel === 'sync-ui:progress');
assert.equal(progress.length, 1);
const payload = progress[0]!.payload as { runId: number; host: string; event: unknown };
assert.equal(payload.runId, start.runId);
assert.equal(payload.host, 'media-box');
launcherCalls[0]!.finish({ ok: true, error: null });
await new Promise((resolve) => setImmediate(resolve));
assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
const third = (await invoke('sync-ui:run-sync', { host: 'media-box' })) as {
started: boolean;
};
assert.equal(third.started, true);
}));
test('run-sync saves the host so previously used hosts persist', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
await invoke('sync-ui:run-sync', { host: 'media-box', direction: 'pull' });
const snapshot = (await invoke('sync-ui:get-snapshot')) as {
hosts: { hosts: Array<{ host: string; direction: string }> };
};
assert.equal(snapshot.hosts.hosts[0]!.host, 'media-box');
assert.equal(snapshot.hosts.hosts[0]!.direction, 'pull');
launcherCalls[0]!.finish({ ok: true, error: null });
}));
test('create-snapshot runs the launcher with a timestamped path under the snapshots dir', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
const start = (await invoke('sync-ui:create-snapshot')) as { started: boolean };
assert.equal(start.started, true);
const args = launcherCalls[0]!.args;
assert.equal(args[0], 'sync');
assert.equal(args[1], '--snapshot');
assert.ok(args[2]!.startsWith(path.join(root, 'snapshots')));
assert.match(args[2]!, /immersion-.*\.sqlite$/);
assert.equal(args[3], '--json');
launcherCalls[0]!.finish({ ok: true, error: null });
}));
test('delete-snapshot refuses paths outside the snapshots dir', () =>
withTempDir(async (root) => {
const { invoke } = makeTestRig(root);
fs.mkdirSync(path.join(root, 'snapshots'), { recursive: true });
const inside = path.join(root, 'snapshots', 'immersion-1.sqlite');
fs.writeFileSync(inside, 'x');
const outside = path.join(root, 'immersion.sqlite');
fs.writeFileSync(outside, 'x');
await assert.rejects(async () => invoke('sync-ui:delete-snapshot', outside));
const remaining = (await invoke('sync-ui:delete-snapshot', inside)) as unknown[];
assert.equal(remaining.length, 0);
assert.equal(fs.existsSync(inside), false);
assert.equal(fs.existsSync(outside), true);
}));
test('check-host resolves with the check-result event', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
const pending = invoke('sync-ui:check-host', 'media-box') as Promise<{
ok: boolean;
sshOk: boolean;
}>;
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(launcherCalls[0]!.args, ['sync', 'media-box', '--check', '--json']);
launcherCalls[0]!.onEvent({
type: 'check-result',
host: 'media-box',
sshOk: true,
remoteCommand: 'subminer',
remoteVersion: '0.18.0',
ok: true,
error: null,
});
launcherCalls[0]!.finish({ ok: true, error: null });
const result = await pending;
assert.equal(result.ok, true);
assert.equal(result.sshOk, true);
}));
test('cancel-run cancels the active run', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
await invoke('sync-ui:run-sync', { host: 'media-box' });
const cancelled = (await invoke('sync-ui:cancel-run')) as boolean;
assert.equal(cancelled, true);
assert.equal(launcherCalls[0]!.cancelled, true);
}));
test('get-snapshot lists snapshot files newest first', () =>
withTempDir(async (root) => {
const { invoke } = makeTestRig(root);
const dir = path.join(root, 'snapshots');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'immersion-old.sqlite'), 'old');
fs.writeFileSync(path.join(dir, 'ignore.txt'), 'x');
fs.writeFileSync(path.join(dir, 'immersion-new.sqlite'), 'new');
fs.utimesSync(path.join(dir, 'immersion-old.sqlite'), new Date(1000), new Date(1000));
fs.utimesSync(path.join(dir, 'immersion-new.sqlite'), new Date(2000), new Date(2000));
const snapshot = (await invoke('sync-ui:get-snapshot')) as {
snapshots: Array<{ name: string }>;
dbPath: string;
run: { running: boolean };
};
assert.deepEqual(
snapshot.snapshots.map((file) => file.name),
['immersion-new.sqlite', 'immersion-old.sqlite'],
);
assert.equal(snapshot.run.running, false);
}));