CI / Build & Quality Checks (push) Successful in 1m27s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 1m36s
Every Lotus setting was localStorage-only, so a user on web + desktop + phone configured theme, composer toolbar, quiet hours, call keys… three times. - utils/settingsSync.ts (pure, 7 tests): DEVICE_LOCAL_KEYS denylist (zoom, media auto-load, animation pause, glassmorphism, denoise tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state, and the sync toggle itself), pickSyncable, mergeRemoteSettings (unknown keys, device-local keys and wrong-shaped values are skipped), buildSyncedContent, shouldApplyRemote (LWW on updatedAt; equal stamp = our own echo). - hooks/useSettingsSync.ts: on start applies a newer remote snapshot or pushes local if it differs; debounced push on any settingsAtom write, skipped when the syncable subset equals the last pushed/applied snapshot so a remote apply never echoes back; AccountData listener for live updates; stamps forced monotonic per device; per-account lastSyncedAt marker so another user on the same device can't inherit it; failed pushes roll the marker back so the next change retries. Remote values are re-read through getSettings() so enum coercion applies. - Settings → General → Sync: toggle (device-local), "Push now", "Clear synced copy". AccountDataEvent.LotusSettings registered. - ClientNonUIFeatures: the #103 tracking-param subscriber moves out of PageZoomFeature into its own TrackingParamsFeature next to SettingsSyncFeature. - Docs: LOTUS_FEATURES entries for #103/#104; LOTUS_TODO links the new Features 2026-Q4 milestone and #108. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
107 lines
3.9 KiB
TypeScript
107 lines
3.9 KiB
TypeScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
DEVICE_LOCAL_KEYS,
|
|
SETTINGS_SYNC_VERSION,
|
|
buildSyncedContent,
|
|
isSyncedSettingsContent,
|
|
mergeRemoteSettings,
|
|
pickSyncable,
|
|
shouldApplyRemote,
|
|
syncableEqual,
|
|
} from './settingsSync';
|
|
import { Settings } from '../state/settings';
|
|
|
|
// A representative slice of Settings; the helpers are generic over keys so a
|
|
// partial object cast is enough to exercise every branch.
|
|
const base = {
|
|
settingsSync: true,
|
|
pageZoom: 110,
|
|
isMarkdown: true,
|
|
quietHoursEnabled: false,
|
|
quietHoursStart: '22:00',
|
|
composerToolbarButtons: { showGif: true, order: ['showEmoji', 'showGif'] },
|
|
pttKey: 'KeyV',
|
|
callDenoiseModel: 'rnnoise',
|
|
} as unknown as Settings;
|
|
|
|
describe('pickSyncable', () => {
|
|
it('drops device-local keys and deep-copies objects', () => {
|
|
const picked = pickSyncable(base);
|
|
assert.equal('pageZoom' in picked, false);
|
|
assert.equal('settingsSync' in picked, false);
|
|
assert.equal('callDenoiseModel' in picked, false);
|
|
assert.equal(picked.isMarkdown, true);
|
|
assert.equal(picked.pttKey, 'KeyV');
|
|
assert.notEqual(picked.composerToolbarButtons, base.composerToolbarButtons);
|
|
assert.deepEqual(picked.composerToolbarButtons, base.composerToolbarButtons);
|
|
});
|
|
|
|
it('never syncs the sync toggle itself', () => {
|
|
assert.equal(DEVICE_LOCAL_KEYS.has('settingsSync'), true);
|
|
});
|
|
});
|
|
|
|
describe('syncableEqual', () => {
|
|
it('is structural and key-order independent', () => {
|
|
const a = pickSyncable(base);
|
|
const b = Object.fromEntries(Object.entries(a).reverse()) as Partial<Settings>;
|
|
assert.equal(syncableEqual(a, b), true);
|
|
assert.equal(syncableEqual(a, { ...a, isMarkdown: false }), false);
|
|
assert.equal(syncableEqual(a, { ...a, extra: 1 } as Partial<Settings>), false);
|
|
});
|
|
});
|
|
|
|
describe('isSyncedSettingsContent', () => {
|
|
it('accepts a well-formed event and rejects junk', () => {
|
|
assert.equal(isSyncedSettingsContent(buildSyncedContent(base, 5)), true);
|
|
assert.equal(isSyncedSettingsContent({}), false);
|
|
assert.equal(isSyncedSettingsContent(null), false);
|
|
assert.equal(isSyncedSettingsContent({ version: 1, updatedAt: 'x', settings: {} }), false);
|
|
assert.equal(isSyncedSettingsContent({ version: 1, updatedAt: 1, settings: [] }), false);
|
|
});
|
|
});
|
|
|
|
describe('mergeRemoteSettings', () => {
|
|
it('applies syncable keys, ignores device-local and unknown keys and wrong shapes', () => {
|
|
const remote = {
|
|
version: SETTINGS_SYNC_VERSION,
|
|
updatedAt: 10,
|
|
settings: {
|
|
isMarkdown: false,
|
|
pageZoom: 200, // device-local: ignored
|
|
quietHoursStart: 42, // wrong type: ignored
|
|
composerToolbarButtons: 'nope', // wrong type: ignored
|
|
futureKey: true, // unknown: ignored
|
|
} as unknown as Partial<Settings>,
|
|
};
|
|
const merged = mergeRemoteSettings(base, remote);
|
|
assert.equal(merged.isMarkdown, false);
|
|
assert.equal(merged.pageZoom, 110);
|
|
assert.equal(merged.quietHoursStart, '22:00');
|
|
assert.deepEqual(merged.composerToolbarButtons, base.composerToolbarButtons);
|
|
assert.equal('futureKey' in merged, false);
|
|
// Never mutates the input.
|
|
assert.equal(base.isMarkdown, true);
|
|
});
|
|
});
|
|
|
|
describe('shouldApplyRemote', () => {
|
|
it('applies when never synced or strictly newer; ignores echoes', () => {
|
|
const remote = buildSyncedContent(base, 100);
|
|
assert.equal(shouldApplyRemote(remote, null), true);
|
|
assert.equal(shouldApplyRemote(remote, 99), true);
|
|
assert.equal(shouldApplyRemote(remote, 100), false);
|
|
assert.equal(shouldApplyRemote(remote, 101), false);
|
|
});
|
|
});
|
|
|
|
describe('buildSyncedContent', () => {
|
|
it('stamps version and updatedAt around the syncable subset', () => {
|
|
const c = buildSyncedContent(base, 7);
|
|
assert.equal(c.version, SETTINGS_SYNC_VERSION);
|
|
assert.equal(c.updatedAt, 7);
|
|
assert.deepEqual(c.settings, pickSyncable(base));
|
|
});
|
|
});
|