Repository: web-scrobbler/web-scrobbler
Pull request #3694 (2023-05-03 13:42:57)

Reorganize options
Splits the old options tab into three different tabs, options, edits, and connectors.
When even one of our main maintainers can't find the edits section (https://github.com/web-scrobbler/web-scrobbler/pull/3671#issuecomment-1529133733) we have a UX problem.

<img width="212" alt="image" src="https://user-images.githubusercontent.com/69117606/235932442-35c484a4-e6cb-4ea3-9a88-a50d76063255.png">

Also makes the sidebar scrollable, as at this point that could become relevant with some small window sizes.
Most actually used window sizes should still not experience having to scroll the sidebar though.


Patch:
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index cf690ed6..efc4f7d4 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -191,6 +191,10 @@
         "message": "Options",
         "description": "'Options' section"
     },
+    "optionsEdits": {
+        "message": "Edits",
+        "description": "'Edits' section"
+    },
     "optionsEditedTracks": {
         "message": "Edited tracks",
         "description": "'Edited tracks' header"
@@ -227,6 +231,10 @@
         "message": "Import",
         "description": "Button to import edited tracks"
     },
+    "optionsConnectors": {
+        "message": "Connectors",
+        "description": "'Connectors' section"
+    },
     "optionsEnableDisableHint": {
         "message": "Click on the checkbox to enable/disable connectors.",
         "description": "Enable/disable hint"
diff --git a/src/core/object/pipeline/regex-edits.ts b/src/core/object/pipeline/regex-edits.ts
index 2257ed41..fd801f27 100644
--- a/src/core/object/pipeline/regex-edits.ts
+++ b/src/core/object/pipeline/regex-edits.ts
@@ -12,7 +12,7 @@ export async function process(song: Song): Promise<void> {
 	}
 	try {
 		await RegexEdits.loadSongInfo(song);
-	} catch (e) {
-		// Do nothing
+	} catch (err) {
+		console.error(err);
 	}
 }
