[PM-6426] Working through jest tests and implementing a method to guard against setting a task without having a registered callback

This commit is contained in:
Cesar Gonzalez 2024-04-30 16:00:03 -05:00
parent cb4051f704
commit ad3f2e4eb9
No known key found for this signature in database
GPG Key ID: 3381A5457F8CCECF
7 changed files with 120 additions and 341 deletions

View File

@ -6,7 +6,7 @@ import { ScheduledTaskNames } from "@bitwarden/common/platform/enums/scheduled-t
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserTaskSchedulerService } from "../../platform/services/browser-task-scheduler.service"; import { BrowserTaskSchedulerService } from "../../platform/services/abstractions/browser-task-scheduler.service";
import { ClearClipboard } from "./clear-clipboard"; import { ClearClipboard } from "./clear-clipboard";
import { GeneratePasswordToClipboardCommand } from "./generate-password-to-clipboard-command"; import { GeneratePasswordToClipboardCommand } from "./generate-password-to-clipboard-command";

View File

@ -215,12 +215,13 @@ import { UpdateBadge } from "../platform/listeners/update-badge";
import { ChromeMessageSender } from "../platform/messaging/chrome-message.sender"; import { ChromeMessageSender } from "../platform/messaging/chrome-message.sender";
/* eslint-enable no-restricted-imports */ /* eslint-enable no-restricted-imports */
import { BrowserStateService as StateServiceAbstraction } from "../platform/services/abstractions/browser-state.service"; import { BrowserStateService as StateServiceAbstraction } from "../platform/services/abstractions/browser-state.service";
import { BrowserTaskSchedulerService } from "../platform/services/abstractions/browser-task-scheduler.service";
import { BrowserCryptoService } from "../platform/services/browser-crypto.service"; import { BrowserCryptoService } from "../platform/services/browser-crypto.service";
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
import BrowserLocalStorageService from "../platform/services/browser-local-storage.service"; import BrowserLocalStorageService from "../platform/services/browser-local-storage.service";
import BrowserMemoryStorageService from "../platform/services/browser-memory-storage.service"; import BrowserMemoryStorageService from "../platform/services/browser-memory-storage.service";
import { BrowserScriptInjectorService } from "../platform/services/browser-script-injector.service"; import { BrowserScriptInjectorService } from "../platform/services/browser-script-injector.service";
import { BrowserTaskSchedulerService } from "../platform/services/browser-task-scheduler.service"; import { BrowserTaskSchedulerServiceImplementation } from "../platform/services/browser-task-scheduler.service";
import { DefaultBrowserStateService } from "../platform/services/default-browser-state.service"; import { DefaultBrowserStateService } from "../platform/services/default-browser-state.service";
import I18nService from "../platform/services/i18n.service"; import I18nService from "../platform/services/i18n.service";
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service"; import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
@ -505,7 +506,7 @@ export default class MainBackground {
// The taskSchedulerService needs to be instantiated a single time in a potential context. // The taskSchedulerService needs to be instantiated a single time in a potential context.
// Since the popup creates a new instance of the main background in mv3, we need to guard against a duplicate registration. // Since the popup creates a new instance of the main background in mv3, we need to guard against a duplicate registration.
if (!this.popupOnlyContext) { if (!this.popupOnlyContext) {
this.taskSchedulerService = new BrowserTaskSchedulerService( this.taskSchedulerService = new BrowserTaskSchedulerServiceImplementation(
this.logService, this.logService,
this.stateProvider, this.stateProvider,
); );

View File

@ -1,4 +1,4 @@
import { BrowserTaskSchedulerService } from "../../services/browser-task-scheduler.service"; import { BrowserTaskSchedulerServiceImplementation } from "../../services/browser-task-scheduler.service";
import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { CachedServices, factory, FactoryOptions } from "./factory-options";
import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory";
@ -11,15 +11,17 @@ export type TaskSchedulerServiceInitOptions = TaskSchedulerServiceFactoryOptions
StateProviderInitOptions; StateProviderInitOptions;
export function taskSchedulerServiceFactory( export function taskSchedulerServiceFactory(
cache: { browserTaskSchedulerService?: BrowserTaskSchedulerService } & CachedServices, cache: {
browserTaskSchedulerService?: BrowserTaskSchedulerServiceImplementation;
} & CachedServices,
opts: TaskSchedulerServiceInitOptions, opts: TaskSchedulerServiceInitOptions,
): Promise<BrowserTaskSchedulerService> { ): Promise<BrowserTaskSchedulerServiceImplementation> {
return factory( return factory(
cache, cache,
"browserTaskSchedulerService", "browserTaskSchedulerService",
opts, opts,
async () => async () =>
new BrowserTaskSchedulerService( new BrowserTaskSchedulerServiceImplementation(
await logServiceFactory(cache, opts), await logServiceFactory(cache, opts),
await stateProviderFactory(cache, opts), await stateProviderFactory(cache, opts),
), ),

View File

@ -1,3 +1,5 @@
import { Observable } from "rxjs";
import { TaskSchedulerService } from "@bitwarden/common/platform/abstractions/task-scheduler.service"; import { TaskSchedulerService } from "@bitwarden/common/platform/abstractions/task-scheduler.service";
export type ActiveAlarm = { export type ActiveAlarm = {
@ -6,7 +8,8 @@ export type ActiveAlarm = {
createInfo: chrome.alarms.AlarmCreateInfo; createInfo: chrome.alarms.AlarmCreateInfo;
}; };
export interface BrowserTaskSchedulerService extends TaskSchedulerService { export abstract class BrowserTaskSchedulerService extends TaskSchedulerService {
clearAllScheduledTasks(): Promise<void>; activeAlarms$: Observable<ActiveAlarm[]>;
verifyAlarmsState(): Promise<void>; abstract clearAllScheduledTasks(): Promise<void>;
abstract verifyAlarmsState(): Promise<void>;
} }

View File

@ -1,28 +1,35 @@
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, Observable } from "rxjs"; import { BehaviorSubject, Observable } from "rxjs";
import { TaskIdentifier } from "@bitwarden/common/platform/abstractions/task-scheduler.service";
import { ScheduledTaskNames } from "@bitwarden/common/platform/enums/scheduled-task-name.enum"; import { ScheduledTaskNames } from "@bitwarden/common/platform/enums/scheduled-task-name.enum";
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { GlobalState, StateProvider } from "@bitwarden/common/platform/state"; import { GlobalState, StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { ActiveAlarm } from "./abstractions/browser-task-scheduler.service"; import {
import { BrowserTaskSchedulerService } from "./browser-task-scheduler.service"; ActiveAlarm,
BrowserTaskSchedulerService,
} from "./abstractions/browser-task-scheduler.service";
import { BrowserTaskSchedulerServiceImplementation } from "./browser-task-scheduler.service";
let activeAlarms: ActiveAlarm[] = []; jest.mock("rxjs", () => {
jest.mock("rxjs", () => ({ const actualModule = jest.requireActual("rxjs");
firstValueFrom: jest.fn(() => Promise.resolve(activeAlarms)), return {
map: jest.fn(), ...actualModule,
Observable: jest.fn(), firstValueFrom: jest.fn((state$: BehaviorSubject<any>) => state$.value),
})); };
});
// TODO CG - Likely need to rethink how to test this service a bit more carefully.
describe("BrowserTaskSchedulerService", () => { describe("BrowserTaskSchedulerService", () => {
const callback = jest.fn();
const delayInMinutes = 2;
const userUuid = "user-uuid" as UserId;
let activeUserIdMock$: BehaviorSubject<UserId>; let activeUserIdMock$: BehaviorSubject<UserId>;
let activeAlarmsMock$: BehaviorSubject<ActiveAlarm[]>;
let logService: MockProxy<ConsoleLogService>; let logService: MockProxy<ConsoleLogService>;
let stateProvider: MockProxy<StateProvider>; let stateProvider: MockProxy<StateProvider>;
let browserTaskSchedulerService: BrowserTaskSchedulerService; let browserTaskSchedulerService: BrowserTaskSchedulerService;
let activeAlarms: ActiveAlarm[] = [];
const eventUploadsIntervalCreateInfo = { periodInMinutes: 5, delayInMinutes: 5 }; const eventUploadsIntervalCreateInfo = { periodInMinutes: 5, delayInMinutes: 5 };
const scheduleNextSyncIntervalCreateInfo = { periodInMinutes: 5, delayInMinutes: 5 }; const scheduleNextSyncIntervalCreateInfo = { periodInMinutes: 5, delayInMinutes: 5 };
@ -43,7 +50,8 @@ describe("BrowserTaskSchedulerService", () => {
createInfo: { delayInMinutes: 1, periodInMinutes: undefined }, createInfo: { delayInMinutes: 1, periodInMinutes: undefined },
}), }),
]; ];
activeUserIdMock$ = new BehaviorSubject("user-uuid" as UserId); activeAlarmsMock$ = new BehaviorSubject(activeAlarms);
activeUserIdMock$ = new BehaviorSubject(userUuid);
logService = mock<ConsoleLogService>(); logService = mock<ConsoleLogService>();
stateProvider = mock<StateProvider>({ stateProvider = mock<StateProvider>({
activeUserId$: activeUserIdMock$, activeUserId$: activeUserIdMock$,
@ -54,50 +62,27 @@ describe("BrowserTaskSchedulerService", () => {
}), }),
), ),
}); });
browserTaskSchedulerService = new BrowserTaskSchedulerService(logService, stateProvider); browserTaskSchedulerService = new BrowserTaskSchedulerServiceImplementation(
jest.spyOn(browserTaskSchedulerService as any, "getAlarm").mockImplementation((alarmName) => { logService,
if (alarmName === ScheduledTaskNames.scheduleNextSyncInterval) { stateProvider,
return Promise.resolve(mock<chrome.alarms.Alarm>({ name: alarmName })); );
} browserTaskSchedulerService.activeAlarms$ = activeAlarmsMock$;
}); browserTaskSchedulerService.registerTaskHandler(
ScheduledTaskNames.loginStrategySessionTimeout,
callback,
);
}); });
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
jest.clearAllTimers(); jest.clearAllTimers();
jest.useRealTimers(); jest.useRealTimers();
// eslint-disable-next-line
// @ts-ignore
globalThis.browser = {};
});
describe("verifyAlarmsState", () => {
it("verifies the status of potentially existing alarms referenced from state on initialization", () => {
expect(chrome.alarms.create).toHaveBeenCalledWith(
ScheduledTaskNames.eventUploadsInterval,
eventUploadsIntervalCreateInfo,
expect.any(Function),
);
});
it("skips creating an alarm if the alarm already exists", () => {
expect(chrome.alarms.create).not.toHaveBeenCalledWith(
ScheduledTaskNames.scheduleNextSyncInterval,
scheduleNextSyncIntervalCreateInfo,
expect.any(Function),
);
});
}); });
describe("setTimeout", () => { describe("setTimeout", () => {
it("uses the global setTimeout API if the delay is less than 1000ms", async () => { it("uses the global setTimeout API if the delay is less than 1000ms", async () => {
const callback = jest.fn();
const delayInMs = 999; const delayInMs = 999;
jest.spyOn(globalThis, "setTimeout"); jest.spyOn(globalThis, "setTimeout");
await browserTaskSchedulerService.registerTaskHandler(
ScheduledTaskNames.loginStrategySessionTimeout,
callback,
);
await browserTaskSchedulerService.setTimeout( await browserTaskSchedulerService.setTimeout(
ScheduledTaskNames.loginStrategySessionTimeout, ScheduledTaskNames.loginStrategySessionTimeout,
@ -113,11 +98,38 @@ describe("BrowserTaskSchedulerService", () => {
}); });
it("creates a timeout alarm", async () => { it("creates a timeout alarm", async () => {
const callback = jest.fn(); await browserTaskSchedulerService.setTimeout(
const delayInMinutes = 2;
await browserTaskSchedulerService.registerTaskHandler(
ScheduledTaskNames.loginStrategySessionTimeout, ScheduledTaskNames.loginStrategySessionTimeout,
callback, delayInMinutes * 60 * 1000,
);
expect(chrome.alarms.create).toHaveBeenCalledWith(
`${userUuid}__${ScheduledTaskNames.loginStrategySessionTimeout}`,
{ delayInMinutes },
expect.any(Function),
);
});
it("skips creating a duplicate timeout alarm", async () => {
const mockAlarm = mock<chrome.alarms.Alarm>();
chrome.alarms.get = jest.fn().mockImplementation((_name, callback) => callback(mockAlarm));
await browserTaskSchedulerService.setTimeout(
ScheduledTaskNames.loginStrategySessionTimeout,
delayInMinutes * 60 * 1000,
);
expect(chrome.alarms.create).not.toHaveBeenCalled();
});
it("clears a scheduled alarm if a user-specific alarm for the same task is being registered", async () => {
const mockAlarm = mock<chrome.alarms.Alarm>({
name: ScheduledTaskNames.loginStrategySessionTimeout,
});
chrome.alarms.get = jest
.fn()
.mockImplementation((name, callback) =>
callback(name === ScheduledTaskNames.loginStrategySessionTimeout ? mockAlarm : undefined),
); );
await browserTaskSchedulerService.setTimeout( await browserTaskSchedulerService.setTimeout(
@ -125,287 +137,10 @@ describe("BrowserTaskSchedulerService", () => {
delayInMinutes * 60 * 1000, delayInMinutes * 60 * 1000,
); );
expect(chrome.alarms.create).toHaveBeenCalledWith(
ScheduledTaskNames.loginStrategySessionTimeout,
{ delayInMinutes },
expect.any(Function),
);
});
// it("skips creating a duplicate timeout alarm", async () => {
// const callback = jest.fn();
// const delayInMinutes = 2;
// jest.spyOn(browserTaskSchedulerService as any, "getAlarm").mockResolvedValue(
// mock<chrome.alarms.Alarm>({
// name: ScheduledTaskNames.loginStrategySessionTimeout,
// }),
// );
// jest.spyOn(browserTaskSchedulerService, "createAlarm");
// await browserTaskSchedulerService.registerTaskHandler(
// ScheduledTaskNames.loginStrategySessionTimeout,
// callback,
// );
//
// await browserTaskSchedulerService.setTimeout(
// ScheduledTaskNames.loginStrategySessionTimeout,
// delayInMinutes * 60 * 1000,
// );
//
// expect(browserTaskSchedulerService.createAlarm).not.toHaveBeenCalled();
// });
// it("logs a warning if a duplicate handler is registered when creating an alarm", () => {
// const callback = jest.fn();
// const name = ScheduledTaskNames.loginStrategySessionTimeout;
// browserTaskSchedulerService["onAlarmHandlers"][name] = jest.fn();
//
// browserTaskSchedulerService["registerAlarmHandler"](name, callback);
//
// expect(logService.warning).toHaveBeenCalledWith(
// `Alarm handler for ${name} already exists. Overwriting.`,
// );
// });
});
describe("setInterval", () => {
it("uses the global setInterval API if the interval is less than 1000ms", async () => {
const callback = jest.fn();
const intervalInMs = 999;
jest.spyOn(globalThis, "setInterval");
await browserTaskSchedulerService.registerTaskHandler(
ScheduledTaskNames.loginStrategySessionTimeout,
callback,
);
await browserTaskSchedulerService.setInterval(
ScheduledTaskNames.loginStrategySessionTimeout,
intervalInMs,
);
expect(globalThis.setInterval).toHaveBeenCalledWith(expect.any(Function), intervalInMs);
expect(chrome.alarms.create).not.toHaveBeenCalledWith(
ScheduledTaskNames.loginStrategySessionTimeout,
{ periodInMinutes: 1, delayInMinutes: 1 },
expect.any(Function),
);
});
it("creates an interval alarm", async () => {
const callback = jest.fn();
const periodInMinutes = 2;
const initialDelayInMs = 1000;
await browserTaskSchedulerService.registerTaskHandler(
ScheduledTaskNames.loginStrategySessionTimeout,
callback,
);
await browserTaskSchedulerService.setInterval(
ScheduledTaskNames.loginStrategySessionTimeout,
periodInMinutes * 60 * 1000,
initialDelayInMs,
);
expect(chrome.alarms.create).toHaveBeenCalledWith(
ScheduledTaskNames.loginStrategySessionTimeout,
{ periodInMinutes, delayInMinutes: initialDelayInMs / 1000 / 60 },
expect.any(Function),
);
});
});
describe("clearScheduledTask", () => {
afterEach(() => {
chrome.alarms.clear = jest.fn().mockImplementation((_name, callback) => callback(true));
});
it("skips clearing the alarm if the alarm name is not provided", async () => {
await browserTaskSchedulerService.clearScheduledTask({
timeoutId: 1,
intervalId: 2,
});
expect(chrome.alarms.clear).not.toHaveBeenCalled();
});
it("skips deleting the active alarm if the alarm was not cleared", async () => {
const taskIdentifier: TaskIdentifier = { taskName: ScheduledTaskNames.eventUploadsInterval };
chrome.alarms.clear = jest.fn().mockImplementation((_name, callback) => callback(false));
jest.spyOn(browserTaskSchedulerService as any, "deleteActiveAlarm");
await browserTaskSchedulerService.clearScheduledTask(taskIdentifier);
expect(browserTaskSchedulerService["deleteActiveAlarm"]).not.toHaveBeenCalled();
});
it("clears a named alarm", async () => {
const taskIdentifier: TaskIdentifier = { taskName: ScheduledTaskNames.eventUploadsInterval };
jest.spyOn(browserTaskSchedulerService as any, "deleteActiveAlarm");
await browserTaskSchedulerService.clearScheduledTask(taskIdentifier);
expect(chrome.alarms.clear).toHaveBeenCalledWith( expect(chrome.alarms.clear).toHaveBeenCalledWith(
ScheduledTaskNames.eventUploadsInterval, ScheduledTaskNames.loginStrategySessionTimeout,
expect.any(Function), expect.any(Function),
); );
expect(browserTaskSchedulerService["deleteActiveAlarm"]).toHaveBeenCalledWith(
ScheduledTaskNames.eventUploadsInterval,
);
}); });
}); });
// describe("clearAllScheduledTasks", () => {
// it("clears all scheduled tasks and extension alarms", async () => {
// jest.spyOn(browserTaskSchedulerService, "clearAllAlarms");
// jest.spyOn(browserTaskSchedulerService as any, "updateActiveAlarms");
//
// await browserTaskSchedulerService.clearAllScheduledTasks();
//
// expect(browserTaskSchedulerService.clearAllAlarms).toHaveBeenCalled();
// expect(browserTaskSchedulerService["updateActiveAlarms"]).toHaveBeenCalledWith([]);
// // expect(browserTaskSchedulerService["onAlarmHandlers"]).toEqual({});
// expect(browserTaskSchedulerService["recoveredAlarms"].size).toBe(0);
// });
// });
// describe("handleOnAlarm", () => {
// it("triggers the alarm", async () => {
// const alarm = mock<chrome.alarms.Alarm>({ name: ScheduledTaskNames.eventUploadsInterval });
// const callback = jest.fn();
// browserTaskSchedulerService["onAlarmHandlers"][alarm.name] = callback;
//
// await browserTaskSchedulerService["handleOnAlarm"](alarm);
//
// expect(callback).toHaveBeenCalled();
// });
// });
// describe("clearAlarm", () => {
// it("uses the browser.alarms API if it is available", async () => {
// const alarmName = "alarm-name";
// globalThis.browser = {
// // eslint-disable-next-line
// // @ts-ignore
// alarms: {
// clear: jest.fn(),
// },
// };
//
// await browserTaskSchedulerService.clearAlarm(alarmName);
//
// expect(browser.alarms.clear).toHaveBeenCalledWith(alarmName);
// });
//
// it("clears the alarm with the provided name", async () => {
// const alarmName = "alarm-name";
//
// const wasCleared = await browserTaskSchedulerService.clearAlarm(alarmName);
//
// expect(chrome.alarms.clear).toHaveBeenCalledWith(alarmName, expect.any(Function));
// expect(wasCleared).toBe(true);
// });
// });
//
// describe("clearAllAlarms", () => {
// it("uses the browser.alarms API if it is available", async () => {
// globalThis.browser = {
// // eslint-disable-next-line
// // @ts-ignore
// alarms: {
// clearAll: jest.fn(),
// },
// };
//
// await browserTaskSchedulerService.clearAllAlarms();
//
// expect(browser.alarms.clearAll).toHaveBeenCalled();
// });
//
// it("clears all alarms", async () => {
// const wasCleared = await browserTaskSchedulerService.clearAllAlarms();
//
// expect(chrome.alarms.clearAll).toHaveBeenCalledWith(expect.any(Function));
// expect(wasCleared).toBe(true);
// });
// });
//
// describe("createAlarm", () => {
// it("uses the browser.alarms API if it is available", async () => {
// const alarmName = "alarm-name";
// const alarmInfo = { when: 1000 };
// globalThis.browser = {
// // eslint-disable-next-line
// // @ts-ignore
// alarms: {
// create: jest.fn(),
// },
// };
//
// await browserTaskSchedulerService.createAlarm(alarmName, alarmInfo);
//
// expect(browser.alarms.create).toHaveBeenCalledWith(alarmName, alarmInfo);
// });
//
// it("creates an alarm", async () => {
// const alarmName = "alarm-name";
// const alarmInfo = { when: 1000 };
//
// await browserTaskSchedulerService.createAlarm(alarmName, alarmInfo);
//
// expect(chrome.alarms.create).toHaveBeenCalledWith(alarmName, alarmInfo, expect.any(Function));
// });
// });
//
// describe.skip("getAlarm", () => {
// it("uses the browser.alarms API if it is available", async () => {
// const alarmName = "alarm-name";
// globalThis.browser = {
// // eslint-disable-next-line
// // @ts-ignore
// alarms: {
// get: jest.fn(),
// },
// };
//
// await browserTaskSchedulerService.getAlarm(alarmName);
//
// expect(browser.alarms.get).toHaveBeenCalledWith(alarmName);
// });
//
// it("gets the alarm by name", async () => {
// const alarmName = "alarm-name";
// const alarmMock = mock<chrome.alarms.Alarm>();
// chrome.alarms.get = jest.fn().mockImplementation((_name, callback) => callback(alarmMock));
//
// const receivedAlarm = await browserTaskSchedulerService.getAlarm(alarmName);
//
// expect(chrome.alarms.get).toHaveBeenCalledWith(alarmName, expect.any(Function));
// expect(receivedAlarm).toBe(alarmMock);
// });
// });
//
// describe("getAllAlarms", () => {
// it("uses the browser.alarms API if it is available", async () => {
// globalThis.browser = {
// // eslint-disable-next-line
// // @ts-ignore
// alarms: {
// getAll: jest.fn(),
// },
// };
//
// await browserTaskSchedulerService.getAllAlarms();
//
// expect(browser.alarms.getAll).toHaveBeenCalled();
// });
//
// it("gets all registered alarms", async () => {
// const alarms = [mock<chrome.alarms.Alarm>(), mock<chrome.alarms.Alarm>()];
// chrome.alarms.getAll = jest.fn().mockImplementation((callback) => callback(alarms));
//
// const receivedAlarms = await browserTaskSchedulerService.getAllAlarms();
//
// expect(chrome.alarms.getAll).toHaveBeenCalledWith(expect.any(Function));
// expect(receivedAlarms).toBe(alarms);
// });
// });
}); });

View File

@ -15,16 +15,16 @@ import { BrowserApi } from "../browser/browser-api";
import { import {
ActiveAlarm, ActiveAlarm,
BrowserTaskSchedulerService as BrowserTaskSchedulerServiceInterface, BrowserTaskSchedulerService,
} from "./abstractions/browser-task-scheduler.service"; } from "./abstractions/browser-task-scheduler.service";
const ACTIVE_ALARMS = new KeyDefinition(TASK_SCHEDULER_DISK, "activeAlarms", { const ACTIVE_ALARMS = new KeyDefinition(TASK_SCHEDULER_DISK, "activeAlarms", {
deserializer: (value: ActiveAlarm[]) => value ?? [], deserializer: (value: ActiveAlarm[]) => value ?? [],
}); });
export class BrowserTaskSchedulerService export class BrowserTaskSchedulerServiceImplementation
extends DefaultTaskSchedulerService extends DefaultTaskSchedulerService
implements BrowserTaskSchedulerServiceInterface implements BrowserTaskSchedulerService
{ {
private activeAlarmsState: GlobalState<ActiveAlarm[]>; private activeAlarmsState: GlobalState<ActiveAlarm[]>;
readonly activeAlarms$: Observable<ActiveAlarm[]>; readonly activeAlarms$: Observable<ActiveAlarm[]>;
@ -57,6 +57,8 @@ export class BrowserTaskSchedulerService
return super.setTimeout(taskName, delayInMs); return super.setTimeout(taskName, delayInMs);
} }
this.validateRegisteredTask(taskName);
const alarmName = await this.getActiveUserAlarmName(taskName); const alarmName = await this.getActiveUserAlarmName(taskName);
await this.scheduleAlarm(alarmName, { delayInMinutes }); await this.scheduleAlarm(alarmName, { delayInMinutes });
} }
@ -80,6 +82,8 @@ export class BrowserTaskSchedulerService
return super.setInterval(taskName, intervalInMs); return super.setInterval(taskName, intervalInMs);
} }
this.validateRegisteredTask(taskName);
const alarmName = await this.getActiveUserAlarmName(taskName); const alarmName = await this.getActiveUserAlarmName(taskName);
const initialDelayInMinutes = initialDelayInMs ? initialDelayInMs / 1000 / 60 : undefined; const initialDelayInMinutes = initialDelayInMs ? initialDelayInMs / 1000 / 60 : undefined;
await this.scheduleAlarm(alarmName, { await this.scheduleAlarm(alarmName, {
@ -122,7 +126,7 @@ export class BrowserTaskSchedulerService
*/ */
async verifyAlarmsState(): Promise<void> { async verifyAlarmsState(): Promise<void> {
const currentTime = Date.now(); const currentTime = Date.now();
const activeAlarms = await firstValueFrom(this.activeAlarms$); const activeAlarms = await this.getActiveAlarms();
for (const alarm of activeAlarms) { for (const alarm of activeAlarms) {
const { alarmName, startTime, createInfo } = alarm; const { alarmName, startTime, createInfo } = alarm;
@ -159,10 +163,6 @@ export class BrowserTaskSchedulerService
alarmName: string, alarmName: string,
createInfo: chrome.alarms.AlarmCreateInfo, createInfo: chrome.alarms.AlarmCreateInfo,
): Promise<void> { ): Promise<void> {
if (!alarmName) {
return;
}
const existingAlarm = await this.getAlarm(alarmName); const existingAlarm = await this.getAlarm(alarmName);
if (existingAlarm) { if (existingAlarm) {
this.logService.warning(`Alarm ${alarmName} already exists. Skipping creation.`); this.logService.warning(`Alarm ${alarmName} already exists. Skipping creation.`);
@ -181,6 +181,13 @@ export class BrowserTaskSchedulerService
await this.setActiveAlarm(alarmName, createInfo); await this.setActiveAlarm(alarmName, createInfo);
} }
/**
* Gets the active alarms from state.
*/
private async getActiveAlarms(): Promise<ActiveAlarm[]> {
return await firstValueFrom(this.activeAlarms$);
}
/** /**
* Sets an active alarm in state. * Sets an active alarm in state.
* *
@ -191,7 +198,7 @@ export class BrowserTaskSchedulerService
alarmName: string, alarmName: string,
createInfo: chrome.alarms.AlarmCreateInfo, createInfo: chrome.alarms.AlarmCreateInfo,
): Promise<void> { ): Promise<void> {
const activeAlarms = await firstValueFrom(this.activeAlarms$); const activeAlarms = await this.getActiveAlarms();
const filteredAlarms = activeAlarms.filter((alarm) => alarm.alarmName !== alarmName); const filteredAlarms = activeAlarms.filter((alarm) => alarm.alarmName !== alarmName);
filteredAlarms.push({ filteredAlarms.push({
alarmName, alarmName,
@ -207,11 +214,16 @@ export class BrowserTaskSchedulerService
* @param alarmName - The name of the active alarm to delete. * @param alarmName - The name of the active alarm to delete.
*/ */
private async deleteActiveAlarm(alarmName: string): Promise<void> { private async deleteActiveAlarm(alarmName: string): Promise<void> {
const activeAlarms = await firstValueFrom(this.activeAlarms$); const activeAlarms = await this.getActiveAlarms();
const filteredAlarms = activeAlarms.filter((alarm) => alarm.alarmName !== alarmName); const filteredAlarms = activeAlarms.filter((alarm) => alarm.alarmName !== alarmName);
await this.updateActiveAlarms(filteredAlarms || []); await this.updateActiveAlarms(filteredAlarms || []);
} }
/**
* Clears a scheduled alarm by its name and deletes it from the active alarms state.
*
* @param alarmName - The name of the alarm to clear.
*/
private async clearScheduledAlarm(alarmName: string): Promise<void> { private async clearScheduledAlarm(alarmName: string): Promise<void> {
const wasCleared = await this.clearAlarm(alarmName); const wasCleared = await this.clearAlarm(alarmName);
if (wasCleared) { if (wasCleared) {
@ -285,8 +297,20 @@ export class BrowserTaskSchedulerService
} }
} }
/**
* Gets the active user id from state.
*/
private async getActiveUserId(): Promise<string> {
return await firstValueFrom(this.stateProvider.activeUserId$);
}
/**
* Gets the active user alarm name by appending the active user id to the task name.
*
* @param taskName - The task name to append the active user id to.
*/
private async getActiveUserAlarmName(taskName: ScheduledTaskName): Promise<string> { private async getActiveUserAlarmName(taskName: ScheduledTaskName): Promise<string> {
const activeUserId = await firstValueFrom(this.stateProvider.activeUserId$); const activeUserId = await this.getActiveUserId();
if (!activeUserId) { if (!activeUserId) {
return taskName; return taskName;
} }
@ -294,6 +318,12 @@ export class BrowserTaskSchedulerService
return `${activeUserId}__${taskName}`; return `${activeUserId}__${taskName}`;
} }
/**
* Parses and returns the task name from an alarm name. If the alarm name
* contains a user id, it will return the task name without the user id.
*
* @param alarmName - The alarm name to parse.
*/
private getTaskFromAlarmName(alarmName: string): ScheduledTaskName { private getTaskFromAlarmName(alarmName: string): ScheduledTaskName {
const activeUserTask = alarmName.split("__")[1] as ScheduledTaskName; const activeUserTask = alarmName.split("__")[1] as ScheduledTaskName;
if (activeUserTask) { if (activeUserTask) {
@ -310,7 +340,7 @@ export class BrowserTaskSchedulerService
* @param alarmName - The name of the alarm to create. * @param alarmName - The name of the alarm to create.
*/ */
private async clearAlarm(alarmName: string): Promise<boolean> { private async clearAlarm(alarmName: string): Promise<boolean> {
if (typeof browser !== "undefined" && browser.alarms) { if (this.isNonChromeEnvironment()) {
return browser.alarms.clear(alarmName); return browser.alarms.clear(alarmName);
} }
@ -322,7 +352,7 @@ export class BrowserTaskSchedulerService
* that indicates when all alarms have been cleared successfully. * that indicates when all alarms have been cleared successfully.
*/ */
private clearAllAlarms(): Promise<boolean> { private clearAllAlarms(): Promise<boolean> {
if (typeof browser !== "undefined" && browser.alarms) { if (this.isNonChromeEnvironment()) {
return browser.alarms.clearAll(); return browser.alarms.clearAll();
} }
@ -339,7 +369,7 @@ export class BrowserTaskSchedulerService
alarmName: string, alarmName: string,
createInfo: chrome.alarms.AlarmCreateInfo, createInfo: chrome.alarms.AlarmCreateInfo,
): Promise<void> { ): Promise<void> {
if (typeof browser !== "undefined" && browser.alarms) { if (this.isNonChromeEnvironment()) {
return browser.alarms.create(alarmName, createInfo); return browser.alarms.create(alarmName, createInfo);
} }
@ -352,10 +382,18 @@ export class BrowserTaskSchedulerService
* @param alarmName - The name of the alarm to get. * @param alarmName - The name of the alarm to get.
*/ */
private async getAlarm(alarmName: string): Promise<chrome.alarms.Alarm> { private async getAlarm(alarmName: string): Promise<chrome.alarms.Alarm> {
if (typeof browser !== "undefined" && browser.alarms) { if (this.isNonChromeEnvironment()) {
return browser.alarms.get(alarmName); return browser.alarms.get(alarmName);
} }
return new Promise((resolve) => chrome.alarms.get(alarmName, resolve)); return new Promise((resolve) => chrome.alarms.get(alarmName, resolve));
} }
/**
* Checks if the environment is a non-Chrome environment. This is used to determine
* if the browser alarms API should be used in place of the chrome alarms API.
*/
private isNonChromeEnvironment(): boolean {
return typeof browser !== "undefined" && !!browser.alarms;
}
} }

View File

@ -104,7 +104,7 @@ import { ScriptInjectorService } from "../../platform/services/abstractions/scri
import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service"; import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service";
import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service"; import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service";
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service"; import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
import { BrowserTaskSchedulerService } from "../../platform/services/browser-task-scheduler.service"; import { BrowserTaskSchedulerServiceImplementation } from "../../platform/services/browser-task-scheduler.service";
import { DefaultBrowserStateService } from "../../platform/services/default-browser-state.service"; import { DefaultBrowserStateService } from "../../platform/services/default-browser-state.service";
import I18nService from "../../platform/services/i18n.service"; import I18nService from "../../platform/services/i18n.service";
import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service"; import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service";
@ -568,10 +568,10 @@ const safeProviders: SafeProvider[] = [
}), }),
safeProvider({ safeProvider({
provide: TaskSchedulerService, provide: TaskSchedulerService,
useExisting: BrowserTaskSchedulerService, useExisting: BrowserTaskSchedulerServiceImplementation,
}), }),
safeProvider({ safeProvider({
provide: BrowserTaskSchedulerService, provide: BrowserTaskSchedulerServiceImplementation,
deps: [LogService, StateProvider], deps: [LogService, StateProvider],
}), }),
]; ];