mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 08:42:45 +00:00
* feat: add a global default proxy with an explicit direct override Resolve the proxy as the host setting, then the nearest group default, then one optional saved profile from synced settings, then a direct connection. A host can choose direct and skip both inherited defaults. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: keep global default proxy persistence across settings instances Unregister only the setter owned by the unmounting useSettingsState instance so closing Settings still writes the cleared default through the remaining AppLockGate setter. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: round-trip explicit direct mode in vault CSV Write the Proxy cell as direct when a host sets directProxy, and import that literal back as an explicit direct connection. An empty Proxy cell still inherits. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: keep CSV proxy export from embedding the global default Grouped hosts ran through applyGroupDefaults and picked up the ambient global proxy, while ungrouped hosts exported an empty inheriting cell. Both paths now opt out of that default so inheritance stays inheritance. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: follow global default proxy changes in peer session windows Session windows disable settings IPC sync, which also skipped the storage listener and left the resolver singleton at its mount-time id. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * refactor: clear a deleted global default proxy at the call site Drop the pass-through field on removeProxyProfileReferences. The profile manager already knows the active id and clears it directly. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * test: share the global default proxy settings harness The peer-window case and the multi-instance cleanup case were mounting the same JSDOM fixture twice. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2342 lines
77 KiB
TypeScript
2342 lines
77 KiB
TypeScript
import { installBrowserLockFixture } from "./testSupport/browserLockFixture";
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import type { SyncPayload } from "../domain/sync.ts";
|
|
import type { KnownHost } from "../domain/models.ts";
|
|
import type { SyncableVaultData } from "./syncPayload.ts";
|
|
import { clampTerminalFontSizeValue, parseTerminalFontSizeRecord } from "./state/terminalFontSizeSync.ts";
|
|
import { parseCustomAccentRecord } from "./state/customAccentSync.ts";
|
|
import commandBlocklistTable from "../lib/commandBlocklist.json";
|
|
|
|
type LocalStorageMock = {
|
|
clear(): void;
|
|
getItem(key: string): string | null;
|
|
setItem(key: string, value: string): void;
|
|
removeItem(key: string): void;
|
|
};
|
|
|
|
function installLocalStorage(): LocalStorageMock {
|
|
const store = new Map<string, string>();
|
|
const localStorage: LocalStorageMock = {
|
|
clear() {
|
|
store.clear();
|
|
},
|
|
getItem(key: string) {
|
|
return store.has(key) ? store.get(key)! : null;
|
|
},
|
|
setItem(key: string, value: string) {
|
|
store.set(key, String(value));
|
|
},
|
|
removeItem(key: string) {
|
|
store.delete(key);
|
|
},
|
|
};
|
|
Object.defineProperty(globalThis, "localStorage", {
|
|
value: localStorage,
|
|
configurable: true,
|
|
});
|
|
return localStorage;
|
|
}
|
|
|
|
installBrowserLockFixture();
|
|
|
|
const localStorage = installLocalStorage();
|
|
const {
|
|
applyLocalVaultPayload,
|
|
prepareLocalVaultPayloadApply,
|
|
applySyncPayload,
|
|
buildLocalVaultPayload,
|
|
buildCloudSyncPayload,
|
|
withPluginSyncSidecars,
|
|
buildSyncPayload,
|
|
sanitizeHostsForSync,
|
|
retainLocalHostLastConnectedAt,
|
|
hasCloudSyncEntityData,
|
|
hasMeaningfulCloudSyncData,
|
|
hasMeaningfulSyncData,
|
|
shouldPromptCloudVaultRecovery,
|
|
SYNCABLE_SETTING_STORAGE_KEYS,
|
|
} = await import("./syncPayload.ts");
|
|
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
|
|
const { SYNC_STORAGE_KEYS } = await import("../domain/sync.ts");
|
|
const { localStorageAdapter } = await import("../infrastructure/persistence/localStorageAdapter.ts");
|
|
const { readCommandBlocklistSetting } = await import("./state/commandBlocklistSettings.ts");
|
|
|
|
const knownHost = (id = "kh-1"): KnownHost => ({
|
|
id,
|
|
hostname: `${id}.example.com`,
|
|
port: 22,
|
|
keyType: "ssh-ed25519",
|
|
publicKey: `SHA256:${id}`,
|
|
discoveredAt: 1,
|
|
});
|
|
|
|
const vault = (knownHosts: KnownHost[] = [knownHost()]): SyncableVaultData => ({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
snippetPackages: [],
|
|
notes: [],
|
|
noteGroups: [],
|
|
knownHosts,
|
|
groupConfigs: [],
|
|
});
|
|
|
|
test.beforeEach(() => {
|
|
localStorage.clear();
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: {
|
|
dispatchEvent: () => true,
|
|
},
|
|
configurable: true,
|
|
});
|
|
});
|
|
|
|
test("buildSyncPayload treats known hosts as local-only data", () => {
|
|
const payload = buildSyncPayload(vault([knownHost("kh-cloud")]));
|
|
|
|
assert.equal("knownHosts" in payload, false);
|
|
});
|
|
|
|
test("buildSyncPayload strips lastConnectedAt so connecting a host does not dirty cloud sync", () => {
|
|
const host = {
|
|
id: "host-1",
|
|
label: "prod",
|
|
hostname: "prod.example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
lastConnectedAt: 1_700_000_000_000,
|
|
pinned: true,
|
|
};
|
|
|
|
const payload = buildSyncPayload({ ...vault([]), hosts: [host] });
|
|
|
|
assert.equal(payload.hosts[0]?.lastConnectedAt, undefined);
|
|
assert.equal(payload.hosts[0]?.pinned, true);
|
|
assert.equal(payload.hosts[0]?.hostname, "prod.example.com");
|
|
});
|
|
|
|
test("buildCloudSyncPayload strips lastConnectedAt like port-forward lastUsedAt", async () => {
|
|
const host = {
|
|
id: "host-2",
|
|
label: "db",
|
|
hostname: "db.example.com",
|
|
username: "ubuntu",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
lastConnectedAt: 42,
|
|
};
|
|
|
|
const payload = await buildCloudSyncPayload({ ...vault([]), hosts: [host] });
|
|
const serializedHosts = JSON.parse(JSON.stringify(payload.hosts ?? []));
|
|
|
|
assert.equal(payload.hosts[0]?.lastConnectedAt, undefined);
|
|
assert.equal("lastConnectedAt" in (serializedHosts[0] ?? {}), false);
|
|
});
|
|
|
|
test("sanitizeHostsForSync hashes equal when hosts differ only by lastConnectedAt", () => {
|
|
const base = {
|
|
id: "host-hash",
|
|
label: "edge",
|
|
hostname: "edge.example.com",
|
|
username: "ops",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
pinned: true,
|
|
};
|
|
|
|
const sanitizedA = sanitizeHostsForSync([{ ...base, lastConnectedAt: 11 }]);
|
|
const sanitizedB = sanitizeHostsForSync([{ ...base, lastConnectedAt: 99 }]);
|
|
const payloadA = buildSyncPayload({ ...vault([]), hosts: [{ ...base, lastConnectedAt: 11 }] });
|
|
const payloadB = buildSyncPayload({ ...vault([]), hosts: [{ ...base, lastConnectedAt: 99 }] });
|
|
|
|
assert.equal(JSON.stringify(sanitizedA), JSON.stringify(sanitizedB));
|
|
assert.equal(JSON.stringify(payloadA.hosts), JSON.stringify(payloadB.hosts));
|
|
});
|
|
|
|
test("buildLocalVaultPayload keeps lastConnectedAt for local backups", () => {
|
|
const host = {
|
|
id: "host-3",
|
|
label: "lab",
|
|
hostname: "lab.example.com",
|
|
username: "lab",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
lastConnectedAt: 99,
|
|
};
|
|
|
|
const payload = buildLocalVaultPayload({ ...vault([]), hosts: [host] });
|
|
|
|
assert.equal(payload.hosts[0]?.lastConnectedAt, 99);
|
|
});
|
|
|
|
test("applySyncPayload keeps local lastConnectedAt when the cloud host omits it", async () => {
|
|
const localHost = {
|
|
id: "host-apply",
|
|
label: "prod",
|
|
hostname: "prod.example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
lastConnectedAt: 77,
|
|
};
|
|
const remoteHost = {
|
|
...localHost,
|
|
label: "prod-renamed",
|
|
lastConnectedAt: undefined,
|
|
};
|
|
|
|
const payload = buildSyncPayload({ ...vault([]), hosts: [remoteHost] });
|
|
let imported: { hosts?: Array<{ lastConnectedAt?: number; label?: string }> } | null = null;
|
|
await applySyncPayload(
|
|
payload,
|
|
{ importVaultData: (json) => { imported = JSON.parse(json); } },
|
|
{ currentHosts: [localHost] },
|
|
);
|
|
|
|
assert.equal(imported?.hosts?.[0]?.lastConnectedAt, 77);
|
|
assert.equal(imported?.hosts?.[0]?.label, "prod-renamed");
|
|
});
|
|
|
|
test("retainLocalHostLastConnectedAt does not invent timestamps for unknown hosts", () => {
|
|
const incoming = [{
|
|
id: "new-host",
|
|
label: "new",
|
|
hostname: "new.example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
}];
|
|
const retained = retainLocalHostLastConnectedAt(incoming, [{
|
|
id: "other",
|
|
label: "other",
|
|
hostname: "other.example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: "ssh" as const,
|
|
lastConnectedAt: 5,
|
|
}]);
|
|
|
|
assert.equal(retained?.[0]?.lastConnectedAt, undefined);
|
|
});
|
|
|
|
test("buildSyncPayload includes reusable proxy profiles", () => {
|
|
const proxyProfiles = [
|
|
{
|
|
id: "proxy-1",
|
|
label: "Office Proxy",
|
|
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
];
|
|
|
|
const payload = buildSyncPayload({
|
|
...vault(),
|
|
proxyProfiles,
|
|
} as SyncableVaultData & { proxyProfiles: typeof proxyProfiles });
|
|
|
|
assert.deepEqual(payload.proxyProfiles, proxyProfiles);
|
|
});
|
|
|
|
test("sync payloads preserve opaque plugin hosts without requiring the plugin to be installed", async () => {
|
|
const providerId = "com.example.transport.connection";
|
|
const pluginHost = {
|
|
id: "plugin-host-1",
|
|
label: "Example transport",
|
|
hostname: providerId,
|
|
username: "",
|
|
tags: [],
|
|
os: "linux" as const,
|
|
protocol: `plugin:${providerId}` as const,
|
|
pluginConnection: {
|
|
providerId,
|
|
authenticationProviderId: "com.example.transport.authentication",
|
|
credentialId: "credential-reference-1234",
|
|
configuration: { endpoint: "opaque.example", options: { compression: true } },
|
|
},
|
|
};
|
|
const payload = buildSyncPayload({ ...vault([]), hosts: [pluginHost] });
|
|
assert.deepEqual(JSON.parse(JSON.stringify(payload.hosts)), [pluginHost]);
|
|
|
|
let imported: Record<string, unknown> | null = null;
|
|
await applySyncPayload(payload, {
|
|
importVaultData: (json) => { imported = JSON.parse(json); },
|
|
});
|
|
assert.deepEqual(imported?.hosts, [pluginHost]);
|
|
});
|
|
|
|
test("applySyncPayload preserves host startup command and appearance overrides (#2757)", async () => {
|
|
const host = {
|
|
id: "host-1",
|
|
label: "Prod",
|
|
hostname: "prod.example",
|
|
username: "root",
|
|
port: 22,
|
|
protocol: "ssh" as const,
|
|
tags: [],
|
|
os: "linux" as const,
|
|
startupCommand: "tmux attach || tmux",
|
|
startupCommandRunMode: "lineDelay" as const,
|
|
theme: "solarized-dark",
|
|
themeOverride: true,
|
|
};
|
|
const groupConfigs = [
|
|
{
|
|
path: "prod",
|
|
startupCommand: "cd /srv && exec bash -l",
|
|
theme: "solarized-dark",
|
|
themeOverride: true,
|
|
},
|
|
];
|
|
const payload = buildSyncPayload({
|
|
...vault(),
|
|
hosts: [host],
|
|
groupConfigs,
|
|
});
|
|
|
|
assert.equal(payload.hosts[0]?.startupCommand, "tmux attach || tmux");
|
|
assert.equal(payload.hosts[0]?.theme, "solarized-dark");
|
|
assert.equal(payload.hosts[0]?.themeOverride, true);
|
|
assert.equal(payload.groupConfigs?.[0]?.startupCommand, "cd /srv && exec bash -l");
|
|
|
|
let imported: Record<string, unknown> | null = null;
|
|
await applySyncPayload(payload, {
|
|
importVaultData: (json) => { imported = JSON.parse(json); },
|
|
});
|
|
|
|
const importedHosts = imported?.hosts as typeof payload.hosts;
|
|
assert.equal(importedHosts?.[0]?.startupCommand, "tmux attach || tmux");
|
|
assert.equal(importedHosts?.[0]?.startupCommandRunMode, "lineDelay");
|
|
assert.equal(importedHosts?.[0]?.theme, "solarized-dark");
|
|
assert.equal(importedHosts?.[0]?.themeOverride, true);
|
|
assert.equal(
|
|
(imported?.groupConfigs as typeof groupConfigs)?.[0]?.startupCommand,
|
|
"cd /srv && exec bash -l",
|
|
);
|
|
});
|
|
|
|
test("buildCloudSyncPayload includes notes and note groups", async () => {
|
|
const payload = await buildCloudSyncPayload({
|
|
...vault([]),
|
|
notes: [{
|
|
id: "note-1",
|
|
title: "Runbook",
|
|
content: "# Runbook",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
}],
|
|
noteGroups: ["Ops"],
|
|
});
|
|
|
|
assert.equal(payload.notes?.length, 1);
|
|
assert.equal(payload.notes?.[0]?.title, "Runbook");
|
|
assert.deepEqual(payload.noteGroups, ["Ops"]);
|
|
});
|
|
|
|
test("buildSyncPayload includes AI configuration settings", () => {
|
|
const providers = [{
|
|
id: "openai-main",
|
|
providerId: "openai",
|
|
name: "OpenAI",
|
|
apiKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
|
defaultModel: "gpt-test",
|
|
enabled: true,
|
|
}];
|
|
const webSearch = {
|
|
providerId: "tavily",
|
|
apiKey: "enc:v1:djEwd2ViAAAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
maxResults: 7,
|
|
};
|
|
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify(providers));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_ACTIVE_PROVIDER, "openai-main");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_ACTIVE_MODEL, "gpt-test");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE, "auto");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, "skills");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT, "codex");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST, JSON.stringify(["rm -rf"]));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT, "120");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, "600");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS, "10");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_THINKING_MAP, JSON.stringify({ catty: "high" }));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION, "false");
|
|
|
|
const payload = buildSyncPayload(vault([]));
|
|
|
|
// Device-bound enc:v1 apiKeys are stripped from portable sync settings.
|
|
const { apiKey: _providerKey, ...providerWithoutKey } = providers[0]!;
|
|
const { apiKey: _webKey, ...webSearchWithoutKey } = webSearch;
|
|
assert.deepEqual(payload.settings?.ai, {
|
|
providers: [providerWithoutKey],
|
|
activeProviderId: "openai-main",
|
|
activeModelId: "gpt-test",
|
|
globalPermissionMode: "auto",
|
|
toolIntegrationMode: "skills",
|
|
defaultAgentId: "codex",
|
|
commandBlocklist: ["rm -rf"],
|
|
commandTimeout: 120,
|
|
responseIdleTimeout: 600,
|
|
maxIterations: 10,
|
|
agentModelMap: { codex: "gpt-test" },
|
|
agentProviderMap: { catty: "openai-main" },
|
|
agentThinkingMap: { catty: "high" },
|
|
webSearchConfig: webSearchWithoutKey,
|
|
showTerminalSelectionAction: false,
|
|
});
|
|
});
|
|
|
|
test("terminal selection AI preference is syncable for auto-sync detection", () => {
|
|
assert.ok(
|
|
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
|
storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
|
),
|
|
);
|
|
});
|
|
|
|
test("AI response wait time is syncable for auto-sync detection", () => {
|
|
assert.ok(
|
|
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
|
storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
|
),
|
|
);
|
|
});
|
|
|
|
test("terminal side panel auto-open settings are syncable for auto-sync detection", () => {
|
|
assert.ok(
|
|
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
|
storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
|
),
|
|
);
|
|
assert.ok(
|
|
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
|
storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
|
),
|
|
);
|
|
});
|
|
|
|
test("note appearance settings survive sync and trigger auto-sync", async () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY, "Menlo, monospace");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE, "16");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE, "14");
|
|
|
|
const payload = buildSyncPayload(vault([]));
|
|
assert.equal(payload.settings?.noteFontFamily, "Menlo, monospace");
|
|
assert.equal(payload.settings?.noteFontSize, 16);
|
|
assert.equal(payload.settings?.noteCodeFontSize, 14);
|
|
for (const key of [
|
|
storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
|
storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
|
storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
|
]) {
|
|
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(key));
|
|
}
|
|
|
|
localStorage.clear();
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY), "Menlo, monospace");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE), "16");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE), "14");
|
|
});
|
|
|
|
test("buildSyncPayload round-trips the global default proxy profile", async () => {
|
|
localStorage.clear();
|
|
const absent = buildSyncPayload(vault([]));
|
|
assert.equal(absent.settings?.defaultProxyProfileId, undefined);
|
|
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "proxy-1");
|
|
const selected = buildSyncPayload(vault([]));
|
|
assert.equal(selected.settings?.defaultProxyProfileId, "proxy-1");
|
|
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "");
|
|
const cleared = buildSyncPayload(vault([]));
|
|
assert.equal(cleared.settings?.defaultProxyProfileId, "");
|
|
|
|
localStorage.clear();
|
|
await applySyncPayload(selected, { importVaultData: () => {} });
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "proxy-1");
|
|
|
|
await applySyncPayload(cleared, { importVaultData: () => {} });
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "");
|
|
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
|
storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
|
));
|
|
});
|
|
|
|
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, "scripts");
|
|
|
|
const payload = buildSyncPayload(vault([]));
|
|
|
|
assert.equal(payload.settings?.showHostTreeSidebar, false);
|
|
assert.equal(payload.settings?.terminalSidePanelAutoOpen, true);
|
|
assert.equal(payload.settings?.terminalSidePanelAutoOpenTab, "scripts");
|
|
});
|
|
|
|
test("buildSyncPayload reads the versioned terminal font size record", () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
|
|
"18|9|settings-window",
|
|
);
|
|
|
|
const payload = buildSyncPayload(vault([]));
|
|
|
|
assert.equal(payload.settings?.terminalFontSize, 18);
|
|
});
|
|
|
|
test("applySyncPayload writes a newer terminal font size that old versions can read", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
|
|
"16|7|main-window",
|
|
);
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: { terminalFontSize: 19 },
|
|
};
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_FONT_SIZE);
|
|
assert.ok(raw);
|
|
assert.equal(parseInt(raw, 10), 19);
|
|
const record = parseTerminalFontSizeRecord(raw);
|
|
assert.equal(record.fontSize, 19);
|
|
assert.ok(record.version > 7);
|
|
assert.equal(record.origin, "sync-payload");
|
|
});
|
|
|
|
test("applying identical versioned settings preserves their records, then propagates real changes", async () => {
|
|
const scenarios: Array<{
|
|
name: string;
|
|
storageKey: string;
|
|
field: keyof NonNullable<SyncPayload["settings"]>;
|
|
initialValue: string | number | Record<string, unknown>;
|
|
changedValue: string | number | Record<string, unknown>;
|
|
}> = [
|
|
{
|
|
name: "accent",
|
|
storageKey: storageKeys.STORAGE_KEY_COLOR,
|
|
field: "customAccent",
|
|
initialValue: parseCustomAccentRecord(null).color,
|
|
changedValue: "0 84% 60%",
|
|
},
|
|
{
|
|
name: "terminal font size",
|
|
storageKey: storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
|
|
field: "terminalFontSize",
|
|
initialValue: parseTerminalFontSizeRecord(null).fontSize,
|
|
changedValue: 19,
|
|
},
|
|
{
|
|
name: "custom key bindings",
|
|
storageKey: storageKeys.STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
|
field: "customKeyBindings",
|
|
initialValue: {},
|
|
changedValue: { open: { mac: "Cmd+K" } },
|
|
},
|
|
];
|
|
|
|
for (const scenario of scenarios) {
|
|
localStorage.clear();
|
|
const writes: string[] = [];
|
|
const originalSetItem = localStorage.setItem;
|
|
localStorage.setItem = (key, value) => {
|
|
writes.push(key);
|
|
originalSetItem(key, value);
|
|
};
|
|
const applySetting = (value: typeof scenario.initialValue) => applySyncPayload({
|
|
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
|
settings: { [scenario.field]: value },
|
|
} as SyncPayload, { importVaultData: () => {} });
|
|
|
|
try {
|
|
await applySetting(scenario.initialValue);
|
|
const firstRecord = localStorage.getItem(scenario.storageKey);
|
|
assert.ok(firstRecord, `${scenario.name}: an explicit first value must be saved`);
|
|
assert.equal(writes.filter((key) => key === scenario.storageKey).length, 1);
|
|
|
|
writes.length = 0;
|
|
await applySetting(scenario.initialValue);
|
|
assert.equal(localStorage.getItem(scenario.storageKey), firstRecord);
|
|
assert.equal(writes.filter((key) => key === scenario.storageKey).length, 0,
|
|
`${scenario.name}: unchanged value must not write or trigger a storage change`);
|
|
|
|
writes.length = 0;
|
|
await applySetting(scenario.changedValue);
|
|
assert.equal(writes.filter((key) => key === scenario.storageKey).length, 1,
|
|
`${scenario.name}: a real change must be saved`);
|
|
assert.notEqual(localStorage.getItem(scenario.storageKey), firstRecord);
|
|
assert.deepEqual(buildSyncPayload(vault([])).settings?.[scenario.field], scenario.changedValue);
|
|
} finally {
|
|
localStorage.setItem = originalSetItem;
|
|
}
|
|
}
|
|
});
|
|
|
|
test("sync replaces malformed setting records and does not repeat normalized writes", async () => {
|
|
const versionedKeys = [
|
|
storageKeys.STORAGE_KEY_COLOR,
|
|
storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
|
|
storageKeys.STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
|
];
|
|
for (const key of versionedKeys) localStorage.setItem(key, "bad");
|
|
const writes: string[] = [];
|
|
const originalSetItem = localStorage.setItem;
|
|
localStorage.setItem = (key, value) => {
|
|
writes.push(key);
|
|
originalSetItem(key, value);
|
|
};
|
|
const settings = {
|
|
customAccent: parseCustomAccentRecord(null).color,
|
|
terminalFontSize: parseTerminalFontSizeRecord(null).fontSize,
|
|
customKeyBindings: {},
|
|
};
|
|
const applySettings = (nextSettings: NonNullable<SyncPayload["settings"]>) => applySyncPayload({
|
|
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
|
settings: nextSettings,
|
|
}, { importVaultData: () => {} });
|
|
|
|
try {
|
|
await applySettings(settings);
|
|
for (const key of versionedKeys) {
|
|
assert.notEqual(localStorage.getItem(key), "bad", `${key}: malformed record must be repaired`);
|
|
assert.equal(writes.filter((written) => written === key).length, 1);
|
|
}
|
|
|
|
writes.length = 0;
|
|
await applySettings(settings);
|
|
for (const key of versionedKeys) {
|
|
assert.equal(writes.filter((written) => written === key).length, 0,
|
|
`${key}: repaired record must not be written again`);
|
|
}
|
|
|
|
const oversizedFontSize = 100;
|
|
writes.length = 0;
|
|
await applySettings({ terminalFontSize: oversizedFontSize });
|
|
assert.equal(parseTerminalFontSizeRecord(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_FONT_SIZE)).fontSize,
|
|
clampTerminalFontSizeValue(oversizedFontSize));
|
|
assert.equal(writes.filter((key) => key === storageKeys.STORAGE_KEY_TERM_FONT_SIZE).length, 1);
|
|
|
|
writes.length = 0;
|
|
await applySettings({ terminalFontSize: oversizedFontSize });
|
|
assert.equal(writes.filter((key) => key === storageKeys.STORAGE_KEY_TERM_FONT_SIZE).length, 0,
|
|
"a repeated out-of-range incoming size must not bump the stored version again");
|
|
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_FONT_SIZE, "999");
|
|
writes.length = 0;
|
|
await applySettings({ terminalFontSize: oversizedFontSize });
|
|
assert.equal(writes.filter((key) => key === storageKeys.STORAGE_KEY_TERM_FONT_SIZE).length, 1,
|
|
"an out-of-range legacy record must be replaced with the normalized value");
|
|
assert.notEqual(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_FONT_SIZE), "999");
|
|
} finally {
|
|
localStorage.setItem = originalSetItem;
|
|
}
|
|
});
|
|
|
|
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify([
|
|
{ id: "codex", name: "Codex", command: "/opt/homebrew/bin/codex", enabled: true },
|
|
]));
|
|
|
|
const payload = buildSyncPayload(vault([]));
|
|
|
|
assert.equal("ai" in (payload.settings ?? {}), false);
|
|
});
|
|
|
|
test("buildSyncPayload omits device-bound encrypted AI API keys", () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
|
id: "openai-main",
|
|
providerId: "openai",
|
|
name: "OpenAI",
|
|
apiKey: "enc:v1:djEwQUFBQQAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}]));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
|
providerId: "tavily",
|
|
apiKey: "enc:v1:djEwQUFBQQAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}));
|
|
|
|
const payload = buildSyncPayload(vault([]));
|
|
|
|
assert.equal("apiKey" in (payload.settings?.ai?.providers?.[0] ?? {}), false);
|
|
assert.equal("apiKey" in (payload.settings?.ai?.webSearchConfig ?? {}), false);
|
|
});
|
|
|
|
test("buildCloudSyncPayload includes decrypted AI API keys for portable cloud sync", async () => {
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: {
|
|
netcatty: {
|
|
credentialsDecrypt: async (value: string) => {
|
|
if (value === "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==") return "sk-provider";
|
|
if (value === "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==") return "sk-web";
|
|
return value;
|
|
},
|
|
},
|
|
},
|
|
configurable: true,
|
|
});
|
|
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
|
id: "openai-main",
|
|
providerId: "openai",
|
|
name: "OpenAI",
|
|
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}]));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
|
providerId: "tavily",
|
|
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}));
|
|
|
|
const payload = await buildCloudSyncPayload(vault([]));
|
|
|
|
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "sk-provider");
|
|
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "sk-web");
|
|
});
|
|
|
|
test("buildCloudSyncPayload fails instead of deleting API keys when decrypt fails", async () => {
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: {
|
|
netcatty: {
|
|
credentialsDecrypt: async (value: string) => value,
|
|
},
|
|
},
|
|
configurable: true,
|
|
});
|
|
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
|
id: "openai-main",
|
|
providerId: "openai",
|
|
name: "OpenAI",
|
|
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}]));
|
|
|
|
await assert.rejects(
|
|
() => buildCloudSyncPayload(vault([])),
|
|
/Unable to decrypt AI API key/,
|
|
);
|
|
});
|
|
|
|
test("applySyncPayload restores AI configuration settings", async () => {
|
|
const providers = [{
|
|
id: "anthropic-main",
|
|
providerId: "anthropic",
|
|
name: "Anthropic",
|
|
apiKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}];
|
|
const webSearch = {
|
|
providerId: "exa",
|
|
apiKey: "enc:v1:djEwd2ViAAAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
};
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
providers,
|
|
activeProviderId: "anthropic-main",
|
|
activeModelId: "claude-test",
|
|
globalPermissionMode: "observer",
|
|
toolIntegrationMode: "mcp",
|
|
defaultAgentId: "claude",
|
|
commandBlocklist: ["shutdown"],
|
|
commandTimeout: 30,
|
|
responseIdleTimeout: 900,
|
|
maxIterations: 5,
|
|
agentModelMap: { claude: "claude-test" },
|
|
agentProviderMap: { catty: "anthropic-main" },
|
|
webSearchConfig: webSearch,
|
|
showTerminalSelectionAction: false,
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!), providers);
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_ACTIVE_PROVIDER), "anthropic-main");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_ACTIVE_MODEL), "claude-test");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE), "observer");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_TOOL_INTEGRATION_MODE), "mcp");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT), "claude");
|
|
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!), ["shutdown"]);
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT), "30");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT), "900");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS), "5");
|
|
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
|
|
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
|
|
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION), "false");
|
|
});
|
|
|
|
test("applySyncPayload migrates an untouched legacy command blocklist", async () => {
|
|
const legacyDefaults = [
|
|
...commandBlocklistTable.common,
|
|
...commandBlocklistTable.posixNative,
|
|
...commandBlocklistTable.posix,
|
|
];
|
|
|
|
await applySyncPayload({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
commandBlocklist: legacyDefaults,
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload, { importVaultData: () => {} });
|
|
|
|
assert.deepEqual(
|
|
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!),
|
|
[...legacyDefaults, ...commandBlocklistTable.powershell],
|
|
);
|
|
assert.deepEqual(
|
|
readCommandBlocklistSetting(),
|
|
[...legacyDefaults, ...commandBlocklistTable.powershell],
|
|
);
|
|
});
|
|
|
|
test("command blocklist read migrates an untouched local legacy list", () => {
|
|
const legacyDefaults = [
|
|
...commandBlocklistTable.common,
|
|
...commandBlocklistTable.posixNative,
|
|
...commandBlocklistTable.posix,
|
|
];
|
|
const currentDefaults = [...legacyDefaults, ...commandBlocklistTable.powershell];
|
|
|
|
assert.deepEqual(readCommandBlocklistSetting(), currentDefaults);
|
|
assert.deepEqual(
|
|
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!),
|
|
currentDefaults,
|
|
);
|
|
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
|
JSON.stringify([...legacyDefaults, "old-client-user-rule"]),
|
|
);
|
|
assert.deepEqual(readCommandBlocklistSetting(), [
|
|
...legacyDefaults,
|
|
"old-client-user-rule",
|
|
...commandBlocklistTable.powershell,
|
|
]);
|
|
});
|
|
|
|
test("applySyncPayload preserves a customized PowerShell blocklist", async () => {
|
|
const currentCustomized = [
|
|
...commandBlocklistTable.common,
|
|
...commandBlocklistTable.posixNative,
|
|
...commandBlocklistTable.posix,
|
|
...commandBlocklistTable.powershell.slice(1),
|
|
];
|
|
|
|
await applySyncPayload({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
commandBlocklist: currentCustomized,
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload, { importVaultData: () => {} });
|
|
|
|
assert.deepEqual(readCommandBlocklistSetting(), currentCustomized);
|
|
});
|
|
|
|
test("applySyncPayload encrypts synced plaintext AI API keys before saving locally", async () => {
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: {
|
|
netcatty: {
|
|
credentialsEncrypt: async (value: string) => {
|
|
const body = Buffer.alloc(19, 0);
|
|
Buffer.from("v10", "utf8").copy(body, 0);
|
|
Buffer.from(`LOCAL_${value}`.slice(0, 16), "utf8").copy(body, 3);
|
|
return `enc:v1:${body.toString("base64")}`;
|
|
},
|
|
},
|
|
dispatchEvent: () => true,
|
|
},
|
|
configurable: true,
|
|
});
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
providers: [
|
|
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "sk-provider", enabled: true },
|
|
],
|
|
webSearchConfig: { providerId: "tavily", apiKey: "sk-web", enabled: true },
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const provider = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
|
|
const webSearch = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
|
const expectedProviderKey = (() => {
|
|
const body = Buffer.alloc(19, 0);
|
|
Buffer.from("v10", "utf8").copy(body, 0);
|
|
Buffer.from("LOCAL_sk-provider".slice(0, 16), "utf8").copy(body, 3);
|
|
return `enc:v1:${body.toString("base64")}`;
|
|
})();
|
|
const expectedWebKey = (() => {
|
|
const body = Buffer.alloc(19, 0);
|
|
Buffer.from("v10", "utf8").copy(body, 0);
|
|
Buffer.from("LOCAL_sk-web".slice(0, 16), "utf8").copy(body, 3);
|
|
return `enc:v1:${body.toString("base64")}`;
|
|
})();
|
|
assert.equal(provider.apiKey, expectedProviderKey);
|
|
assert.equal(webSearch.apiKey, expectedWebKey);
|
|
});
|
|
|
|
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
showHostTreeSidebar: false,
|
|
terminalSidePanelAutoOpen: true,
|
|
terminalSidePanelAutoOpenTab: "scripts",
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR), "false");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN), "true");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB), "scripts");
|
|
});
|
|
|
|
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
|
|
// Without this nudge, the apply path writes to localStorage but
|
|
// `useAIState` (listening for `storage` events) never sees the changes
|
|
// in the calling window — mounted UI keeps showing pre-sync data.
|
|
const dispatched: Array<{ type: string; detail: unknown }> = [];
|
|
const fakeWindow = {
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
dispatchEvent(event: Event) {
|
|
dispatched.push({
|
|
type: event.type,
|
|
detail: (event as CustomEvent).detail,
|
|
});
|
|
return true;
|
|
},
|
|
};
|
|
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
|
|
try {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "deepseek-local" }));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ catty: "deepseek-v4-flash" }));
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
providers: [{ id: "openai-main", providerId: "openai", name: "OpenAI", enabled: true }],
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const events = dispatched.filter((e) => e.type === "netcatty:ai-state-changed");
|
|
const keys = events.map((e) => (e.detail as { key?: string })?.key);
|
|
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_PROVIDERS), "providers nudge");
|
|
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP), "agentProviderMap nudge");
|
|
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP), "agentModelMap nudge");
|
|
} finally {
|
|
delete (globalThis as { window?: unknown }).window;
|
|
}
|
|
});
|
|
|
|
test("applySyncPayload prunes per-agent bindings that reference providers absent from the synced set", async () => {
|
|
// Local state has Catty bound to a provider the incoming sync no longer
|
|
// ships — both the per-agent provider override and the saved model should
|
|
// be cleared so we don't dispatch a ghost provider id (or its now-orphan
|
|
// model name) to the wrong endpoint.
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({
|
|
catty: "deepseek-local",
|
|
codex: "openai-main",
|
|
}));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({
|
|
catty: "deepseek-v4-flash",
|
|
codex: "gpt-test",
|
|
}));
|
|
|
|
const syncedProviders = [
|
|
{ id: "openai-main", providerId: "openai", name: "OpenAI", enabled: true },
|
|
];
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
providers: syncedProviders,
|
|
// Intentionally omit agentProviderMap — exercises the reconcile path.
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
assert.deepEqual(
|
|
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!),
|
|
{ codex: "openai-main" },
|
|
);
|
|
// Catty's saved model belonged to the now-missing deepseek-local — drop it.
|
|
// Codex's binding stays, so its saved model stays.
|
|
assert.deepEqual(
|
|
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!),
|
|
{ codex: "gpt-test" },
|
|
);
|
|
});
|
|
|
|
test("applySyncPayload preserves local externalAgents and ignores legacy payload field", async () => {
|
|
const localAgents = [
|
|
{ id: "codex", name: "Codex", command: "/usr/local/bin/codex", enabled: true },
|
|
];
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify(localAgents));
|
|
|
|
const payload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
// Legacy snapshot still carries externalAgents; current code must ignore it.
|
|
externalAgents: [
|
|
{ id: "claude", name: "Claude", command: "C:\\Tools\\claude.exe", enabled: true },
|
|
],
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as unknown as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
assert.deepEqual(
|
|
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS)!),
|
|
localAgents,
|
|
);
|
|
});
|
|
|
|
test("applySyncPayload preserves local AI provider apiKeys when synced payload omits them", async () => {
|
|
const localProviders = [
|
|
{
|
|
id: "openai-main",
|
|
providerId: "openai",
|
|
name: "OpenAI",
|
|
apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
},
|
|
{
|
|
id: "anthropic-main",
|
|
providerId: "anthropic",
|
|
name: "Anthropic",
|
|
apiKey: "enc:v1:djEwQU5USFJPUElDAAAAAAAAAA==",
|
|
enabled: true,
|
|
},
|
|
];
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify(localProviders));
|
|
|
|
// Synced payload mirrors what `collectSyncableSettings` produces on another device:
|
|
// metadata is preserved but encrypted device-bound apiKeys are stripped.
|
|
const syncedProviders = [
|
|
{ id: "openai-main", providerId: "openai", name: "OpenAI (renamed)", enabled: true },
|
|
{ id: "anthropic-main", providerId: "anthropic", name: "Anthropic", enabled: false },
|
|
];
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: { ai: { providers: syncedProviders } },
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!);
|
|
assert.deepEqual(stored, [
|
|
{
|
|
id: "openai-main",
|
|
providerId: "openai",
|
|
name: "OpenAI (renamed)",
|
|
apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
},
|
|
{
|
|
id: "anthropic-main",
|
|
providerId: "anthropic",
|
|
name: "Anthropic",
|
|
apiKey: "enc:v1:djEwQU5USFJPUElDAAAAAAAAAA==",
|
|
enabled: false,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("applySyncPayload prefers explicit synced apiKey over local apiKey", async () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
|
|
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==", enabled: true },
|
|
]));
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
providers: [
|
|
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "plaintext-from-other-device", enabled: true },
|
|
],
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!);
|
|
assert.equal(stored[0].apiKey, "plaintext-from-other-device");
|
|
});
|
|
|
|
test("applySyncPayload preserves local web-search apiKey when synced config omits it", async () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
|
providerId: "tavily",
|
|
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
maxResults: 7,
|
|
}));
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
webSearchConfig: { providerId: "tavily", enabled: false, maxResults: 12 },
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
|
assert.deepEqual(stored, {
|
|
providerId: "tavily",
|
|
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
|
enabled: false,
|
|
maxResults: 12,
|
|
});
|
|
});
|
|
|
|
test("applySyncPayload drops local web-search apiKey when synced config switches provider", async () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
|
providerId: "tavily",
|
|
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}));
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: {
|
|
ai: {
|
|
webSearchConfig: { providerId: "exa", enabled: true },
|
|
},
|
|
},
|
|
syncedAt: 1,
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
|
assert.equal("apiKey" in stored, false);
|
|
assert.equal(stored.providerId, "exa");
|
|
});
|
|
|
|
test("buildSyncPayload includes syncable terminal options from settings", () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_FOLLOW_APP_THEME, "true");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS, JSON.stringify({
|
|
terminalEmulationType: "vt100",
|
|
altAsMeta: true,
|
|
kittyKeyboardProtocolEnabled: true,
|
|
shiftEnterNewlineEnabled: false,
|
|
shiftEnterNewlineText: " \\\\\\n",
|
|
middleClickBehavior: "context-menu",
|
|
fontSmoothing: false,
|
|
showServerStats: false,
|
|
serverStatsRefreshInterval: 12,
|
|
rendererType: "dom",
|
|
localShell: "/bin/zsh",
|
|
}));
|
|
|
|
const payload = buildSyncPayload(vault([]));
|
|
|
|
assert.equal(payload.settings?.followAppTerminalTheme, true);
|
|
assert.deepEqual(payload.settings?.terminalSettings, {
|
|
terminalEmulationType: "vt100",
|
|
altAsMeta: true,
|
|
kittyKeyboardProtocolEnabled: true,
|
|
shiftEnterNewlineEnabled: false,
|
|
shiftEnterNewlineText: " \\\\\\n",
|
|
middleClickBehavior: "context-menu",
|
|
fontSmoothing: false,
|
|
showServerStats: false,
|
|
serverStatsRefreshInterval: 12,
|
|
rendererType: "dom",
|
|
});
|
|
});
|
|
|
|
test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
|
|
assert.equal(
|
|
hasMeaningfulCloudSyncData({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
knownHosts: [knownHost("kh-only")],
|
|
syncedAt: 1,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("buildLocalVaultPayload includes last-known plugin sidecars for protective backups", () => {
|
|
localStorage.setItem(
|
|
"netcatty_plugin_sidecars_last_known_v1",
|
|
JSON.stringify({
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.example.p",
|
|
kind: "settings",
|
|
key: "com.example.p.theme\0application\0application",
|
|
value: "dark",
|
|
updatedAt: 1,
|
|
}],
|
|
}),
|
|
);
|
|
try {
|
|
const payload = buildLocalVaultPayload(vault([]));
|
|
assert.equal(payload.pluginSidecars?.entries?.length, 1);
|
|
assert.equal(payload.pluginSidecars?.entries?.[0].value, "dark");
|
|
assert.equal(hasMeaningfulSyncData(payload), true);
|
|
} finally {
|
|
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
|
|
}
|
|
});
|
|
|
|
test("buildLocalVaultPayloadAsync prefers live empty over last-known for backups", async () => {
|
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: {
|
|
netcatty: {
|
|
collectPluginSyncSidecars: async () => ({ version: 1, entries: [] }),
|
|
pluginHostReady: () => true,
|
|
},
|
|
dispatchEvent: () => true,
|
|
},
|
|
configurable: true,
|
|
});
|
|
localStorage.setItem(
|
|
"netcatty_plugin_sidecars_last_known_v1",
|
|
JSON.stringify({
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.example.p",
|
|
kind: "settings",
|
|
key: "com.example.p.theme\0application\0application",
|
|
value: "dark",
|
|
updatedAt: 1,
|
|
}],
|
|
}),
|
|
);
|
|
try {
|
|
const { buildLocalVaultPayloadAsync } = await import("./syncPayload.ts");
|
|
const payload = await buildLocalVaultPayloadAsync(vault([]));
|
|
assert.equal(payload.pluginSidecars?.entries?.length, 0);
|
|
} finally {
|
|
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: previousWindow,
|
|
configurable: true,
|
|
});
|
|
}
|
|
});
|
|
|
|
test("hasMeaningfulCloudSyncData treats non-empty plugin sidecars as meaningful", () => {
|
|
assert.equal(
|
|
hasMeaningfulCloudSyncData({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
pluginSidecars: {
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.example.p",
|
|
kind: "settings",
|
|
key: "com.example.p.theme\0application\0application",
|
|
value: "dark",
|
|
updatedAt: 1,
|
|
}],
|
|
},
|
|
}),
|
|
true,
|
|
);
|
|
// Empty bundle alone must not bypass the empty-vault upload guard.
|
|
assert.equal(
|
|
hasMeaningfulCloudSyncData({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
pluginSidecars: { version: 1, entries: [] },
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
hasMeaningfulCloudSyncData({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("hasMeaningfulCloudSyncData does not treat last-known-only empty sidecars as meaningful", () => {
|
|
const previous = localStorageAdapter.read(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
|
|
try {
|
|
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.example.p",
|
|
kind: "settings",
|
|
key: "k",
|
|
value: 1,
|
|
updatedAt: 1,
|
|
}],
|
|
});
|
|
// Empty vault + empty sidecar entries must stay blocked even when last-known
|
|
// still remembers prior plugin settings (empty-vault upload guard).
|
|
assert.equal(
|
|
hasMeaningfulCloudSyncData({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
pluginSidecars: { version: 1, entries: [] },
|
|
}),
|
|
false,
|
|
);
|
|
} finally {
|
|
if (previous == null) localStorageAdapter.remove(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
|
|
else localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, previous);
|
|
}
|
|
});
|
|
|
|
test("plugin sidecar storage keys stay aligned between sync domain and storageKeys registry", () => {
|
|
assert.equal(
|
|
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_LAST_KNOWN,
|
|
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
|
|
);
|
|
assert.equal(
|
|
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_PENDING_REMOTE,
|
|
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE,
|
|
);
|
|
assert.equal(
|
|
storageKeys.STORAGE_KEY_AVAILABLE_PLUGIN_SYNC_PROVIDERS,
|
|
SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS,
|
|
);
|
|
});
|
|
|
|
test("hasCloudSyncEntityData ignores settings-only payloads for empty-vault recovery", () => {
|
|
assert.equal(
|
|
hasCloudSyncEntityData({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: { theme: "system", terminalTheme: "default" },
|
|
syncedAt: 1,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("surviving browser bookmarks do not suppress empty-vault recovery", () => {
|
|
const local: SyncPayload = {
|
|
...vault([]), syncedAt: 1,
|
|
browserBookmarks: [{
|
|
id: "app", title: "App", url: "https://app.example.com/", scope: "all-hosts",
|
|
hostId: null, icon: "A", color: "#247969", order: 0, createdAt: 1, updatedAt: 1,
|
|
}],
|
|
};
|
|
assert.equal(hasMeaningfulCloudSyncData(local), true, "bookmarks must remain syncable");
|
|
assert.equal(hasCloudSyncEntityData(local), false);
|
|
const host = {
|
|
id: "remote", label: "Remote", hostname: "remote.example.com", username: "test",
|
|
port: 22, protocol: "ssh" as const, tags: [], os: "linux" as const,
|
|
};
|
|
for (const hosts of [[host], [host, { ...host, id: "second" }]]) {
|
|
assert.equal(shouldPromptCloudVaultRecovery(local, { ...vault([]), hosts, syncedAt: 2 }), true);
|
|
}
|
|
assert.equal(shouldPromptCloudVaultRecovery({ ...vault([]), syncedAt: 1 }, local), false);
|
|
});
|
|
|
|
test("shouldPromptCloudVaultRecovery ignores settings-only remote payloads", () => {
|
|
const settingsOnlyPayload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
settings: { theme: "system", terminalTheme: "default" },
|
|
syncedAt: 1,
|
|
};
|
|
|
|
assert.equal(
|
|
shouldPromptCloudVaultRecovery(settingsOnlyPayload, settingsOnlyPayload),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("buildLocalVaultPayload preserves known hosts for local backups", () => {
|
|
const payload = buildLocalVaultPayload(vault([knownHost("kh-local")]));
|
|
|
|
assert.deepEqual(payload.knownHosts, [knownHost("kh-local")]);
|
|
});
|
|
|
|
test("buildLocalVaultPayload preserves local AI API keys for protective backups", () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
|
id: "openai-main",
|
|
providerId: "openai",
|
|
name: "OpenAI",
|
|
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}]));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
|
providerId: "tavily",
|
|
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
|
enabled: true,
|
|
}));
|
|
|
|
const payload = buildLocalVaultPayload(vault([]));
|
|
|
|
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==");
|
|
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==");
|
|
});
|
|
|
|
test("applySyncPayload ignores legacy cloud known hosts", async () => {
|
|
let imported: Record<string, unknown> | null = null;
|
|
const proxyProfiles = [
|
|
{
|
|
id: "proxy-1",
|
|
label: "Office Proxy",
|
|
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
];
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
knownHosts: [knownHost("kh-legacy")],
|
|
proxyProfiles,
|
|
syncedAt: 1,
|
|
} as SyncPayload & { proxyProfiles: typeof proxyProfiles };
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: (json) => {
|
|
imported = JSON.parse(json);
|
|
},
|
|
});
|
|
|
|
assert.ok(imported);
|
|
assert.equal("knownHosts" in imported, false);
|
|
assert.deepEqual(imported.proxyProfiles, proxyProfiles);
|
|
});
|
|
|
|
test("applySyncPayload keeps missing proxy references visible to connection guards", async () => {
|
|
let imported: Record<string, unknown> | null = null;
|
|
const payload: SyncPayload = {
|
|
hosts: [{
|
|
id: "host-1",
|
|
label: "Host",
|
|
hostname: "example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux",
|
|
proxyProfileId: "missing-proxy",
|
|
}],
|
|
keys: [],
|
|
identities: [],
|
|
proxyProfiles: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
groupConfigs: [{ path: "prod", proxyProfileId: "missing-proxy" }],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: (json) => {
|
|
imported = JSON.parse(json);
|
|
},
|
|
});
|
|
|
|
assert.ok(imported);
|
|
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
|
|
assert.equal((imported.groupConfigs as SyncPayload["groupConfigs"])?.[0]?.proxyProfileId, "missing-proxy");
|
|
});
|
|
|
|
test("applySyncPayload preserves host proxy references when group configs are absent", async () => {
|
|
let imported: Record<string, unknown> | null = null;
|
|
const payload: SyncPayload = {
|
|
hosts: [{
|
|
id: "host-1",
|
|
label: "Host",
|
|
hostname: "example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux",
|
|
proxyProfileId: "missing-proxy",
|
|
}],
|
|
keys: [],
|
|
identities: [],
|
|
proxyProfiles: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: (json) => {
|
|
imported = JSON.parse(json);
|
|
},
|
|
});
|
|
|
|
assert.ok(imported);
|
|
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
|
|
assert.equal("groupConfigs" in imported, false);
|
|
});
|
|
|
|
test("applySyncPayload migrates legacy global line timestamps onto hosts", async () => {
|
|
let imported: Record<string, unknown> | null = null;
|
|
const payload: SyncPayload = {
|
|
hosts: [
|
|
{
|
|
id: "host-1",
|
|
label: "Inherited",
|
|
hostname: "example.com",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux",
|
|
},
|
|
{
|
|
id: "host-2",
|
|
label: "Explicit",
|
|
hostname: "example.net",
|
|
username: "root",
|
|
tags: [],
|
|
os: "linux",
|
|
showLineTimestamps: false,
|
|
},
|
|
],
|
|
keys: [],
|
|
identities: [],
|
|
proxyProfiles: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
settings: { terminalSettings: { showLineTimestamps: true } },
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: (json) => {
|
|
imported = JSON.parse(json);
|
|
},
|
|
});
|
|
|
|
assert.ok(imported);
|
|
const hosts = imported.hosts as SyncPayload["hosts"];
|
|
assert.equal(hosts[0]?.showLineTimestamps, true);
|
|
assert.equal(hosts[1]?.showLineTimestamps, false);
|
|
});
|
|
|
|
test("applySyncPayload waits for async vault imports", async () => {
|
|
let finished = false;
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
const promise = applySyncPayload(payload, {
|
|
importVaultData: async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
finished = true;
|
|
},
|
|
});
|
|
|
|
assert.equal(finished, false);
|
|
await promise;
|
|
assert.equal(finished, true);
|
|
});
|
|
|
|
test("buildSyncPayload includes fallbackFont when present in TERM_SETTINGS", () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ scrollback: 5000, fallbackFont: "PingFang SC", fontLigatures: true }),
|
|
);
|
|
|
|
const payload = buildSyncPayload(vault());
|
|
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
|
assert.equal(termSettings.fallbackFont, "PingFang SC");
|
|
});
|
|
|
|
test("buildSyncPayload includes the terminal host information bar preference", () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ showHostInfoBar: false }),
|
|
);
|
|
|
|
const payload = buildSyncPayload(vault());
|
|
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
|
assert.equal(termSettings.showHostInfoBar, false);
|
|
});
|
|
|
|
test("terminal auto-close preference survives sync round-trip", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ autoCloseOnExit: false }),
|
|
);
|
|
|
|
const payload = buildSyncPayload(vault());
|
|
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
|
assert.equal(termSettings.autoCloseOnExit, false);
|
|
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ autoCloseOnExit: true }),
|
|
);
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
|
assert.equal(restored.autoCloseOnExit, false);
|
|
});
|
|
|
|
test("inbound sync does not resurrect the materialized auto-close default after the marker", async () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, "true");
|
|
try {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
|
|
);
|
|
|
|
await applySyncPayload({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
settings: { terminalSettings: { autoCloseOnExit: true } },
|
|
}, { importVaultData: () => {} });
|
|
|
|
const resurrected = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
|
assert.equal(resurrected.autoCloseOnExit, false);
|
|
assert.equal(resurrected.scrollback, 3000);
|
|
|
|
// An explicit opt-in made after the migration round-trips with a marker.
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ autoCloseOnExit: true, scrollback: 3000 }),
|
|
);
|
|
const optedIn = buildSyncPayload(vault());
|
|
assert.equal(optedIn.settings?.autoCloseOnExitDefaultMigrated, true);
|
|
assert.equal(optedIn.settings?.terminalSettings?.autoCloseOnExit, true);
|
|
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
|
|
);
|
|
await applySyncPayload(optedIn, { importVaultData: () => {} });
|
|
const restoredChoice = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
|
assert.equal(restoredChoice.autoCloseOnExit, true);
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
|
|
} finally {
|
|
localStorage.removeItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED);
|
|
}
|
|
});
|
|
|
|
test("terminal disconnected notice preference survives sync round-trip", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ disconnectedNoticeMode: "dialog" }),
|
|
);
|
|
|
|
const payload = buildSyncPayload(vault());
|
|
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
|
assert.equal(termSettings.disconnectedNoticeMode, "dialog");
|
|
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ disconnectedNoticeMode: "terminal" }),
|
|
);
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
|
assert.equal(restored.disconnectedNoticeMode, "dialog");
|
|
});
|
|
|
|
test("applySyncPayload restores the terminal host information bar preference", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ showHostInfoBar: true, scrollback: 5000 }),
|
|
);
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
settings: { terminalSettings: { showHostInfoBar: false } },
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: () => {},
|
|
});
|
|
|
|
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
|
assert.ok(raw, "TERM_SETTINGS should be written");
|
|
const parsed = JSON.parse(raw!);
|
|
assert.equal(parsed.showHostInfoBar, false);
|
|
assert.equal(parsed.scrollback, 5000);
|
|
});
|
|
|
|
test("bar cursor width survives terminal settings sync round-trip", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ cursorBarWidth: 3 }),
|
|
);
|
|
|
|
const payload = buildSyncPayload(vault());
|
|
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
|
assert.equal(termSettings.cursorBarWidth, 3);
|
|
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ cursorBarWidth: 1 }),
|
|
);
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
|
|
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
|
assert.equal(restored.cursorBarWidth, 3);
|
|
});
|
|
|
|
test("buildSyncPayload omits fallbackFont when TERM_SETTINGS does not set it", () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ scrollback: 5000, fontLigatures: true }),
|
|
);
|
|
|
|
const payload = buildSyncPayload(vault());
|
|
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
|
assert.equal("fallbackFont" in termSettings, false);
|
|
});
|
|
|
|
test("applySyncPayload writes incoming fallbackFont into local TERM_SETTINGS", async () => {
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
settings: { terminalSettings: { fallbackFont: "Sarasa Mono SC" } },
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: () => {},
|
|
});
|
|
|
|
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
|
assert.ok(raw, "TERM_SETTINGS should be written");
|
|
const parsed = JSON.parse(raw!);
|
|
assert.equal(parsed.fallbackFont, "Sarasa Mono SC");
|
|
});
|
|
|
|
test("applySyncPayload writes incoming Shift+Enter terminal settings", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ scrollback: 5000 }),
|
|
);
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
settings: {
|
|
terminalSettings: {
|
|
shiftEnterNewlineEnabled: false,
|
|
shiftEnterNewlineText: " \\\\\\n",
|
|
},
|
|
},
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: () => {},
|
|
});
|
|
|
|
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
|
assert.ok(raw, "TERM_SETTINGS should be written");
|
|
const parsed = JSON.parse(raw!);
|
|
assert.equal(parsed.scrollback, 5000);
|
|
assert.equal(parsed.shiftEnterNewlineEnabled, false);
|
|
assert.equal(parsed.shiftEnterNewlineText, " \\\\\\n");
|
|
});
|
|
|
|
test("applySyncPayload lets legacy middle-click paste update the new middle-click behavior", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({
|
|
scrollback: 2000,
|
|
middleClickBehavior: "paste",
|
|
middleClickPaste: true,
|
|
}),
|
|
);
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
settings: {
|
|
terminalSettings: {
|
|
middleClickPaste: false,
|
|
},
|
|
},
|
|
} as SyncPayload;
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: () => {},
|
|
});
|
|
|
|
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
|
assert.ok(raw, "TERM_SETTINGS should be written");
|
|
const parsed = JSON.parse(raw!);
|
|
assert.equal(parsed.scrollback, 2000);
|
|
assert.equal(parsed.middleClickBehavior, "disabled");
|
|
assert.equal(parsed.middleClickPaste, false);
|
|
});
|
|
|
|
test("applySyncPayload from legacy client (no fallbackFont) preserves local value", async () => {
|
|
localStorage.setItem(
|
|
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ scrollback: 5000, fallbackFont: "Microsoft YaHei UI" }),
|
|
);
|
|
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
settings: { terminalSettings: { scrollback: 9999 } },
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: () => {},
|
|
});
|
|
|
|
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
|
const parsed = JSON.parse(raw!);
|
|
assert.equal(parsed.fallbackFont, "Microsoft YaHei UI", "legacy payload must not wipe local fallbackFont");
|
|
assert.equal(parsed.scrollback, 9999);
|
|
});
|
|
|
|
test("applyLocalVaultPayload restores known hosts from local backups", async () => {
|
|
let imported: Record<string, unknown> | null = null;
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
knownHosts: [knownHost("kh-backup")],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
await applyLocalVaultPayload(payload, {
|
|
importVaultData: (json) => {
|
|
imported = JSON.parse(json);
|
|
},
|
|
});
|
|
|
|
assert.ok(imported);
|
|
assert.deepEqual(imported.knownHosts, [knownHost("kh-backup")]);
|
|
});
|
|
|
|
test("applyLocalVaultPayload prepares before import and commits after it succeeds", async () => {
|
|
const calls: string[] = [];
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
await applyLocalVaultPayload(payload, {
|
|
importVaultData: () => {
|
|
calls.push("import");
|
|
},
|
|
}, {
|
|
prepareConvergentRestore: async () => {
|
|
calls.push("prepare");
|
|
return async () => {
|
|
calls.push("commit");
|
|
};
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(calls, ["prepare", "import", "commit"]);
|
|
});
|
|
|
|
test("prepareLocalVaultPayloadApply does not import until the prepared callback runs", async () => {
|
|
const calls: string[] = [];
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
const applyPreparedPayload = await prepareLocalVaultPayloadApply(payload, {
|
|
importVaultData: () => {
|
|
calls.push("import");
|
|
},
|
|
}, {
|
|
prepareConvergentRestore: async () => {
|
|
calls.push("prepare");
|
|
return async () => {
|
|
calls.push("commit");
|
|
};
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(calls, ["prepare"]);
|
|
await applyPreparedPayload();
|
|
assert.deepEqual(calls, ["prepare", "import", "commit"]);
|
|
});
|
|
|
|
test("prepareLocalVaultPayloadApply sanitizes enc:v1 before convergent prepare and apply", async () => {
|
|
const completeBlob = Buffer.alloc(31, 0);
|
|
Buffer.from("v10", "utf8").copy(completeBlob, 0);
|
|
const ENC = `enc:v1:${completeBlob.toString("base64")}`;
|
|
const preparedPayloads: SyncPayload[] = [];
|
|
const imported: Array<Record<string, unknown>> = [];
|
|
|
|
const applyPreparedPayload = await prepareLocalVaultPayloadApply({
|
|
hosts: [{
|
|
id: "h1",
|
|
label: "prod",
|
|
hostname: "prod.example",
|
|
username: "root",
|
|
password: ENC,
|
|
port: 22,
|
|
os: "linux",
|
|
group: "",
|
|
tags: [],
|
|
protocol: "ssh",
|
|
}],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
}, {
|
|
importVaultData: (json) => {
|
|
imported.push(JSON.parse(json) as Record<string, unknown>);
|
|
},
|
|
}, {
|
|
prepareConvergentRestore: async (payload) => {
|
|
preparedPayloads.push(payload);
|
|
return async () => undefined;
|
|
},
|
|
});
|
|
|
|
await applyPreparedPayload();
|
|
|
|
assert.equal(preparedPayloads.length, 1);
|
|
assert.equal(preparedPayloads[0]?.hosts[0]?.password, undefined);
|
|
assert.equal(imported.length, 1);
|
|
const hosts = imported[0]?.hosts as Array<{ password?: string }> | undefined;
|
|
assert.equal(hosts?.[0]?.password, undefined);
|
|
});
|
|
|
|
test("applyLocalVaultPayload leaves local data untouched when convergent preparation fails", async () => {
|
|
let imported = false;
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
await assert.rejects(
|
|
() => applyLocalVaultPayload(payload, {
|
|
importVaultData: () => {
|
|
imported = true;
|
|
},
|
|
}, {
|
|
prepareConvergentRestore: async () => {
|
|
throw new Error("replica unavailable");
|
|
},
|
|
}),
|
|
/replica unavailable/,
|
|
);
|
|
|
|
assert.equal(imported, false);
|
|
});
|
|
|
|
test("applyLocalVaultPayload does not commit convergent writes when local import fails", async () => {
|
|
let committed = false;
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
};
|
|
|
|
await assert.rejects(
|
|
() => applyLocalVaultPayload(payload, {
|
|
importVaultData: async () => {
|
|
throw new Error("local import failed");
|
|
},
|
|
}, {
|
|
prepareConvergentRestore: async () => async () => {
|
|
committed = true;
|
|
},
|
|
}),
|
|
/local import failed/,
|
|
);
|
|
|
|
assert.equal(committed, false);
|
|
});
|
|
|
|
test("withPluginSyncSidecars attaches non-empty plugin sidecar bundles", () => {
|
|
const base = buildSyncPayload({
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
});
|
|
const withEmpty = withPluginSyncSidecars(base, { version: 1, entries: [] });
|
|
assert.deepEqual(withEmpty.pluginSidecars, { version: 1, entries: [] });
|
|
|
|
const withData = withPluginSyncSidecars(base, {
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.example.sync",
|
|
kind: "settings",
|
|
key: "com.example.sync.theme\0application\0application",
|
|
value: "dark",
|
|
updatedAt: 1,
|
|
}],
|
|
});
|
|
assert.equal(withData.pluginSidecars?.entries.length, 1);
|
|
assert.equal(withData.pluginSidecars?.entries[0].value, "dark");
|
|
});
|
|
|
|
test("buildCloudSyncPayload includes plugin sidecars from the production collector hook", async () => {
|
|
const payload = await buildCloudSyncPayload(vault([]), [], {
|
|
collectPluginSidecars: async () => ({
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.example.sync",
|
|
kind: "account_baseline",
|
|
key: "account",
|
|
value: { id: "acct-1" },
|
|
updatedAt: 9,
|
|
}],
|
|
}),
|
|
});
|
|
assert.equal(payload.pluginSidecars?.entries.length, 1);
|
|
assert.equal(payload.pluginSidecars?.entries[0].kind, "account_baseline");
|
|
assert.deepEqual(payload.pluginSidecars?.entries[0].value, { id: "acct-1" });
|
|
});
|
|
|
|
test("applySyncPayload applies pluginSidecars through the production applier hook", async () => {
|
|
let applied: unknown = null;
|
|
const payload: SyncPayload = {
|
|
hosts: [],
|
|
keys: [],
|
|
identities: [],
|
|
snippets: [],
|
|
customGroups: [],
|
|
syncedAt: 1,
|
|
pluginSidecars: {
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.missing.plugin",
|
|
kind: "crdt_baseline",
|
|
key: "replica",
|
|
value: { clock: 3 },
|
|
updatedAt: 2,
|
|
}],
|
|
},
|
|
};
|
|
|
|
await applySyncPayload(payload, {
|
|
importVaultData: () => {},
|
|
}, {
|
|
applyPluginSidecars: async (sidecars) => {
|
|
applied = sidecars;
|
|
},
|
|
});
|
|
|
|
assert.equal((applied as SyncPayload["pluginSidecars"])?.entries[0].value.clock, 3);
|
|
});
|
|
|
|
test("local shell side panel settings round-trip independently and trigger auto-sync", async () => {
|
|
for (const key of [storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB]) {
|
|
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(key));
|
|
}
|
|
for (const enabled of [true, false]) {
|
|
localStorage.clear();
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, String(enabled));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, "notes");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, String(!enabled));
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, "scripts");
|
|
const payload = await buildSyncPayload(vault());
|
|
assert.equal(payload.settings?.localShellSidePanelAutoOpen, enabled);
|
|
assert.equal(payload.settings?.localShellSidePanelAutoOpenTab, "notes");
|
|
localStorage.clear();
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN), String(enabled));
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB), "notes");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN), String(!enabled));
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB), "scripts");
|
|
}
|
|
});
|
|
|
|
test("missing local shell settings and invalid tabs preserve existing settings", async () => {
|
|
const payload = await buildSyncPayload(vault());
|
|
assert.equal(payload.settings?.localShellSidePanelAutoOpen, undefined);
|
|
assert.equal(payload.settings?.localShellSidePanelAutoOpenTab, undefined);
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, "true");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, "notes");
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN), "true");
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB), "notes");
|
|
await applySyncPayload({ ...payload, settings: { localShellSidePanelAutoOpenTab: "invalid" } } as unknown as SyncPayload, { importVaultData: () => {} });
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB), "notes");
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, "invalid");
|
|
assert.equal((await buildSyncPayload(vault())).settings?.localShellSidePanelAutoOpenTab, undefined);
|
|
});
|
|
|
|
test("old bottom tab preference is ignored by settings export and import", async () => {
|
|
localStorage.clear();
|
|
const legacyKey = "netcatty_tab_bar_position_v1";
|
|
localStorage.setItem(legacyKey, "bottom");
|
|
const payload = buildSyncPayload(vault([]));
|
|
assert.equal(Object.hasOwn(payload.settings ?? {}, "tabBarPosition"), false);
|
|
await applySyncPayload({ ...payload, settings: { tabBarPosition: "bottom" } } as unknown as SyncPayload, { importVaultData: () => {} });
|
|
assert.equal(localStorage.getItem(legacyKey), "bottom");
|
|
localStorage.removeItem(legacyKey);
|
|
await applySyncPayload({ ...payload, settings: { tabBarPosition: "bottom" } } as unknown as SyncPayload, { importVaultData: () => {} });
|
|
assert.equal(localStorage.getItem(legacyKey), null);
|
|
});
|
|
for (const enabled of [true, false]) {
|
|
test(`right-click long press preference survives sync (${enabled})`, async () => {
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ rightClickLongPressMenu: enabled }));
|
|
const payload = buildSyncPayload(vault());
|
|
assert.equal(payload.settings?.terminalSettings?.rightClickLongPressMenu, enabled);
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
|
JSON.stringify({ rightClickLongPressMenu: !enabled }));
|
|
await applySyncPayload(payload, { importVaultData: () => {} });
|
|
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
|
assert.equal(restored.rightClickLongPressMenu, enabled);
|
|
});
|
|
}
|
|
test('custom provider headers use portable cloud secrets and local encryption with legacy preservation', async () => {
|
|
localStorage.clear();
|
|
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 2)]).toString('base64')}`;
|
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
|
netcatty: {
|
|
credentialsEncrypt: async () => sealed,
|
|
credentialsDecrypt: async (value: string) => value === sealed ? 'tenant-secret' : value,
|
|
},
|
|
dispatchEvent: () => true,
|
|
} });
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
|
id: 'custom-headers', providerId: 'custom', enabled: true, customHeaders: { 'X-Tenant': sealed },
|
|
}]));
|
|
const plainSettings = buildSyncPayload(vault([]));
|
|
assert.equal(plainSettings.settings?.ai?.providers?.[0]?.customHeaders, undefined);
|
|
const cloud = await buildCloudSyncPayload(vault([]));
|
|
assert.deepEqual(cloud.settings?.ai?.providers?.[0]?.customHeaders, { 'X-Tenant': 'tenant-secret' });
|
|
await applySyncPayload(cloud, { importVaultData: () => {} });
|
|
const read = () => JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
|
|
assert.deepEqual(read().customHeaders, { 'X-Tenant': sealed });
|
|
await applySyncPayload(plainSettings, { importVaultData: () => {} });
|
|
assert.deepEqual(read().customHeaders, { 'X-Tenant': sealed });
|
|
cloud.settings!.ai!.providers![0].customHeaders = {};
|
|
await applySyncPayload(cloud, { importVaultData: () => {} });
|
|
assert.deepEqual(read().customHeaders, {});
|
|
});
|
|
|
|
for (const localRestore of [false, true]) {
|
|
test(`header encryption failure leaves vault and settings untouched (${localRestore ? 'restore' : 'sync'})`, async () => {
|
|
localStorage.clear();
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_THEME, 'light');
|
|
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, '[]');
|
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
|
netcatty: { credentialsEncrypt: async () => { throw new Error('keychain locked'); } },
|
|
dispatchEvent: () => true,
|
|
} });
|
|
const payload: SyncPayload = { ...vault([]), syncedAt: 1, settings: {
|
|
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { Authorization: 'secret' } }] },
|
|
} };
|
|
const original = JSON.stringify(payload);
|
|
let imports = 0;
|
|
let completed = 0;
|
|
let commits = 0;
|
|
const importers = { importVaultData: () => { imports++; }, onSettingsApplied: () => { completed++; } };
|
|
await assert.rejects(localRestore
|
|
? applyLocalVaultPayload(payload, importers, { prepareConvergentRestore: async () => async () => { commits++; } })
|
|
: applySyncPayload(payload, importers));
|
|
assert.equal(imports, 0);
|
|
assert.equal(completed, 0);
|
|
assert.equal(commits, 0);
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_THEME), 'light');
|
|
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS), '[]');
|
|
assert.equal(JSON.stringify(payload), original);
|
|
});
|
|
}
|
|
|
|
test('sync prepares header encryption once before importing and leaves input unchanged', async () => {
|
|
localStorage.clear();
|
|
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 4)]).toString('base64')}`;
|
|
const events: string[] = [];
|
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
|
netcatty: { credentialsEncrypt: async () => { events.push('encrypt'); return sealed; } },
|
|
dispatchEvent: () => true,
|
|
} });
|
|
const payload: SyncPayload = { ...vault([]), syncedAt: 1, settings: {
|
|
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] },
|
|
} };
|
|
const original = JSON.stringify(payload);
|
|
await applySyncPayload(payload, { importVaultData: () => { events.push('import'); } });
|
|
assert.deepEqual(events, ['encrypt', 'import']);
|
|
assert.equal(JSON.stringify(payload), original);
|
|
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0].customHeaders, { 'X-Tenant': sealed });
|
|
});
|