From 9586d121e4d440fcbc03ab46e21604474c1d30c0 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Tue, 25 Feb 2025 03:19:22 +0400 Subject: [PATCH 1/5] Moved storage definition to constructor for testability --- src/lib/extension/ConfigurationController.ts | 22 +++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/lib/extension/ConfigurationController.ts b/src/lib/extension/ConfigurationController.ts index 860b852..06d2e7f 100644 --- a/src/lib/extension/ConfigurationController.ts +++ b/src/lib/extension/ConfigurationController.ts @@ -2,12 +2,16 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag export default class ConfigurationController { readonly #configurationName: string; + readonly #storage: StorageHelper; /** * @param {string} configurationName Name of the configuration to work with. + * @param {StorageHelper} [storage] Selected storage where the settings are stored in. If not provided, local storage + * is used. */ - constructor(configurationName: string) { + constructor(configurationName: string, storage: StorageHelper = new StorageHelper(chrome.storage.local)) { this.#configurationName = configurationName; + this.#storage = storage; } /** @@ -19,7 +23,7 @@ export default class ConfigurationController { * @return The setting value or the default value if the setting does not exist. */ async readSetting(settingName: string, defaultValue: DefaultType | null = null): Promise { - const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {}); + const settings = await this.#storage.read(this.#configurationName, {}); return settings[settingName] ?? defaultValue; } @@ -32,11 +36,11 @@ export default class ConfigurationController { * @return {Promise} */ async writeSetting(settingName: string, value: any): Promise { - const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {}); + const settings = await this.#storage.read(this.#configurationName, {}); settings[settingName] = value; - ConfigurationController.#storageHelper.write(this.#configurationName, settings); + this.#storage.write(this.#configurationName, settings); } /** @@ -45,11 +49,11 @@ export default class ConfigurationController { * @param {string} settingName Setting name to delete. */ async deleteSetting(settingName: string): Promise { - const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {}); + const settings = await this.#storage.read(this.#configurationName, {}); delete settings[settingName]; - ConfigurationController.#storageHelper.write(this.#configurationName, settings); + this.#storage.write(this.#configurationName, settings); } /** @@ -69,10 +73,8 @@ export default class ConfigurationController { callback(changes[this.#configurationName].newValue); } - ConfigurationController.#storageHelper.subscribe(subscriber); + this.#storage.subscribe(subscriber); - return () => ConfigurationController.#storageHelper.unsubscribe(subscriber); + return () => this.#storage.unsubscribe(subscriber); } - - static #storageHelper = new StorageHelper(chrome.storage.local); } From ed263d2da4f5ed0f4615b9cc66eaa036c0dcbf58 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Tue, 25 Feb 2025 03:19:49 +0400 Subject: [PATCH 2/5] Installed types for NodeJS for testing --- package-lock.json | 18 ++++++++++++++++++ package.json | 1 + 2 files changed, 19 insertions(+) diff --git a/package-lock.json b/package-lock.json index 9332b49..354e124 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@sveltejs/kit": "^2.17.2", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@types/chrome": "^0.0.304", + "@types/node": "^22.13.5", "@vitest/coverage-v8": "^3.0.6", "cheerio": "^1.0.0", "jsdom": "^26.0.0", @@ -814,6 +815,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.13.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz", + "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, "node_modules/@vitest/coverage-v8": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.6.tgz", @@ -2972,6 +2983,13 @@ "node": ">=18.17" } }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", diff --git a/package.json b/package.json index 6f4ff32..409b3ed 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@sveltejs/kit": "^2.17.2", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@types/chrome": "^0.0.304", + "@types/node": "^22.13.5", "@vitest/coverage-v8": "^3.0.6", "cheerio": "^1.0.0", "jsdom": "^26.0.0", From a9d53afdbe347885ffff4a0724ab6931ca4ebb22 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Tue, 25 Feb 2025 03:20:43 +0400 Subject: [PATCH 3/5] Mocked storage change events for mocked storage area --- tests/mocks/ChromeEvent.ts | 14 ++++++------- tests/mocks/ChromeLocalStorageArea.ts | 2 +- tests/mocks/ChromeStorageArea.ts | 27 ++++++++++++++++++++++--- tests/mocks/ChromeStorageChangeEvent.ts | 27 +++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 tests/mocks/ChromeStorageChangeEvent.ts diff --git a/tests/mocks/ChromeEvent.ts b/tests/mocks/ChromeEvent.ts index 7c3029b..5b16ed9 100644 --- a/tests/mocks/ChromeEvent.ts +++ b/tests/mocks/ChromeEvent.ts @@ -1,9 +1,9 @@ export default class ChromeEvent implements chrome.events.Event { - addListener = vi.fn(); - getRules = vi.fn(); - hasListener = vi.fn(); - removeRules = vi.fn(); - addRules = vi.fn(); - removeListener = vi.fn(); - hasListeners = vi.fn(); + addListener = vi.fn(); + getRules = vi.fn(); + hasListener = vi.fn(); + removeRules = vi.fn(); + addRules = vi.fn(); + removeListener = vi.fn(); + hasListeners = vi.fn(); } diff --git a/tests/mocks/ChromeLocalStorageArea.ts b/tests/mocks/ChromeLocalStorageArea.ts index 8aa4f77..e14586e 100644 --- a/tests/mocks/ChromeLocalStorageArea.ts +++ b/tests/mocks/ChromeLocalStorageArea.ts @@ -1,5 +1,5 @@ import ChromeStorageArea from "$tests/mocks/ChromeStorageArea"; export class ChromeLocalStorageArea extends ChromeStorageArea implements chrome.storage.LocalStorageArea { - QUOTA_BYTES = 100000; + QUOTA_BYTES = 100000; } diff --git a/tests/mocks/ChromeStorageArea.ts b/tests/mocks/ChromeStorageArea.ts index 1372fd3..95609a6 100644 --- a/tests/mocks/ChromeStorageArea.ts +++ b/tests/mocks/ChromeStorageArea.ts @@ -1,4 +1,4 @@ -import ChromeEvent from "./ChromeEvent"; +import ChromeStorageChangeEvent from "$tests/mocks/ChromeStorageChangeEvent"; type ChangedEventCallback = (changes: chrome.storage.StorageChange) => void @@ -13,8 +13,20 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea { }) }); set = vi.fn((...args: any[]): Promise => { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { + const change: Record = {}; + const setter = args[0]; + + for (let targetKey of Object.keys(setter)) { + change[targetKey] = { + oldValue: this.#mockedData[targetKey] ?? undefined, + newValue: setter[targetKey], + }; + } + this.#mockedData = Object.assign(this.#mockedData, args[0]); + this.onChanged.mockEmitStorageChange(change); + resolve(); }) }); @@ -23,7 +35,16 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea { const key = args[0]; if (typeof key === 'string') { + const change: chrome.storage.StorageChange = { + oldValue: this.#mockedData[key], + }; + delete this.#mockedData[key]; + + this.onChanged.mockEmitStorageChange({ + [key]: change + }); + resolve(); } @@ -58,7 +79,7 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea { }); }); setAccessLevel = vi.fn(); - onChanged = new ChromeEvent(); + onChanged = new ChromeStorageChangeEvent(); getKeys = vi.fn(); insertMockedData(data: Record) { diff --git a/tests/mocks/ChromeStorageChangeEvent.ts b/tests/mocks/ChromeStorageChangeEvent.ts new file mode 100644 index 0000000..b4fb187 --- /dev/null +++ b/tests/mocks/ChromeStorageChangeEvent.ts @@ -0,0 +1,27 @@ +import ChromeEvent from "$tests/mocks/ChromeEvent"; +import { EventEmitter } from "node:events"; + +type MockedStorageChanges = Record; +type IncomingStorageChangeListener = (changes: MockedStorageChanges) => void; + +const storageChangeEvent = Symbol(); + +interface StorageChangeEventMap { + [storageChangeEvent]: [MockedStorageChanges]; +} + +export default class ChromeStorageChangeEvent extends ChromeEvent { + #emitter = new EventEmitter(); + + addListener = vi.fn((actualListener: IncomingStorageChangeListener) => { + this.#emitter.addListener(storageChangeEvent, actualListener); + }); + + removeListener = vi.fn((actualListener: IncomingStorageChangeListener) => { + this.#emitter.removeListener(storageChangeEvent, actualListener); + }); + + mockEmitStorageChange(changes: MockedStorageChanges) { + this.#emitter.emit(storageChangeEvent, changes); + } +} From 09edc44af846c340427afb3ef18c42f04e501a25 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Tue, 25 Feb 2025 03:38:49 +0400 Subject: [PATCH 4/5] Added tests for configuration controller --- .../extension/ConfigurationController.spec.ts | 186 ++++++++++++++++++ tests/utils.ts | 7 + 2 files changed, 193 insertions(+) create mode 100644 tests/lib/extension/ConfigurationController.spec.ts create mode 100644 tests/utils.ts diff --git a/tests/lib/extension/ConfigurationController.spec.ts b/tests/lib/extension/ConfigurationController.spec.ts new file mode 100644 index 0000000..20e4577 --- /dev/null +++ b/tests/lib/extension/ConfigurationController.spec.ts @@ -0,0 +1,186 @@ +import ConfigurationController from "$lib/extension/ConfigurationController"; +import ChromeStorageArea from "$tests/mocks/ChromeStorageArea"; +import StorageHelper from "$lib/browser/StorageHelper"; +import { randomString } from "$tests/utils"; + +describe('ConfigurationController', () => { + const mockedStorageArea = new ChromeStorageArea(); + const mockedStorageHelper = new StorageHelper(mockedStorageArea); + + beforeEach(() => { + mockedStorageArea.clear(); + }); + + it('should read setting from the field inside the configuration object', async () => { + const name = randomString(); + const field = randomString(); + const value = randomString(); + + mockedStorageArea.insertMockedData({ + [name]: { + [field]: value + } + }); + + const controller = new ConfigurationController(name, mockedStorageHelper); + const returnedValue = await controller.readSetting(field); + + expect(returnedValue).toBe(value); + }); + + it('should return fallback value if configuration field does not exist', async () => { + const controller = new ConfigurationController(randomString(), mockedStorageHelper); + const fallbackValue = randomString(); + const returnedValue = await controller.readSetting(randomString(), fallbackValue); + + expect(returnedValue).toBe(fallbackValue); + }); + + it('should treat existing falsy values as existing values', async () => { + const name = randomString(); + + const falsyValuesStorage = [0, false, ''].reduce((record, value) => { + record[randomString()] = value; + return record; + }, {} as Record); + + mockedStorageArea.insertMockedData({ + [name]: falsyValuesStorage + }); + + const controller = new ConfigurationController(name, mockedStorageHelper); + + for (const fieldName of Object.keys(falsyValuesStorage)) { + const returnedValue = await controller.readSetting(fieldName, randomString()); + + expect(returnedValue).toBe(falsyValuesStorage[fieldName]); + } + }); + + it('should write data to storage', async () => { + const name = randomString(); + const field = randomString(); + const value = randomString(); + + const controller = new ConfigurationController(name, mockedStorageHelper); + await controller.writeSetting(field, value); + + const expectedStructure = { + [name]: { + [field]: value, + } + }; + + expect(mockedStorageArea.mockedData).toEqual(expectedStructure); + }); + + it('should update existing object without touching other entries', async () => { + const name = randomString(); + const existingField = randomString(); + const existingValue = randomString(); + const addedField = randomString(); + const addedValue = randomString(); + + mockedStorageArea.insertMockedData({ + [name]: { + [existingField]: existingValue, + } + }); + + const controller = new ConfigurationController(name, mockedStorageHelper); + await controller.writeSetting(addedField, addedValue); + + const expectedStructure = { + [name]: { + [existingField]: existingValue, + [addedField]: addedValue, + } + } + + expect(mockedStorageArea.mockedData).toEqual(expectedStructure); + }); + + it('should delete setting from storage', async () => { + const name = randomString(); + const field = randomString(); + const value = randomString(); + + mockedStorageArea.insertMockedData({ + [name]: { + [field]: value + } + }); + + const controller = new ConfigurationController(name, mockedStorageHelper); + await controller.deleteSetting(field); + + expect(mockedStorageArea.mockedData).toEqual({ + [name]: {}, + }); + }); + + it('should return updated settings contents on changes', async () => { + const name = randomString(); + const initialField = randomString(); + const initialValue = randomString(); + + const addedField = randomString(); + const addedValue = randomString(); + + const updatedInitialValue = randomString(); + const receivedData: Record[] = []; + + mockedStorageArea.insertMockedData({ + [name]: { + [initialField]: initialValue, + } + }); + + const controller = new ConfigurationController(name, mockedStorageHelper); + const subscriber = vi.fn((storageState: Record) => { + receivedData.push(JSON.parse(JSON.stringify(storageState))); + }); + + controller.subscribeToChanges(subscriber); + + await controller.writeSetting(addedField, addedValue); + await controller.writeSetting(initialField, updatedInitialValue); + await controller.deleteSetting(initialField); + + expect(subscriber).toBeCalledTimes(3); + + const expectedData: Record[] = [ + // First, initial data and added field are present + { + [initialField]: initialValue, + [addedField]: addedValue, + }, + // Then we get new value on initial field + { + [initialField]: updatedInitialValue, + [addedField]: addedValue, + }, + // And then the initial value is dropped + { + [addedField]: addedValue, + } + ]; + + expect(receivedData).toEqual(expectedData); + }); + + it('should stop listening once unsubscribe called', async () => { + const controller = new ConfigurationController(randomString(), mockedStorageHelper); + const subscriber = vi.fn(); + + const unsubscribe = controller.subscribeToChanges(subscriber); + + await controller.writeSetting(randomString(), randomString()); + expect(subscriber).toBeCalledTimes(1); + + unsubscribe(); + subscriber.mockReset(); + await controller.writeSetting(randomString(), randomString()) + expect(subscriber).not.toBeCalled(); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 0000000..61ac9a7 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,7 @@ +export function randomString(): string { + return crypto.randomUUID(); +} + +export function copyValue(object: T): T { + return JSON.parse(JSON.stringify(object)); +} From dc0a9f0aa832c34ac70e29f886eee243b0f1160a Mon Sep 17 00:00:00 2001 From: KoloMl Date: Tue, 25 Feb 2025 03:39:30 +0400 Subject: [PATCH 5/5] Imported utils function for random string --- tests/lib/components/base/BaseComponent.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/lib/components/base/BaseComponent.spec.ts b/tests/lib/components/base/BaseComponent.spec.ts index d15392c..9a80330 100644 --- a/tests/lib/components/base/BaseComponent.spec.ts +++ b/tests/lib/components/base/BaseComponent.spec.ts @@ -1,9 +1,6 @@ import { BaseComponent } from "$lib/components/base/BaseComponent"; import { getComponent } from "$lib/components/base/component-utils"; - -function randomString() { - return crypto.randomUUID(); -} +import { randomString } from "$tests/utils"; describe('BaseComponent', () => { it('should bind the component to the element', () => {