Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
623 lines
16 KiB
TypeScript
623 lines
16 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { mergeSyncPayloads } from "./syncMerge.ts";
|
|
import { withSyncReliabilityMeta } from "./syncReliability.ts";
|
|
import type { SyncPayload } from "./sync.ts";
|
|
|
|
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
|
|
return {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
snippetPackages: [],
|
|
notes: [],
|
|
noteGroups: [],
|
|
portForwardingRules: [],
|
|
groupConfigs: [],
|
|
settings: undefined,
|
|
syncedAt: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const knownHosts = (n: number): SyncPayload["knownHosts"] =>
|
|
Array.from({ length: n }, (_, i) => ({
|
|
id: `kh-${i}`,
|
|
hostname: `host-${i}.example.com`,
|
|
port: 22,
|
|
keyType: "ssh-ed25519",
|
|
publicKey: `SHA256:${i}`,
|
|
discoveredAt: 1,
|
|
}));
|
|
|
|
test("mergeSyncPayloads does not carry legacy known hosts forward", () => {
|
|
const result = mergeSyncPayloads(
|
|
payload({ knownHosts: knownHosts(2) }),
|
|
payload(),
|
|
payload({ knownHosts: knownHosts(3) }),
|
|
);
|
|
|
|
assert.equal("knownHosts" in result.payload, false);
|
|
});
|
|
|
|
test("mergeSyncPayloads merges reusable proxy profiles by id", () => {
|
|
const localProfile = {
|
|
id: "proxy-local",
|
|
label: "Local Proxy",
|
|
config: { type: "http", host: "local.example.com", port: 3128 },
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const remoteProfile = {
|
|
id: "proxy-remote",
|
|
label: "Remote Proxy",
|
|
config: { type: "socks5", host: "remote.example.com", port: 1080 },
|
|
createdAt: 2,
|
|
updatedAt: 2,
|
|
};
|
|
|
|
const result = mergeSyncPayloads(
|
|
payload(),
|
|
payload({ proxyProfiles: [localProfile] } as Partial<SyncPayload>),
|
|
payload({ proxyProfiles: [remoteProfile] } as Partial<SyncPayload>),
|
|
);
|
|
|
|
assert.deepEqual(result.payload.proxyProfiles?.map((item) => item.id).sort(), [
|
|
"proxy-local",
|
|
"proxy-remote",
|
|
]);
|
|
});
|
|
|
|
test("mergeSyncPayloads keeps local and remote notes", () => {
|
|
const result = mergeSyncPayloads(
|
|
payload(),
|
|
payload({
|
|
notes: [{
|
|
id: "local",
|
|
title: "Local",
|
|
content: "",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
}],
|
|
noteGroups: ["Local"],
|
|
}),
|
|
payload({
|
|
notes: [{
|
|
id: "remote",
|
|
title: "Remote",
|
|
content: "",
|
|
createdAt: 1,
|
|
updatedAt: 3,
|
|
}],
|
|
noteGroups: ["Remote"],
|
|
}),
|
|
);
|
|
|
|
assert.deepEqual(result.payload.notes?.map((note) => note.id).sort(), ["local", "remote"]);
|
|
assert.deepEqual(result.payload.noteGroups?.sort(), ["Local", "Remote"]);
|
|
});
|
|
|
|
test("mergeSyncPayloads preserves proxy profiles when remote payload predates them", () => {
|
|
const proxy = {
|
|
id: "proxy-1",
|
|
label: "Office Proxy",
|
|
config: { type: "http", host: "proxy.example.com", port: 3128 },
|
|
createdAt: 1,
|
|
};
|
|
|
|
const result = mergeSyncPayloads(
|
|
payload({ proxyProfiles: [proxy] } as Partial<SyncPayload>),
|
|
payload({ proxyProfiles: [proxy] } as Partial<SyncPayload>),
|
|
payload(),
|
|
);
|
|
|
|
assert.deepEqual(result.payload.proxyProfiles, [proxy]);
|
|
});
|
|
|
|
test("mergeSyncPayloads keeps missing proxy references visible to connection guards", () => {
|
|
const result = mergeSyncPayloads(
|
|
payload({
|
|
hosts: [{
|
|
id: "host-1",
|
|
label: "Host",
|
|
hostname: "example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux",
|
|
proxyProfileId: "proxy-1",
|
|
}],
|
|
proxyProfiles: [{
|
|
id: "proxy-1",
|
|
label: "Old Proxy",
|
|
config: { type: "http", host: "old.example.com", port: 3128 },
|
|
createdAt: 1,
|
|
}],
|
|
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
|
|
}),
|
|
payload({
|
|
hosts: [{
|
|
id: "host-1",
|
|
label: "Host",
|
|
hostname: "example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux",
|
|
proxyProfileId: "proxy-1",
|
|
}],
|
|
proxyProfiles: [],
|
|
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
|
|
}),
|
|
payload({
|
|
hosts: [{
|
|
id: "host-1",
|
|
label: "Host",
|
|
hostname: "example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux",
|
|
proxyProfileId: "proxy-1",
|
|
}],
|
|
proxyProfiles: [],
|
|
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
|
|
}),
|
|
);
|
|
|
|
assert.equal(result.payload.hosts[0]?.proxyProfileId, "proxy-1");
|
|
assert.equal(result.payload.groupConfigs?.[0]?.proxyProfileId, "proxy-1");
|
|
});
|
|
|
|
test("mergeSyncPayloads honors remote deletion records when base is unavailable", () => {
|
|
const result = mergeSyncPayloads(
|
|
null,
|
|
payload({
|
|
hosts: [{
|
|
id: "host-1",
|
|
label: "Stale local copy",
|
|
hostname: "old.example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux",
|
|
}],
|
|
}),
|
|
payload({
|
|
syncMeta: {
|
|
schemaVersion: 1,
|
|
generatedAt: 123,
|
|
localChanged: true,
|
|
deletions: [{
|
|
entityType: "hosts",
|
|
id: "host-1",
|
|
deletedAt: 123,
|
|
deviceId: "remote-device",
|
|
}],
|
|
changeSummary: {
|
|
hasLocalChanges: true,
|
|
hasRemoteChanges: false,
|
|
hasConflicts: false,
|
|
byEntity: {},
|
|
conflicts: [],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
assert.deepEqual(result.payload.hosts, []);
|
|
assert.equal(result.summary.deleted.remote, 1);
|
|
});
|
|
|
|
test("mergeSyncPayloads carries deletion records forward after applying a tombstone", () => {
|
|
const remote = payload({
|
|
syncMeta: {
|
|
schemaVersion: 1,
|
|
generatedAt: 123,
|
|
localChanged: true,
|
|
deletions: [{
|
|
entityType: "hosts",
|
|
id: "host-1",
|
|
deletedAt: 123,
|
|
deviceId: "remote-device",
|
|
}],
|
|
changeSummary: {
|
|
hasLocalChanges: true,
|
|
hasRemoteChanges: false,
|
|
hasConflicts: false,
|
|
byEntity: {},
|
|
conflicts: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = mergeSyncPayloads(
|
|
null,
|
|
payload({
|
|
hosts: [{
|
|
id: "host-1",
|
|
label: "Stale local copy",
|
|
hostname: "old.example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux",
|
|
}],
|
|
}),
|
|
remote,
|
|
);
|
|
const enriched = withSyncReliabilityMeta(result.payload, null, {
|
|
deviceId: "local-device",
|
|
now: 456,
|
|
});
|
|
|
|
assert.deepEqual(enriched.syncMeta?.deletions, [{
|
|
entityType: "hosts",
|
|
id: "host-1",
|
|
deletedAt: 123,
|
|
deviceId: "remote-device",
|
|
}]);
|
|
});
|
|
|
|
test("mergeSyncPayloads adopts cloud settings on the first merge without a base", () => {
|
|
const result = mergeSyncPayloads(
|
|
null,
|
|
payload({
|
|
settings: {
|
|
theme: "dark",
|
|
terminalFontSize: 14,
|
|
terminalSettings: {
|
|
cursorBlink: true,
|
|
copyOnSelect: false,
|
|
},
|
|
},
|
|
}),
|
|
payload({
|
|
settings: {
|
|
theme: "light",
|
|
terminalFontSize: 18,
|
|
terminalSettings: {
|
|
cursorBlink: false,
|
|
copyOnSelect: true,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
assert.deepEqual(result.payload.settings, {
|
|
theme: "light",
|
|
terminalFontSize: 18,
|
|
terminalSettings: {
|
|
cursorBlink: false,
|
|
copyOnSelect: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("mergeSyncPayloads preserves one-sided settings on the first merge", () => {
|
|
const result = mergeSyncPayloads(
|
|
null,
|
|
payload({
|
|
settings: {
|
|
customCSS: ".terminal { opacity: 0.9; }",
|
|
terminalSettings: { copyOnSelect: true },
|
|
},
|
|
}),
|
|
payload({
|
|
settings: {
|
|
theme: "system",
|
|
terminalSettings: { cursorBlink: false },
|
|
},
|
|
}),
|
|
);
|
|
|
|
assert.deepEqual(result.payload.settings, {
|
|
customCSS: ".terminal { opacity: 0.9; }",
|
|
theme: "system",
|
|
terminalSettings: {
|
|
copyOnSelect: true,
|
|
cursorBlink: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("mergeSyncPayloads honors empty cloud setting maps as resets on the first merge", () => {
|
|
const result = mergeSyncPayloads(
|
|
null,
|
|
payload({
|
|
settings: {
|
|
customKeyBindings: {
|
|
copy: { mac: "meta+c", pc: "ctrl+c" },
|
|
},
|
|
ai: {
|
|
agentModelMap: { codex: "gpt-local" },
|
|
agentProviderMap: { codex: "openai-local" },
|
|
activeModelId: "local-model",
|
|
},
|
|
},
|
|
}),
|
|
payload({
|
|
settings: {
|
|
customKeyBindings: {},
|
|
ai: {
|
|
agentModelMap: {},
|
|
agentProviderMap: {},
|
|
activeProviderId: "cloud-provider",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
assert.deepEqual(result.payload.settings, {
|
|
customKeyBindings: {},
|
|
ai: {
|
|
agentModelMap: {},
|
|
agentProviderMap: {},
|
|
activeModelId: "local-model",
|
|
activeProviderId: "cloud-provider",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("mergeSyncPayloads retains unique nested setting entries while cloud wins duplicate ids", () => {
|
|
const result = mergeSyncPayloads(
|
|
null,
|
|
payload({
|
|
settings: {
|
|
ai: {
|
|
providers: [
|
|
{ id: "shared", name: "Local shared" },
|
|
{ id: "local-only", name: "Local only" },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
payload({
|
|
settings: {
|
|
ai: {
|
|
providers: [
|
|
{ id: "shared", name: "Cloud shared" },
|
|
{ id: "cloud-only", name: "Cloud only" },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
assert.deepEqual(result.payload.settings?.ai?.providers, [
|
|
{ id: "shared", name: "Cloud shared" },
|
|
{ id: "cloud-only", name: "Cloud only" },
|
|
{ id: "local-only", name: "Local only" },
|
|
]);
|
|
});
|
|
|
|
test("mergeSyncPayloads keeps local settings conflict policy when a base exists", () => {
|
|
const base = payload({
|
|
settings: {
|
|
theme: "system",
|
|
terminalSettings: { cursorBlink: true },
|
|
},
|
|
});
|
|
const result = mergeSyncPayloads(
|
|
base,
|
|
payload({
|
|
settings: {
|
|
theme: "dark",
|
|
terminalSettings: { cursorBlink: false },
|
|
},
|
|
}),
|
|
payload({
|
|
settings: {
|
|
theme: "light",
|
|
terminalSettings: { cursorBlink: true },
|
|
},
|
|
}),
|
|
);
|
|
|
|
assert.deepEqual(result.payload.settings, {
|
|
theme: "dark",
|
|
terminalSettings: { cursorBlink: false },
|
|
});
|
|
});
|
|
|
|
test("mergeSyncPayloads carries plugin sidecars through without dropping remote baselines", () => {
|
|
const local: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
pluginSidecars: {
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.local.plugin",
|
|
kind: "settings",
|
|
key: "com.local.plugin.theme\0application\0application",
|
|
value: "dark",
|
|
updatedAt: 5,
|
|
}],
|
|
},
|
|
};
|
|
const remote: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 2,
|
|
pluginSidecars: {
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.remote.plugin",
|
|
kind: "account_baseline",
|
|
key: "account",
|
|
value: { id: "acct-r" },
|
|
updatedAt: 9,
|
|
}],
|
|
},
|
|
};
|
|
const result = mergeSyncPayloads(null, local, remote);
|
|
assert.ok(result.payload.pluginSidecars);
|
|
assert.equal(result.payload.pluginSidecars!.entries.length, 2);
|
|
assert.ok(result.payload.pluginSidecars!.entries.some((e) => e.pluginId === "com.local.plugin"));
|
|
assert.ok(result.payload.pluginSidecars!.entries.some((e) => e.kind === "account_baseline"));
|
|
});
|
|
|
|
test("mergeSyncPayloads propagates local sidecar setting resets via three-way merge", () => {
|
|
const entry = {
|
|
pluginId: "com.example.p",
|
|
kind: "settings" as const,
|
|
key: "com.example.p.theme\0application\0application",
|
|
value: "dark",
|
|
updatedAt: 1,
|
|
};
|
|
const base: SyncPayload = {
|
|
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
|
syncedAt: 1,
|
|
pluginSidecars: { version: 1, entries: [entry] },
|
|
};
|
|
const local: SyncPayload = {
|
|
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
|
syncedAt: 2,
|
|
// user reset: explicit empty sidecar bundle (omitted field = legacy/unsupported)
|
|
pluginSidecars: { version: 1, entries: [] },
|
|
};
|
|
const remote: SyncPayload = {
|
|
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
|
syncedAt: 2,
|
|
pluginSidecars: { version: 1, entries: [entry] },
|
|
};
|
|
const result = mergeSyncPayloads(base, local, remote);
|
|
assert.equal(
|
|
result.payload.pluginSidecars?.entries?.some((e) => e.key === entry.key) ?? false,
|
|
false,
|
|
"local reset must not be resurrected from base/remote",
|
|
);
|
|
assert.ok(
|
|
Object.prototype.hasOwnProperty.call(result.payload, "pluginSidecars"),
|
|
"explicit empty sidecar field must remain so apply clears remote/local DB",
|
|
);
|
|
assert.deepEqual(result.payload.pluginSidecars, { version: 1, entries: [] });
|
|
});
|
|
|
|
test("mergeSyncPayloads preserves sidecars when remote legacy payload omits the field", () => {
|
|
const entry = {
|
|
pluginId: "com.example.p",
|
|
kind: "settings" as const,
|
|
key: "com.example.p.theme\0application\0application",
|
|
value: "dark",
|
|
updatedAt: 1,
|
|
};
|
|
const base: SyncPayload = {
|
|
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
|
syncedAt: 1,
|
|
pluginSidecars: { version: 1, entries: [entry] },
|
|
};
|
|
const local: SyncPayload = {
|
|
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
|
syncedAt: 2,
|
|
pluginSidecars: { version: 1, entries: [entry] },
|
|
};
|
|
const remote: SyncPayload = {
|
|
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
|
syncedAt: 2,
|
|
// legacy remote omits pluginSidecars entirely
|
|
};
|
|
const result = mergeSyncPayloads(base, local, remote);
|
|
assert.ok(result.payload.pluginSidecars?.entries?.some((e) => e.key === entry.key));
|
|
});
|
|
|
|
test("mergeSyncPayloads treats missing optional arrays as legacy payloads, not deletions", () => {
|
|
const identity = {
|
|
id: "identity-1",
|
|
label: "Prod",
|
|
username: "root",
|
|
authMethod: "password" as const,
|
|
created: 1,
|
|
};
|
|
const rule = {
|
|
id: "rule-1",
|
|
name: "Web",
|
|
hostId: "host-1",
|
|
type: "local" as const,
|
|
localHost: "127.0.0.1",
|
|
localPort: 8080,
|
|
remoteHost: "127.0.0.1",
|
|
remotePort: 80,
|
|
enabled: true,
|
|
createdAt: 1,
|
|
};
|
|
|
|
const base = payload({
|
|
identities: [identity],
|
|
snippetPackages: ["ops"],
|
|
portForwardingRules: [rule],
|
|
groupConfigs: [{ path: "prod", username: "root" }],
|
|
});
|
|
const local = payload({
|
|
identities: [identity],
|
|
snippetPackages: ["ops"],
|
|
portForwardingRules: [rule],
|
|
groupConfigs: [{ path: "prod", username: "root" }],
|
|
});
|
|
const remote = payload();
|
|
delete remote.identities;
|
|
delete remote.snippetPackages;
|
|
delete remote.portForwardingRules;
|
|
delete remote.groupConfigs;
|
|
|
|
const result = mergeSyncPayloads(base, local, remote);
|
|
|
|
assert.deepEqual(result.payload.identities, [identity]);
|
|
assert.deepEqual(result.payload.snippetPackages, ["ops"]);
|
|
assert.deepEqual(result.payload.portForwardingRules, [rule]);
|
|
assert.deepEqual(result.payload.groupConfigs, [{ path: "prod", username: "root" }]);
|
|
});
|
|
|
|
const hostWithTelemetry = (lastConnectedAt?: number) => ({
|
|
id: "host-1",
|
|
label: "prod",
|
|
hostname: "prod.example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
...(lastConnectedAt === undefined ? {} : { lastConnectedAt }),
|
|
});
|
|
|
|
test("mergeSyncPayloads normalizes lastConnectedAt across base and remote so sanitized local does not shadow remote edits", () => {
|
|
// Legacy base + old-device remote still carry the legacy telemetry field;
|
|
// the upgraded local payload strips it. A remote edit to the same host
|
|
// must not be classified as a local-modified conflict and overwritten.
|
|
const base = payload({
|
|
hosts: [hostWithTelemetry(1_000)],
|
|
});
|
|
// Local is otherwise unchanged — it only lost the telemetry field to the
|
|
// new sanitize-on-build behavior.
|
|
const local = payload({
|
|
hosts: [hostWithTelemetry()],
|
|
});
|
|
const remote = payload({
|
|
hosts: [{ ...hostWithTelemetry(1_000), label: "prod-renamed-by-remote" }],
|
|
});
|
|
|
|
const result = mergeSyncPayloads(base, local, remote);
|
|
|
|
// Remote edit survives: the telemetry-only local delta must not classify
|
|
// this host as locally modified and shadow the remote rename.
|
|
assert.equal(result.payload.hosts[0]?.label, "prod-renamed-by-remote");
|
|
assert.equal(result.summary.modified.local, 0);
|
|
assert.equal(result.summary.modified.remote, 1);
|
|
});
|
|
|
|
test("mergeSyncPayloads keeps host telemetry off merged payloads", () => {
|
|
const base = payload({ hosts: [hostWithTelemetry(1_000)] });
|
|
|
|
const unchanged = mergeSyncPayloads(
|
|
base,
|
|
payload({ hosts: [hostWithTelemetry(2_000)] }),
|
|
payload({ hosts: [hostWithTelemetry(1_000)] }),
|
|
);
|
|
assert.equal(unchanged.payload.hosts[0]?.lastConnectedAt, undefined);
|
|
assert.equal(unchanged.summary.modified.local, 0);
|
|
});
|