Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
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",
|
|
);
|
|
});
|