fix(settings): don't crash on load when localStorage is blocked + tests (+6)

Prevention work found a real bug: getSettings() runs at module load, and its
catch block called localStorage.removeItem() — but we often reach that catch
*because* localStorage access threw (blocked storage / private mode / sandboxed
context). The removeItem then re-threw, producing an uncaught error that crashed
the whole app at startup. Guarded the cleanup in its own try/catch.

New state/settings suite (6) covers the legacy-boolean callNoiseSuppression
migration, denoise-model/ringtone-id coercion of unknown values, default merge,
malformed JSON, and the blocked-storage regression.

Full suite now 129 tests, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 13:46:51 -04:00
parent 4d55e45962
commit e17cb09269
2 changed files with 88 additions and 1 deletions
+79
View File
@@ -0,0 +1,79 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getSettings } 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,
};
};
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('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');
});
+9 -1
View File
@@ -296,7 +296,15 @@ export const getSettings = (): Settings => {
},
};
} catch {
localStorage.removeItem(STORAGE_KEY);
// We may be here precisely because localStorage access throws (blocked
// storage / private mode / sandboxed context). Removing the key must not be
// allowed to re-throw — getSettings() runs at module load, so an uncaught
// error here would crash the whole app on startup.
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* localStorage unavailable — nothing to clean up */
}
return defaultSettings;
}
};