diff --git a/src/core/object/pipeline/user-input.ts b/src/core/object/pipeline/user-input.ts
index c842cee4..f1a590e3 100644
--- a/src/core/object/pipeline/user-input.ts
+++ b/src/core/object/pipeline/user-input.ts
@@ -14,8 +14,8 @@ export async function process(song: Song): Promise<void> {
 	let isSongInfoLoaded = false;
 	try {
 		isSongInfoLoaded = await SavedEdits.loadSongInfo(song);
-	} catch (e) {
-		// Do nothing
+	} catch (err) {
+		console.error(err);
 	}
 
 	song.flags.isCorrectedByUser = isSongInfoLoaded;
diff --git a/src/core/storage/custom-patterns.ts b/src/core/storage/custom-patterns.ts
index 31941a28..270fbd0c 100644
--- a/src/core/storage/custom-patterns.ts
+++ b/src/core/storage/custom-patterns.ts
@@ -23,11 +23,7 @@ export async function setPatterns(
 	connectorId: string,
 	patterns: string[]
 ): Promise<void> {
-	const data = await storage.get();
-	if (!data) {
-		throw new Error('No custom patterns found');
-	}
-
+	const data = (await storage.get()) ?? {};
 	data[connectorId] = patterns;
 	await storage.set(data);
 }
@@ -37,11 +33,7 @@ export async function setPatterns(
  * @param connectorId - Connector ID
  */
 export async function resetPatterns(connectorId: string): Promise<void> {
-	const data = await storage.get();
-	if (!data) {
-		throw new Error('No custom patterns found');
-	}
-
+	const data = (await storage.get()) ?? {};
 	delete data[connectorId];
 	await storage.set(data);
 }
diff --git a/src/core/storage/saved-edits.model.ts b/src/core/storage/saved-edits.model.ts
index 6d1affae..4b07047c 100644
--- a/src/core/storage/saved-edits.model.ts
+++ b/src/core/storage/saved-edits.model.ts
@@ -11,7 +11,7 @@ import { SavedEdit } from '@/core/storage/options';
 type K = typeof LOCAL_CACHE;
 type V = DataModels[K];
 type T = Record<K, V>;
-type TContent = T[K];
+export type TContent = T[K];
 
 export default abstract class SavedEditsModel extends CustomStorage<K> {
 	/**
diff --git a/src/core/storage/wrapper.ts b/src/core/storage/wrapper.ts
index 24f8b7db..d164ba6a 100644
--- a/src/core/storage/wrapper.ts
+++ b/src/core/storage/wrapper.ts
@@ -230,9 +230,6 @@ export default class StorageWrapper<K extends keyof DataModels> {
 			if (hiddenKeys.includes(key)) {
 				return hideObjectValue(value);
 			}
-			if (typeof value !== 'string' && typeof value !== 'number') {
-				return 'expected a number or a string';
-			}
 
 			return value;
 		};
diff --git a/src/ui/options/components/options/connector-override.tsx b/src/ui/options/components/connector-override.tsx
similarity index 98%
rename from src/ui/options/components/options/connector-override.tsx
rename to src/ui/options/components/connector-override.tsx
index e67876c3..080617d9 100644
--- a/src/ui/options/components/options/connector-override.tsx
+++ b/src/ui/options/components/connector-override.tsx
@@ -9,7 +9,7 @@ import {
 	createResource,
 	createSignal,
 } from 'solid-js';
-import styles from '../components.module.scss';
+import styles from './components.module.scss';
 import Settings from '@suid/icons-material/SettingsOutlined';
 import ExpandMore from '@suid/icons-material/ExpandMoreOutlined';
 import Delete from '@suid/icons-material/DeleteOutlined';
@@ -20,7 +20,7 @@ import {
 	ConnectorTripleCheckbox,
 	RadioButtons,
 	SummaryCheckbox,
-} from '../inputs';
+} from './inputs';
 import browser from 'webextension-polyfill';
 import { t } from '@/util/i18n';
 
@@ -48,7 +48,7 @@ const [customPatternOptions, setCustomPatternOptions] = createResource(
 export default function ConnectorOverrideOptions() {
 	return (
 		<>
-			<h2>{t('optionsSupportedWebsites')}</h2>
+			<h1>{t('optionsSupportedWebsites')}</h1>
 			<p>{t('optionsEnableDisableHint')}</p>
 			{/* eslint-disable-next-line */}
 			<p innerHTML={t('optionsCustomPatternsHint')} />
diff --git a/src/ui/options/components/edit-options/edit-options.tsx b/src/ui/options/components/edit-options/edit-options.tsx
new file mode 100644
index 00000000..19531a50
--- /dev/null
+++ b/src/ui/options/components/edit-options/edit-options.tsx
@@ -0,0 +1,23 @@
+import { Setter } from 'solid-js';
+import EditedTracks from './edited-tracks';
+import RegexEdits from './regex-edits';
+import { t } from '@/util/i18n';
+
+export default function EditOptions(props: {
+	setActiveModal: Setter<string>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	return (
+		<>
+			<h1>{t('optionsEdits')}</h1>
+			<EditedTracks
+				setActiveModal={props.setActiveModal}
+				modal={props.modal}
+			/>
+			<RegexEdits
+				setActiveModal={props.setActiveModal}
+				modal={props.modal}
+			/>
+		</>
+	);
+}
diff --git a/src/ui/options/components/options/edited-tracks.tsx b/src/ui/options/components/edit-options/edited-tracks.tsx
similarity index 100%
rename from src/ui/options/components/options/edited-tracks.tsx
rename to src/ui/options/components/edit-options/edited-tracks.tsx
diff --git a/src/ui/options/components/options/regex-edits.tsx b/src/ui/options/components/edit-options/regex-edits.tsx
similarity index 100%
rename from src/ui/options/components/options/regex-edits.tsx
rename to src/ui/options/components/edit-options/regex-edits.tsx
diff --git a/src/ui/options/components/options/options.tsx b/src/ui/options/components/options/options.tsx
index 9a2b9b31..2f318700 100644
--- a/src/ui/options/components/options/options.tsx
+++ b/src/ui/options/components/options/options.tsx
@@ -1,12 +1,9 @@
 import * as BrowserStorage from '@/core/storage/browser-storage';
 import { t } from '@/util/i18n';
-import { Setter, createResource } from 'solid-js';
+import { createResource } from 'solid-js';
 import GlobalOptionsList from './global-options';
 import ConnectorOptionsList from './connector-options';
 import ScrobbleBehavior from './scrobble-behavior';
-import EditedTracks from './edited-tracks';
-import ConnectorOverrideOptions from './connector-override';
-import RegexEdits from './regex-edits';
 
 const globalOptions = BrowserStorage.getStorage(BrowserStorage.OPTIONS);
 
@@ -17,25 +14,13 @@ const [options, setOptions] = createResource(
 /**
  * Component that shows options to the user
  */
-export default function OptionsComponent(props: {
-	setActiveModal: Setter<string>;
-	modal: HTMLDialogElement | undefined;
-}) {
+export default function OptionsComponent() {
 	return (
 		<>
 			<h1>{t('optionsOptions')}</h1>
 			<GlobalOptionsList options={options} setOptions={setOptions} />
 			<ConnectorOptionsList />
 			<ScrobbleBehavior options={options} setOptions={setOptions} />
-			<EditedTracks
-				setActiveModal={props.setActiveModal}
-				modal={props.modal}
-			/>
-			<RegexEdits
-				setActiveModal={props.setActiveModal}
-				modal={props.modal}
-			/>
-			<ConnectorOverrideOptions />
 		</>
 	);
 }
diff --git a/src/ui/options/main.tsx b/src/ui/options/main.tsx
index 09338230..524f82b6 100644
--- a/src/ui/options/main.tsx
+++ b/src/ui/options/main.tsx
@@ -9,6 +9,8 @@ import Info from '@suid/icons-material/InfoOutlined';
 import Help from '@suid/icons-material/HelpOutlined';
 import Contacts from '@suid/icons-material/ContactsOutlined';
 import Settings from '@suid/icons-material/SettingsOutlined';
+import Edit from '@suid/icons-material/EditOutlined';
+import Extension from '@suid/icons-material/ExtensionOutlined';
 import Close from '@suid/icons-material/CloseOutlined';
 import ManageAccounts from '@suid/icons-material/ManageAccountsOutlined';
 import Sidebar from './sidebar/sidebar';
@@ -17,9 +19,11 @@ import FAQ from './components/faq';
 import ContactComponent from './components/contact';
 import OptionsComponent from './components/options/options';
 import Accounts from './components/accounts';
-import { EditsModal } from './components/options/edited-tracks';
+import { EditsModal } from './components/edit-options/edited-tracks';
 import Permissions from './components/permissions';
-import { RegexEditsModal } from './components/options/regex-edits';
+import { RegexEditsModal } from './components/edit-options/regex-edits';
+import ConnectorOverrideOptions from './components/connector-override';
+import EditOptions from './components/edit-options/edit-options';
 
 /**
  * All the different options pages, their sidebar labels, and icons.
@@ -35,6 +39,16 @@ export type Settings =
 			icon: typeof Settings;
 			element: typeof OptionsComponent;
 	  }
+	| {
+			namei18n: 'optionsEdits';
+			icon: typeof Edit;
+			element: typeof EditOptions;
+	  }
+	| {
+			namei18n: 'optionsConnectors';
+			icon: typeof Extension;
+			element: typeof ConnectorOverrideOptions;
+	  }
 	| {
 			namei18n: 'contactTitle';
 			icon: typeof Contacts;
@@ -62,6 +76,12 @@ export type Settings =
 const settings: Settings[] = [
 	{ namei18n: 'optionsAccounts', icon: ManageAccounts, element: Accounts },
 	{ namei18n: 'optionsOptions', icon: Settings, element: OptionsComponent },
+	{ namei18n: 'optionsEdits', icon: Edit, element: EditOptions },
+	{
+		namei18n: 'optionsConnectors',
+		icon: Extension,
+		element: ConnectorOverrideOptions,
+	},
 	{ namei18n: 'contactTitle', icon: Contacts, element: ContactComponent },
 	{ namei18n: 'faqTitle', icon: Help, element: FAQ },
 	{ namei18n: 'optionsAbout', icon: Info, element: InfoComponent },
diff --git a/src/ui/options/sidebar/sidebar.module.scss b/src/ui/options/sidebar/sidebar.module.scss
index 749b459c..de9e5f48 100644
--- a/src/ui/options/sidebar/sidebar.module.scss
+++ b/src/ui/options/sidebar/sidebar.module.scss
@@ -1,28 +1,32 @@
-.sidebar {
+.sidebarWrapper {
 	background-color: var(--sidebar-bg);
 	flex-shrink: 0;
+	height: 100vh;
 	margin-right: 1.5em;
+	overflow-y: auto;
 
-	.sidebarButton {
-		align-items: center;
-		background-color: transparent;
-		border: none;
-		color: var(--text);
-		cursor: pointer;
-		display: flex;
-		font-size: 1.2em;
-		padding: 1em 2em;
-		width: 100%;
+	.sidebar {
+		.sidebarButton {
+			align-items: center;
+			background-color: transparent;
+			border: none;
+			color: var(--text);
+			cursor: pointer;
+			display: flex;
+			font-size: 1.2em;
+			padding: 1em 2em;
+			width: 100%;
 
-		svg {
-			margin-bottom: 3px;
-			margin-right: 0.2em;
-		}
+			svg {
+				margin-bottom: 3px;
+				margin-right: 0.2em;
+			}
 
-		&.active,
-		&:hover {
-			background-color: var(--last-fm);
-			color: var(--always-light);
+			&.active,
+			&:hover {
+				background-color: var(--last-fm);
+				color: var(--always-light);
+			}
 		}
 	}
 }
diff --git a/src/ui/options/sidebar/sidebar.tsx b/src/ui/options/sidebar/sidebar.tsx
index 73b765b9..e32946b4 100644
--- a/src/ui/options/sidebar/sidebar.tsx
+++ b/src/ui/options/sidebar/sidebar.tsx
@@ -38,16 +38,18 @@ export default function Sidebar(props: {
 }) {
 	const { items, activeSetting, setActiveSetting } = props;
 	return (
-		<nav class={styles.sidebar}>
-			<For each={items}>
-				{(item) => (
-					<SidebarButton
-						item={item}
-						activeSetting={activeSetting}
-						setActiveSetting={setActiveSetting}
-					/>
-				)}
-			</For>
-		</nav>
+		<div class={styles.sidebarWrapper}>
+			<nav class={styles.sidebar}>
+				<For each={items}>
+					{(item) => (
+						<SidebarButton
+							item={item}
+							activeSetting={activeSetting}
+							setActiveSetting={setActiveSetting}
+						/>
+					)}
+				</For>
+			</nav>
+		</div>
 	);
 }


Test patch:
diff --git a/tests/background/connectors.test.ts b/tests/background/connectors.test.ts
new file mode 100644
index 00000000..aacde5ec
--- /dev/null
+++ b/tests/background/connectors.test.ts
@@ -0,0 +1,99 @@
+import fs from 'fs';
+import path from 'path';
+
+import { expect, assert, describe, it } from 'vitest';
+
+import connectors, { ConnectorMeta } from '@/core/connectors';
+import * as UrlMatch from '@/util/url-match';
+
+const PROP_TYPES: {
+	allFrames: 'boolean';
+	matches: 'array';
+	label: 'string';
+	js: 'string';
+	id: 'string';
+} = {
+	allFrames: 'boolean',
+	matches: 'array',
+	label: 'string',
+	js: 'string',
+	id: 'string',
+};
+const REQUIRED_PROPS: ['label', 'js', 'id'] = ['label', 'js', 'id'];
+
+function testProps(entry: ConnectorMeta) {
+	for (const prop of REQUIRED_PROPS) {
+		assert(entry[prop], `Missing property: ${prop}`);
+	}
+
+	for (const _prop in entry) {
+		const prop = _prop as keyof ConnectorMeta;
+		const type = PROP_TYPES[prop];
+
+		assert(type, `Missing property: ${prop}`);
+		expect(entry[prop]).to.be.a(type);
+	}
+}
+
+function testMatches(entry: ConnectorMeta) {
+	if (!entry.matches) {
+		return;
+	}
+
+	assert(entry.matches.length !== 0, 'Property is empty: matches');
+
+	for (const m of entry.matches) {
+		assert(UrlMatch.createPattern(m), `URL pattern is invalid: ${m}`);
+	}
+}
+
+function testPaths(entry: ConnectorMeta) {
+	if (!entry.js) {
+		return;
+	}
+
+	const jsPath = path.join(
+		__dirname,
+		'../../src/connectors',
+		entry.js.replace('.js', '.ts')
+	);
+	try {
+		fs.statSync(jsPath);
+	} catch (e) {
+		throw new Error(`File is missing: ${entry.js}`);
+	}
+}
+
+function testUniqueness(entry: ConnectorMeta) {
+	for (const connector of connectors) {
+		if (connector.label === entry.label) {
+			continue;
+		}
+
+		assert(entry.id !== connector.id, `Id is not unique: ${entry.label}`);
+	}
+}
+
+function runTests() {
+	for (const entry of connectors) {
+		describe(entry.label, () => {
+			it('should have valid prop types', () => {
+				testProps(entry);
+			});
+
+			it('should have valid URL matches', () => {
+				testMatches(entry);
+			});
+
+			it('should have js files for', () => {
+				testPaths(entry);
+			});
+
+			it('should have unique id', () => {
+				testUniqueness(entry);
+			});
+		});
+	}
+}
+
+runTests();
diff --git a/tests/background/custom-patterns.test.ts b/tests/background/custom-patterns.test.ts
new file mode 100644
index 00000000..c92c1fc9
--- /dev/null
+++ b/tests/background/custom-patterns.test.ts
@@ -0,0 +1,42 @@
+import { expect, describe, it, beforeAll, afterAll } from 'vitest';
+
+import webextensionPolyfill from '#/mocks/webextension-polyfill';
+import * as CustomPatterns from '@/core/storage/custom-patterns';
+
+/**
+ * Run all tests.
+ */
+function runTests() {
+	describe('Test custom patterns', () => {
+		beforeAll(() => {
+			webextensionPolyfill.reset();
+		});
+
+		afterAll(() => {
+			webextensionPolyfill.reset();
+		});
+
+		it('should return null before patterns have been configured', async () => {
+			const data = await CustomPatterns.getAllPatterns();
+			expect(data).to.be.null;
+		});
+
+		it('should set patterns for connector', async () => {
+			const patterns = ['1', '2'];
+			const expectedData = {
+				connector: patterns,
+			};
+			await CustomPatterns.setPatterns('connector', patterns);
+			const data = await CustomPatterns.getAllPatterns();
+			expect(expectedData).to.deep.equal(data);
+		});
+
+		it('should clear custom patterns', async () => {
+			await CustomPatterns.resetPatterns('connector');
+			const data = await CustomPatterns.getAllPatterns();
+			expect(data).to.deep.equal({});
+		});
+	});
+}
+
+runTests();
diff --git a/tests/background/regex.test.ts b/tests/background/regex-edits.test.ts
similarity index 97%
rename from tests/background/regex.test.ts
rename to tests/background/regex-edits.test.ts
index 2fbab5fc..d38b989e 100644
--- a/tests/background/regex.test.ts
+++ b/tests/background/regex-edits.test.ts
@@ -1,4 +1,4 @@
-import WebextensionPolyfillMocker from '#/mocks/webextension-polyfill';
+import webextensionPolyfill from '#/mocks/webextension-polyfill';
 import Pipeline from '@/core/object/pipeline/pipeline';
 import Song from '@/core/object/song';
 import { SavedEdit } from '@/core/storage/options';
@@ -14,13 +14,12 @@ import { getConnectorById } from '@/util/util-connector';
 import { randomBytes } from 'crypto';
 import { beforeEach, describe, expect, it } from 'vitest';
 
-const mocker = new WebextensionPolyfillMocker();
 const pipeline = new Pipeline();
 
 describe('Should edit Regex', () => {
 	beforeEach(() => {
-		mocker.reset();
-		mocker.setUser();
+		webextensionPolyfill.reset();
+		webextensionPolyfill.setUser();
 	});
 
 	it('Should add edits to storage', async () => {
diff --git a/tests/background/saved-edits.test.ts b/tests/background/saved-edits.test.ts
new file mode 100644
index 00000000..b52fb1d5
--- /dev/null
+++ b/tests/background/saved-edits.test.ts
@@ -0,0 +1,237 @@
+import { expect, describe, it, beforeAll } from 'vitest';
+
+import webextensionPolyfill from '#/mocks/webextension-polyfill';
+('#/mocks/webextension-polyfill');
+
+import Song from '@/core/object/song';
+import { getConnectorById } from '@/util/util-connector';
+import { SavedEdit } from '@/core/storage/options';
+import savedEdits from '@/core/storage/saved-edits';
+import SavedEditsModel from '@/core/storage/saved-edits.model';
+
+const editedInfo = {
+	artist: 'ArtistEdited',
+	album: 'AlbumEdited',
+	track: 'TrackEdited',
+	albumArtist: null,
+};
+
+const connectorStub = getConnectorById('youtube')!;
+/**
+ * Run all tests.
+ */
+function runTests() {
+	describe('should throw an error for empty songs', testSaveEmptySong);
+
+	describe('clear storage', testClearStorage);
+	describe('should not load not a song', testLoadNoSavedSong);
+	describe('should remove song from storage', testRemoveSongInfo);
+	describe('should overwrite edited song info', testSaveOverwriteSong);
+	describe(
+		'should save and load a song with unique ID',
+		testSaveLoadSongWithId
+	);
+	describe(
+		'should save and load a song with no unique ID',
+		testSaveLoadSongWithNoId
+	);
+	describe(
+		'should save and load a song (with fallback)',
+		testSaveLoadSongFallback
+	);
+}
+
+function testSaveEmptySong() {
+	emptySavedEdits();
+	const emptySong = makeNonProcessedSong();
+
+	it('should throw an error while loading info of an empty song', async () => {
+		expect(savedEdits.loadSongInfo(emptySong)).rejects.to.deep.equal(
+			new Error('Empty song')
+		);
+	});
+
+	it('should throw an error while saving an empty song', async () => {
+		expect(
+			savedEdits.saveSongInfo(emptySong, editedInfo)
+		).rejects.to.deep.equal(new Error('Empty song'));
+	});
+}
+
+function testClearStorage() {
+	const song = makeNonProcessedSong('Artist', 'Track', 'Album');
+	const savedEdits = makeSavedEdits({
+		song,
+		edit: editedInfo,
+	});
+
+	it('should clear storage', () => {
+		return savedEdits.clear();
+	});
+
+	it('should not load song', async () => {
+		await expectSongInfoNotLoaded(savedEdits, song);
+	});
+}
+
+function testLoadNoSavedSong() {
+	emptySavedEdits();
+
+	const song = makeNonProcessedSong('Artist', 'Track', 'Album');
+
+	it('should not load not saved song', async () => {
+		const songInfo = await savedEdits.loadSongInfo(song);
+		expect(songInfo).to.be.false;
+	});
+}
+
+function testSaveLoadSongWithNoId() {
+	beforeAll(() => {
+		emptySavedEdits();
+	});
+
+	const songWithNoId = makeNonProcessedSong('Artist', 'Track', 'Album');
+
+	it('should return false for song with no unique ID', async () => {
+		await expectSongInfoNotLoaded(savedEdits, songWithNoId);
+	});
+
+	it('should save edited info of song with no unique ID', () => {
+		return savedEdits.saveSongInfo(songWithNoId, editedInfo);
+	});
+
+	it('should load edited info of song with no unique ID', async () => {
+		await expectSongInfoLoaded(savedEdits, songWithNoId, editedInfo);
+	});
+}
+
+function testSaveLoadSongWithId() {
+	beforeAll(() => {
+		emptySavedEdits();
+	});
+	const songWitId = makeNonProcessedSong(
+		'Artist',
+		'Track',
+		'Album',
+		'uniqueId'
+	);
+
+	it('should return false for song with unique ID', async () => {
+		await expectSongInfoNotLoaded(savedEdits, songWitId);
+	});
+
+	it('should save edited info of song with unique ID', () => {
+		return savedEdits.saveSongInfo(songWitId, editedInfo);
+	});
+
+	it('should load edited info of song with unique ID', async () => {
+		await expectSongInfoLoaded(savedEdits, songWitId, editedInfo);
+	});
+}
+
+function testSaveOverwriteSong() {
+	emptySavedEdits();
+
+	const editedInfo1 = {
+		artist: 'ArtistEdited1',
+		album: 'AlbumEdited1',
+		track: 'TrackEdited1',
+		albumArtist: null,
+	};
+	const editedInfo2 = {
+		artist: 'ArtistEdited2',
+		album: 'AlbumEdited2',
+		track: 'TrackEdited2',
+		albumArtist: null,
+	};
+	const song = makeNonProcessedSong('Artist', 'Track', 'Album');
+
+	it('should save edited info of song with no album', async () => {
+		await savedEdits.saveSongInfo(song, editedInfo1);
+		await savedEdits.saveSongInfo(song, editedInfo2);
+
+		await expectSongInfoLoaded(savedEdits, song, editedInfo2);
+	});
+}
+
+function testSaveLoadSongFallback() {
+	emptySavedEdits();
+
+	const songWithNoAlbum = makeNonProcessedSong('Artist', 'Track');
+	const songWithAlbum = makeNonProcessedSong('Artist', 'Track', 'Album');
+
+	it('should save edited info of song with no album', () => {
+		return savedEdits.saveSongInfo(songWithNoAlbum, editedInfo);
+	});
+
+	it('should load edited info of song with no album', async () => {
+		await expectSongInfoLoaded(savedEdits, songWithNoAlbum, editedInfo);
+	});
+
+	it('should load edited info of song with album', async () => {
+		await expectSongInfoLoaded(savedEdits, songWithAlbum, editedInfo);
+	});
+}
+
+function testRemoveSongInfo() {
+	const song = makeNonProcessedSong('Artist', 'Track', 'Album');
+	const savedEdits = makeSavedEdits({
+		song,
+		edit: editedInfo,
+	});
+
+	it('should remove saved song', async () => {
+		await savedEdits.removeSongInfo(song);
+	});
+
+	it('should not load removed song', async () => {
+		await expectSongInfoNotLoaded(savedEdits, song);
+	});
+}
+
+function makeNonProcessedSong(
+	artist?: string,
+	track?: string,
+	album?: string,
+	uniqueID?: string
+) {
+	return new Song({ artist, track, album, uniqueID }, connectorStub);
+}
+
+async function expectSongInfoLoaded(
+	model: SavedEditsModel,
+	song: Song,
+	editedInfo: SavedEdit
+) {
+	const isLoaded = await model.loadSongInfo(song);
+
+	const { artist, album, track, albumArtist } = song.processed;
+	const loadedInfo = { artist, album, track, albumArtist };
+
+	expect(isLoaded).to.be.true;
+	expect(loadedInfo).to.be.deep.equal(editedInfo);
+}
+
+async function expectSongInfoNotLoaded(model: SavedEditsModel, song: Song) {
+	const songInfo = await model.loadSongInfo(song);
+	expect(songInfo).be.false;
+}
+
+function makeSavedEdits(
+	...edits: {
+		song: Song;
+		edit: SavedEdit;
+	}[]
+) {
+	webextensionPolyfill.reset();
+	for (const edit of edits) {
+		savedEdits.saveSongInfo(edit.song, edit.edit);
+	}
+	return savedEdits;
+}
+
+function emptySavedEdits() {
+	webextensionPolyfill.reset();
+}
+
+runTests();
diff --git a/tests/background/storage.test.ts b/tests/background/storage.test.ts
new file mode 100644
index 00000000..fcb9cf3c
--- /dev/null
+++ b/tests/background/storage.test.ts
@@ -0,0 +1,96 @@
+/**
+ * Tests for 'BrowserStorage' module.
+ */
+
+import '#/mocks/webextension-polyfill';
+import * as BrowserStorage from '@/core/storage/browser-storage';
+import StorageWrapper from '@/core/storage/wrapper';
+import { describe, it, expect } from 'vitest';
+
+/**
+ * Test storage object.
+ * @param type - Storage type
+ * @param storage - Storage instance
+ */
+function testStorage<K extends BrowserStorage.StorageNamespace>(
+	type: K,
+	storage: StorageWrapper<K>
+) {
+	describe(`${type} storage`, () => {
+		it('should return empty object', async () => {
+			const data = await storage.get();
+			expect(data).to.be.null;
+		});
+
+		it('should set key value', async () => {
+			const newData = { test: 'ok' };
+			// @ts-expect-error
+			await storage.set(newData);
+
+			const data = await storage.get();
+			expect(newData).to.be.deep.equal(data);
+		});
+
+		it('should update storage', async () => {
+			const newData = { test: 'ok', key: 'value' };
+			const dataToAdd = { key: 'value' };
+			// @ts-expect-error
+			await storage.update(dataToAdd);
+			const data = await storage.get();
+			expect(newData).to.deep.equal(data);
+		});
+
+		it('should clear storage', async () => {
+			await storage.clear();
+			const data = await storage.get();
+			expect(data).to.be.null;
+		});
+	});
+}
+
+/**
+ * Run all tests.
+ */
+function runTests() {
+	const storages = {
+		// @ts-expect-error
+		local: BrowserStorage.getLocalStorage('Local'),
+		// @ts-expect-error
+		sync: BrowserStorage.getSyncStorage('Sync'),
+	};
+
+	for (const type in storages) {
+		// @ts-expect-error
+		const storage = storages[type];
+		// @ts-expect-error
+		testStorage(type, storage);
+	}
+
+	testBrowserStorage();
+}
+
+function testBrowserStorage() {
+	it('should return local storage', () => {
+		const storage = BrowserStorage.getScrobblerStorage('LastFM');
+		expect(storage).to.be.an('object');
+	});
+
+	it('should return local storage', () => {
+		const storage = BrowserStorage.getStorage(BrowserStorage.CORE);
+		expect(storage).to.be.an('object');
+	});
+
+	it('should return sync storage', () => {
+		const storage = BrowserStorage.getStorage(BrowserStorage.OPTIONS);
+		expect(storage).to.be.an('object');
+	});
+
+	it('should throw error for unknown storage', () => {
+		expect(() => {
+			// @ts-expect-error
+			return BrowserStorage.getStorage('unknown-storage123');
+		}).to.throw();
+	});
+}
+
+runTests();
diff --git a/tests/mocks/webextension-polyfill.ts b/tests/mocks/webextension-polyfill.ts
index 9a9c874d..db89a018 100644
--- a/tests/mocks/webextension-polyfill.ts
+++ b/tests/mocks/webextension-polyfill.ts
@@ -36,7 +36,7 @@ const browser = {
 	},
 };
 
-export default class WebextensionPolyfillMocker {
+class WebextensionPolyfillMocker {
 	constructor() {
 		vi.mock('webextension-polyfill', () => ({
 			default: browser,
@@ -57,3 +57,5 @@ export default class WebextensionPolyfillMocker {
 		browser.storage.sync.clear();
 	}
 }
+
+export default new WebextensionPolyfillMocker();