mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
d7b749d23f
* fix(sync): rehydrate follow-app theme and guard partial groupConfig wipes Second devices were keeping the local follow-app terminal theme flag after cloud sync wrote peer appearance settings, which flickered black vs yellow. Also block uploading an empty groupConfigs list while other vault data remains so hydration races cannot clear startup-command group defaults. Fixes #2757 Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(sync): narrow complete-wipe shrink guard to groupConfigs Only treat empty groupConfigs with other vault data present as suspicious. Wiping the last snippet/note/etc. while hosts remain stays a normal delete. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import { idleThemeUserIntent, resolveGlobalTerminalAppearance } from "../../domain/terminalAppearanceRuntime.ts";
|
|
import {
|
|
hasPersistedAppearanceChanged,
|
|
resolveAppearanceStorageEvent,
|
|
resolveAppearanceSyncState,
|
|
resolveIncomingAppearanceValue,
|
|
type AppearanceState,
|
|
type AppearanceRenderSnapshot,
|
|
type StoredAppearanceValues,
|
|
} from "./appearanceSync.ts";
|
|
import {
|
|
STORAGE_KEY_ACCENT_MODE,
|
|
STORAGE_KEY_COLOR,
|
|
STORAGE_KEY_THEME,
|
|
STORAGE_KEY_UI_THEME_DARK,
|
|
STORAGE_KEY_UI_THEME_LIGHT,
|
|
} from "../../infrastructure/config/storageKeys.ts";
|
|
|
|
const systemLight: AppearanceRenderSnapshot = {
|
|
theme: "system",
|
|
resolvedTheme: "light",
|
|
lightUiThemeId: "snow",
|
|
darkUiThemeId: "midnight",
|
|
accentMode: "theme",
|
|
customAccent: "208 100% 50%",
|
|
customAccentVersion: 0,
|
|
};
|
|
|
|
test("an OS color event cannot persist a stale System choice over a newer Dark choice", () => {
|
|
const systemDark = { ...systemLight, resolvedTheme: "dark" as const };
|
|
let storedTheme: "light" | "dark" | "system" = "dark";
|
|
|
|
if (hasPersistedAppearanceChanged(systemLight, systemDark)) {
|
|
storedTheme = systemDark.theme;
|
|
}
|
|
|
|
assert.equal(storedTheme, "dark");
|
|
});
|
|
|
|
test("an explicit theme choice remains a persisted appearance change", () => {
|
|
assert.equal(
|
|
hasPersistedAppearanceChanged(systemLight, {
|
|
...systemLight,
|
|
theme: "dark",
|
|
resolvedTheme: "dark",
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("an appearance IPC value wins over stale local storage for the changed key", () => {
|
|
const incoming = { key: STORAGE_KEY_THEME, value: "dark" };
|
|
|
|
assert.equal(
|
|
resolveIncomingAppearanceValue(
|
|
incoming,
|
|
STORAGE_KEY_THEME,
|
|
"system",
|
|
"system",
|
|
(value): value is "light" | "dark" | "system" => (
|
|
value === "light" || value === "dark" || value === "system"
|
|
),
|
|
),
|
|
"dark",
|
|
);
|
|
// Non-matching keyed IPC must keep the current in-memory value, not storage.
|
|
assert.equal(
|
|
resolveIncomingAppearanceValue(
|
|
incoming,
|
|
STORAGE_KEY_UI_THEME_DARK,
|
|
"stale-from-storage",
|
|
"midnight",
|
|
(value): value is string => typeof value === "string",
|
|
),
|
|
"midnight",
|
|
);
|
|
// Full rehydrate (no incoming) still prefers storage.
|
|
assert.equal(
|
|
resolveIncomingAppearanceValue(
|
|
undefined,
|
|
STORAGE_KEY_UI_THEME_DARK,
|
|
"from-storage",
|
|
"midnight",
|
|
(value): value is string => typeof value === "string",
|
|
),
|
|
"from-storage",
|
|
);
|
|
});
|
|
|
|
test("a non-theme appearance IPC value wins over stale local storage for that key", () => {
|
|
const current: AppearanceState = {
|
|
theme: "dark",
|
|
lightUiThemeId: "snow",
|
|
darkUiThemeId: "midnight",
|
|
accentMode: "theme",
|
|
customAccent: "208 100% 50%",
|
|
customAccentVersion: 0,
|
|
};
|
|
// Storage still lags for every non-theme field (the race the review covers).
|
|
const staleStored: StoredAppearanceValues = {
|
|
theme: "dark",
|
|
lightUiThemeId: "snow",
|
|
darkUiThemeId: "midnight",
|
|
accentMode: "theme",
|
|
customAccent: "208 100% 50%",
|
|
};
|
|
|
|
// Picking a follow-app terminal theme updates darkUiThemeId; only that key is
|
|
// announced on the IPC payload, so the reducer must prefer the payload.
|
|
const darkUiSelection = {
|
|
key: STORAGE_KEY_UI_THEME_DARK,
|
|
value: "github",
|
|
};
|
|
const nextDarkUi = resolveAppearanceSyncState(current, {
|
|
...staleStored,
|
|
darkUiThemeId: "midnight",
|
|
}, darkUiSelection);
|
|
assert.equal(nextDarkUi.darkUiThemeId, "github");
|
|
assert.equal(nextDarkUi.theme, "dark");
|
|
assert.equal(nextDarkUi.lightUiThemeId, "snow");
|
|
|
|
const lightUiSelection = {
|
|
key: STORAGE_KEY_UI_THEME_LIGHT,
|
|
value: "flexoki",
|
|
};
|
|
const nextLightUi = resolveAppearanceSyncState(current, {
|
|
...staleStored,
|
|
lightUiThemeId: "snow",
|
|
}, lightUiSelection);
|
|
assert.equal(nextLightUi.lightUiThemeId, "flexoki");
|
|
|
|
const accentModeSelection = {
|
|
key: STORAGE_KEY_ACCENT_MODE,
|
|
value: "custom",
|
|
};
|
|
const nextAccentMode = resolveAppearanceSyncState(current, {
|
|
...staleStored,
|
|
accentMode: "theme",
|
|
}, accentModeSelection);
|
|
assert.equal(nextAccentMode.accentMode, "custom");
|
|
|
|
const customAccentSelection = {
|
|
key: STORAGE_KEY_COLOR,
|
|
value: "221.2 83.2% 53.3%",
|
|
};
|
|
const nextCustomAccent = resolveAppearanceSyncState(current, {
|
|
...staleStored,
|
|
customAccent: "208 100% 50%",
|
|
}, customAccentSelection);
|
|
assert.equal(nextCustomAccent.customAccent, "221.2 83.2% 53.3%");
|
|
|
|
// Stale lower-version accent echoes must not clobber a newer local drag sample.
|
|
const afterDrag = {
|
|
...current,
|
|
customAccent: "199 89% 48%",
|
|
customAccentVersion: 2,
|
|
};
|
|
const staleAccentEcho = resolveAppearanceSyncState(afterDrag, {
|
|
...staleStored,
|
|
customAccent: "0 84% 60%",
|
|
}, {
|
|
key: STORAGE_KEY_COLOR,
|
|
value: { color: "0 84% 60%", version: 1 },
|
|
});
|
|
assert.equal(staleAccentEcho.customAccent, "199 89% 48%");
|
|
assert.equal(staleAccentEcho.customAccentVersion, 2);
|
|
|
|
// Without an announced key, full rehydrate still reads storage.
|
|
const noIncoming = resolveAppearanceSyncState(current, {
|
|
...staleStored,
|
|
darkUiThemeId: "github",
|
|
});
|
|
assert.equal(noIncoming.darkUiThemeId, "github");
|
|
});
|
|
|
|
test("sequential keyed appearance IPC updates compose without stale-storage clobber", () => {
|
|
// One action changes theme + dark UI theme; the sender emits two keyed notifies.
|
|
// Storage still holds the pre-change values for the entire sequence.
|
|
const initial: AppearanceState = {
|
|
theme: "system",
|
|
lightUiThemeId: "snow",
|
|
darkUiThemeId: "midnight",
|
|
accentMode: "theme",
|
|
customAccent: "208 100% 50%",
|
|
customAccentVersion: 0,
|
|
};
|
|
const staleStored: StoredAppearanceValues = { ...initial };
|
|
|
|
let next = resolveAppearanceSyncState(initial, staleStored, {
|
|
key: STORAGE_KEY_THEME,
|
|
value: "dark",
|
|
});
|
|
assert.equal(next.theme, "dark");
|
|
assert.equal(next.darkUiThemeId, "midnight");
|
|
|
|
// Later theme-id message must not revert theme back to the stale stored System.
|
|
next = resolveAppearanceSyncState(next, staleStored, {
|
|
key: STORAGE_KEY_UI_THEME_DARK,
|
|
value: "github",
|
|
});
|
|
assert.equal(next.theme, "dark");
|
|
assert.equal(next.darkUiThemeId, "github");
|
|
assert.equal(next.lightUiThemeId, "snow");
|
|
assert.equal(next.accentMode, "theme");
|
|
});
|
|
|
|
test("System on a light OS changes to Dark in every open follow-app terminal", () => {
|
|
const initialState: AppearanceState = {
|
|
theme: systemLight.theme,
|
|
lightUiThemeId: systemLight.lightUiThemeId,
|
|
darkUiThemeId: systemLight.darkUiThemeId,
|
|
accentMode: systemLight.accentMode,
|
|
customAccent: systemLight.customAccent,
|
|
customAccentVersion: systemLight.customAccentVersion,
|
|
};
|
|
const terminalAppearance = (appearance: AppearanceState, resolvedTheme: "light" | "dark") => (
|
|
resolveGlobalTerminalAppearance({
|
|
userIntent: idleThemeUserIntent(),
|
|
settings: {
|
|
terminalThemeId: "netcatty-dark",
|
|
terminalThemeDarkId: "auto",
|
|
terminalThemeLightId: "auto",
|
|
followAppTerminalTheme: true,
|
|
resolvedTheme,
|
|
lightUiThemeId: appearance.lightUiThemeId,
|
|
darkUiThemeId: appearance.darkUiThemeId,
|
|
accentMode: appearance.accentMode,
|
|
customAccent: appearance.customAccent,
|
|
},
|
|
customThemes: [],
|
|
})
|
|
);
|
|
const persistRender = (
|
|
stored: StoredAppearanceValues,
|
|
previous: AppearanceRenderSnapshot,
|
|
current: AppearanceRenderSnapshot,
|
|
): StoredAppearanceValues => {
|
|
if (!hasPersistedAppearanceChanged(previous, current)) return stored;
|
|
return {
|
|
theme: current.theme,
|
|
lightUiThemeId: current.lightUiThemeId,
|
|
darkUiThemeId: current.darkUiThemeId,
|
|
accentMode: current.accentMode,
|
|
customAccent: current.customAccent,
|
|
};
|
|
};
|
|
|
|
let stored: StoredAppearanceValues = { ...initialState };
|
|
let main = { ...initialState };
|
|
let detachedTerminal = { ...initialState };
|
|
const initialMainTerminal = terminalAppearance(main, "light");
|
|
const initialDetachedTerminal = terminalAppearance(detachedTerminal, "light");
|
|
|
|
const settingsDark: AppearanceRenderSnapshot = {
|
|
...systemLight,
|
|
theme: "dark",
|
|
resolvedTheme: "dark",
|
|
};
|
|
stored = persistRender(stored, systemLight, settingsDark);
|
|
const darkSelection = { key: STORAGE_KEY_THEME, value: "dark" };
|
|
|
|
// A stale peer receives an OS color event before the Dark IPC message. Its
|
|
// semantic choice is still System, so it must not write System back.
|
|
stored = persistRender(stored, systemLight, { ...systemLight, resolvedTheme: "dark" });
|
|
|
|
// Main windows run the production IPC reducer. Deliberately pass its lagging
|
|
// System storage read so only the ordered Dark payload can win.
|
|
main = resolveAppearanceSyncState(main, {
|
|
...stored,
|
|
theme: "system",
|
|
}, darkSelection);
|
|
|
|
// Detached terminal and tray renderers run the production storage-event
|
|
// reducer because they are not direct IPC broadcast targets.
|
|
const detachedUpdate = resolveAppearanceStorageEvent(
|
|
detachedTerminal,
|
|
darkSelection.key,
|
|
String(stored.theme),
|
|
);
|
|
detachedTerminal = detachedUpdate.next;
|
|
|
|
const finalMainTerminal = terminalAppearance(main, "dark");
|
|
const finalDetachedTerminal = terminalAppearance(detachedTerminal, "dark");
|
|
|
|
assert.equal(stored.theme, "dark");
|
|
assert.equal(settingsDark.theme, "dark");
|
|
assert.equal(main.theme, "dark");
|
|
assert.equal(detachedUpdate.handled, true);
|
|
assert.equal(detachedTerminal.theme, "dark");
|
|
assert.equal(initialMainTerminal.theme.type, "light");
|
|
assert.equal(initialDetachedTerminal.theme.type, "light");
|
|
assert.equal(finalMainTerminal.theme.type, "dark");
|
|
assert.equal(finalDetachedTerminal.theme.type, "dark");
|
|
assert.notEqual(finalMainTerminal.theme.colors.background, initialMainTerminal.theme.colors.background);
|
|
assert.notEqual(finalDetachedTerminal.theme.colors.background, initialDetachedTerminal.theme.colors.background);
|
|
});
|
|
|
|
test("the race guards are wired into the real settings paths", () => {
|
|
const stateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
|
const ipcSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
|
const storageSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
|
|
const popupSource = readFileSync(new URL("../../components/TerminalPopupPage.tsx", import.meta.url), "utf8");
|
|
|
|
const guardIndex = stateSource.indexOf("hasPersistedAppearanceChanged(");
|
|
const returnIndex = stateSource.indexOf("if (!persistedAppearanceChanged && persistMountedRef.current)", guardIndex);
|
|
const writeIndex = stateSource.indexOf("localStorageAdapter.writeString(STORAGE_KEY_THEME", guardIndex);
|
|
const themeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_THEME, theme)", writeIndex);
|
|
const lightNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId)", writeIndex);
|
|
const darkNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId)", writeIndex);
|
|
const accentModeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode)", writeIndex);
|
|
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord)", writeIndex);
|
|
|
|
assert.ok(guardIndex >= 0, "the settings effect must compare persisted appearance fields");
|
|
assert.ok(returnIndex > guardIndex && returnIndex < writeIndex, "the stale render must stop before storage is written");
|
|
assert.ok(themeNotifyIndex > writeIndex, "theme changes must be announced over IPC with the new value");
|
|
assert.ok(lightNotifyIndex > writeIndex, "light UI theme changes must be announced over IPC with the new value");
|
|
assert.ok(darkNotifyIndex > writeIndex, "dark UI theme changes must be announced over IPC with the new value");
|
|
assert.ok(accentModeNotifyIndex > writeIndex, "accent mode changes must be announced over IPC with the new value");
|
|
assert.ok(colorNotifyIndex > writeIndex, "custom accent changes must be announced over IPC with the new value");
|
|
assert.match(stateSource, /shouldBroadcastCustomAccentChange/);
|
|
assert.match(stateSource, /serializeCustomAccentRecord\(customAccentRecord\)/);
|
|
// Source still only notifies fields that actually changed (keyed, not a single theme-only broadcast).
|
|
assert.match(
|
|
stateSource,
|
|
/previousAppearance\.theme !== theme[\s\S]*notifySettingsChanged\(STORAGE_KEY_THEME, theme\)/,
|
|
);
|
|
assert.match(
|
|
stateSource,
|
|
/previousAppearance\.lightUiThemeId !== lightUiThemeId[\s\S]*notifySettingsChanged\(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId\)/,
|
|
);
|
|
assert.match(
|
|
stateSource,
|
|
/previousAppearance\.darkUiThemeId !== darkUiThemeId[\s\S]*notifySettingsChanged\(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId\)/,
|
|
);
|
|
// Sequential keyed IPC must compose via a ref, not a render-stale closure.
|
|
assert.match(stateSource, /appearanceStateRef\.current = nextAppearance/);
|
|
assert.match(ipcSource, /syncAppearanceFromStorage\(\{ key, value \}\)/);
|
|
assert.match(storageSource, /resolveAppearanceStorageEvent\(s, e\.key, e\.newValue\)/);
|
|
assert.match(popupSource, /terminalTheme=\{settings\.currentTerminalTheme\}/);
|
|
assert.match(popupSource, /followAppTerminalTheme=\{settings\.followAppTerminalTheme\}/);
|
|
});
|
|
|
|
test("cloud sync rehydrate picks up follow-app terminal theme from storage", () => {
|
|
// applySyncPayload writes followAppTerminalTheme to localStorage, then calls
|
|
// rehydrateAllFromStorage. Without reading TERM_FOLLOW_APP_THEME there, the
|
|
// open window keeps the pre-sync follow-app flag while terminalThemeId updates
|
|
// — black default vs synced yellow theme flicker (#2757).
|
|
const stateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
|
const rehydrateIndex = stateSource.indexOf("const rehydrateAllFromStorage = useCallback(() => {");
|
|
assert.ok(rehydrateIndex >= 0, "rehydrateAllFromStorage must exist");
|
|
const nextCallbackIndex = stateSource.indexOf("}, [applyIncomingCustomKeyBindings", rehydrateIndex);
|
|
assert.ok(nextCallbackIndex > rehydrateIndex, "rehydrate callback body must be bounded");
|
|
const rehydrateBody = stateSource.slice(rehydrateIndex, nextCallbackIndex);
|
|
assert.match(
|
|
rehydrateBody,
|
|
/STORAGE_KEY_TERM_FOLLOW_APP_THEME/,
|
|
"rehydrate must read the follow-app terminal theme key written by applySyncableSettings",
|
|
);
|
|
assert.match(
|
|
rehydrateBody,
|
|
/setFollowAppTerminalThemeState/,
|
|
"rehydrate must update React follow-app state from storage after cloud sync",
|
|
);
|
|
});
|