The settings atom was a load-time snapshot with no storage listener and wrote the whole blob, so two tabs silently reverted each other. It now re-reads on storage events and writes only the keys that changed relative to the previous value. Unit-tested. Fixes #42 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
150 lines
5.8 KiB
TypeScript
150 lines
5.8 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { getSettings, setSettings, Settings } from './settings';
|
|
|
|
// getSettings() reads localStorage; node has none, so install a controllable
|
|
// mock per case. (The module already loaded safely with no localStorage thanks
|
|
// to the guarded catch — that path is exercised by the "throws" test below.)
|
|
const setStored = (value: string | null): void => {
|
|
(globalThis as { localStorage?: unknown }).localStorage = {
|
|
getItem: () => value,
|
|
setItem: () => undefined,
|
|
removeItem: () => undefined,
|
|
};
|
|
};
|
|
// Map-backed mock (unlike setStored above, get/set actually round-trip)
|
|
// for the setSettings merge tests below, which need writes to persist.
|
|
const installStorage = (): Map<string, string> => {
|
|
const map = new Map<string, string>();
|
|
(globalThis as { localStorage?: unknown }).localStorage = {
|
|
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
|
setItem: (k: string, v: string) => {
|
|
map.set(k, v);
|
|
},
|
|
removeItem: (k: string) => {
|
|
map.delete(k);
|
|
},
|
|
};
|
|
return map;
|
|
};
|
|
const setThrowingStorage = (): void => {
|
|
(globalThis as { localStorage?: unknown }).localStorage = {
|
|
getItem: () => {
|
|
throw new Error('storage blocked');
|
|
},
|
|
setItem: () => {
|
|
throw new Error('storage blocked');
|
|
},
|
|
removeItem: () => {
|
|
throw new Error('storage blocked');
|
|
},
|
|
};
|
|
};
|
|
|
|
test('returns defaults when nothing is stored', () => {
|
|
setStored(null);
|
|
assert.equal(getSettings().callNoiseSuppression, 'browser');
|
|
});
|
|
|
|
test('migrates the legacy boolean callNoiseSuppression to the 3-way mode', () => {
|
|
setStored(JSON.stringify({ callNoiseSuppression: true }));
|
|
assert.equal(getSettings().callNoiseSuppression, 'browser');
|
|
setStored(JSON.stringify({ callNoiseSuppression: false }));
|
|
assert.equal(getSettings().callNoiseSuppression, 'off');
|
|
// a new string value passes through untouched
|
|
setStored(JSON.stringify({ callNoiseSuppression: 'ml' }));
|
|
assert.equal(getSettings().callNoiseSuppression, 'ml');
|
|
});
|
|
|
|
test('coerces unknown persisted denoise model / ringtone id back to defaults', () => {
|
|
setStored(null);
|
|
const defaults = getSettings();
|
|
|
|
setStored(JSON.stringify({ callDenoiseModel: 'retired-model', ringtoneId: 'bogus' }));
|
|
const coerced = getSettings();
|
|
assert.equal(coerced.callDenoiseModel, defaults.callDenoiseModel);
|
|
assert.equal(coerced.ringtoneId, defaults.ringtoneId);
|
|
|
|
setStored(JSON.stringify({ callDenoiseModel: 'rnnoise', ringtoneId: 'chime' }));
|
|
const valid = getSettings();
|
|
assert.equal(valid.callDenoiseModel, 'rnnoise');
|
|
assert.equal(valid.ringtoneId, 'chime');
|
|
});
|
|
|
|
test('coerces unknown persisted seasonalThemeOverride back to auto', () => {
|
|
setStored(null);
|
|
const defaults = getSettings();
|
|
assert.equal(defaults.seasonalThemeOverride, 'auto');
|
|
|
|
setStored(JSON.stringify({ seasonalThemeOverride: 'retired-theme' }));
|
|
assert.equal(getSettings().seasonalThemeOverride, 'auto');
|
|
|
|
setStored(JSON.stringify({ seasonalThemeOverride: 'earthday' }));
|
|
assert.equal(getSettings().seasonalThemeOverride, 'earthday');
|
|
});
|
|
|
|
test('merges stored values over defaults', () => {
|
|
setStored(JSON.stringify({ callNoiseSuppression: 'off', someUnknownKey: 1 }));
|
|
const s = getSettings();
|
|
assert.equal(s.callNoiseSuppression, 'off');
|
|
// a default field not present in storage is still populated
|
|
assert.notEqual(s.callDenoiseModel, undefined);
|
|
});
|
|
|
|
test('returns defaults without throwing when localStorage access throws', () => {
|
|
setThrowingStorage();
|
|
// regression: the catch used to call localStorage.removeItem(), which re-threw
|
|
// and crashed the app at module load when storage was blocked.
|
|
assert.doesNotThrow(() => getSettings());
|
|
assert.equal(getSettings().callNoiseSuppression, 'browser');
|
|
});
|
|
|
|
test('returns defaults on malformed JSON', () => {
|
|
setStored('{ not valid json');
|
|
assert.doesNotThrow(() => getSettings());
|
|
assert.equal(getSettings().callNoiseSuppression, 'browser');
|
|
});
|
|
|
|
// Gitea #42: two tabs, each holding its own stale snapshot, must not clobber
|
|
// a key the *other* tab changed. setSettings() is given `previous` (this
|
|
// tab's own pre-update snapshot) and `next` (its whole-object update) and must
|
|
// apply only the keys that actually differ, on top of the freshest stored blob
|
|
// — not overwrite the blob wholesale with its own stale view.
|
|
test('setSettings merges only the changed key onto the freshest stored blob', () => {
|
|
installStorage();
|
|
const original = getSettings();
|
|
|
|
// Tab A reads settings, then tab B (concurrently) changes an unrelated key
|
|
// and writes it to the shared storage.
|
|
const tabB = { ...original, gifPickerEnabled: true };
|
|
setSettings(original, tabB);
|
|
|
|
// Tab A, still holding its original snapshot, now changes a different key.
|
|
const tabA = { ...original, isMarkdown: !original.isMarkdown };
|
|
setSettings(original, tabA);
|
|
|
|
// Both survive: tab B's change was not reverted by tab A's stale write.
|
|
const result = getSettings();
|
|
assert.equal(result.gifPickerEnabled, true);
|
|
assert.equal(result.isMarkdown, !original.isMarkdown);
|
|
});
|
|
|
|
test('setSettings is a no-op for keys unchanged relative to previous', () => {
|
|
const store = installStorage();
|
|
store.set('settings', JSON.stringify({ isMarkdown: false }));
|
|
const current = getSettings();
|
|
assert.equal(current.isMarkdown, false);
|
|
|
|
// `next` differs from `current` in object identity/composerToolbarButtons
|
|
// etc. but not in any actual value vs `previous` — nothing should be
|
|
// reapplied, so an out-of-band external change to storage survives.
|
|
const externallyChanged: Settings = { ...current, isMarkdown: true };
|
|
localStorage.setItem('settings', JSON.stringify(externallyChanged));
|
|
|
|
const previous = current;
|
|
const next = { ...current }; // identical values to `previous`
|
|
setSettings(previous, next);
|
|
|
|
assert.equal(getSettings().isMarkdown, true);
|
|
});
|