diff --git a/.changeset/clean-clocks-thank.md b/.changeset/clean-clocks-thank.md new file mode 100644 index 0000000000..47e99c4e41 --- /dev/null +++ b/.changeset/clean-clocks-thank.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': minor +--- + +Switched out `mockServices.scheduler` to use a mocked implementation instead of the default scheduler implementation. This implementation runs any scheduled tasks immediately on startup, as long as they don't have an initial delay or a manual trigger. After the initial run, the tasks are never run again unless manually triggered. diff --git a/docs/backend-system/core-services/scheduler.md b/docs/backend-system/core-services/scheduler.md index eb25692698..b8f8c6aac1 100644 --- a/docs/backend-system/core-services/scheduler.md +++ b/docs/backend-system/core-services/scheduler.md @@ -129,3 +129,31 @@ Responds with - `200 OK` if successful - `404 Not Found` if there was no such registered task for this plugin - `409 Conflict` if the task was already in a running state + +## Testing + +The `@backstage/backend-test-utils` package provides `mockServices.scheduler`, which provides a mocked implementation of the scheduler service that can be used in tests. This mocked implementation is used by default in `startTestBackend`, and it will immediately run any registered tasks on startup as long as they're not configured to run manually or with an initial delay. + +A dedicated instance can be used for more control during testing, with the mock implementation providing additional utilities to trigger and wait for tasks to complete: + +```ts +it('should trigger a task', async () => { + const scheduler = mockServices.scheduler(); + + const { server } = await startTestBackend({ + features: [scheduler.factory()], + }); + + // Start waiting for some task to complete + const waitForTask = scheduler.waitForTask('some-task-id'); + + // Call an endpoit that triggers a task + const res = await request(server).post( + '/api/my-plugin/route-that-triggers-a-task', + ); + expect(res.status).toBe(200); + + // Wait for the task to complete + await waitForTask; +}); +``` diff --git a/packages/backend-test-utils/report.api.md b/packages/backend-test-utils/report.api.md index fb22e5c850..eeb5009641 100644 --- a/packages/backend-test-utils/report.api.md +++ b/packages/backend-test-utils/report.api.md @@ -366,9 +366,15 @@ export namespace mockServices { ) => ServiceMock; } // (undocumented) + export function scheduler(): SchedulerService; + // (undocumented) export namespace scheduler { const // (undocumented) - factory: () => ServiceFactory; + factory: (options?: { + skipTaskRunOnStartup?: boolean; + includeManualTasksOnStartup?: boolean; + includeInitialDelayedTasksOnStartup?: boolean; + }) => ServiceFactory; const // (undocumented) mock: ( partialImpl?: Partial | undefined, diff --git a/packages/backend-test-utils/src/services/MockSchedulerService.test.ts b/packages/backend-test-utils/src/services/MockSchedulerService.test.ts new file mode 100644 index 0000000000..8e26fd770e --- /dev/null +++ b/packages/backend-test-utils/src/services/MockSchedulerService.test.ts @@ -0,0 +1,208 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { startTestBackend } from '../wiring'; +import { MockSchedulerService } from './MockSchedulerService'; +import { mockServices } from './mockServices'; +import { setTimeout } from 'timers/promises'; + +const baseOpts = { + frequency: { seconds: 10 }, + timeout: { seconds: 10 }, +}; + +describe('MockSchedulerService', () => { + it('should run a task', async () => { + const scheduler = new MockSchedulerService(); + expect(scheduler).toBeDefined(); + + const taskFn = jest.fn(); + scheduler.scheduleTask({ + ...baseOpts, + id: 'test', + fn: taskFn, + }); + + expect(taskFn).not.toHaveBeenCalled(); + + await scheduler.triggerTask('test'); + + expect(taskFn).toHaveBeenCalled(); + }); + + it('should run tasks on startup', async () => { + const testFnPlain = jest.fn(); + const testFnInitialDelay = jest.fn(); + const testFnManual = jest.fn(); + const testFnLocal = jest.fn(); + + // Relying on the fact that the mock scheduler service is used by default + await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'tester', + register(reg) { + reg.registerInit({ + deps: { scheduler: coreServices.scheduler }, + async init({ scheduler }) { + scheduler.scheduleTask({ + ...baseOpts, + id: 'test-plain', + fn: testFnPlain, + }); + scheduler.scheduleTask({ + ...baseOpts, + id: 'test-local', + scope: 'local', + fn: testFnLocal, + }); + + // Should not run by default + scheduler.scheduleTask({ + ...baseOpts, + id: 'test-with-initial-delay', + initialDelay: { seconds: 1 }, + fn: testFnInitialDelay, + }); + scheduler.scheduleTask({ + ...baseOpts, + id: 'test-manual', + frequency: { trigger: 'manual' }, + fn: testFnManual, + }); + }, + }); + }, + }), + ], + }); + + expect(testFnPlain).toHaveBeenCalled(); + expect(testFnLocal).toHaveBeenCalled(); + expect(testFnInitialDelay).not.toHaveBeenCalled(); + expect(testFnManual).not.toHaveBeenCalled(); + }); + + it('should not run tasks on startup if skipped', async () => { + const testFnPlain = jest.fn(); + + await startTestBackend({ + features: [ + new MockSchedulerService().factory({ skipTaskRunOnStartup: true }), + createBackendPlugin({ + pluginId: 'tester', + register(reg) { + reg.registerInit({ + deps: { scheduler: coreServices.scheduler }, + async init({ scheduler }) { + scheduler.scheduleTask({ + ...baseOpts, + id: 'test-plain', + fn: testFnPlain, + }); + }, + }); + }, + }), + ], + }); + + expect(testFnPlain).not.toHaveBeenCalled(); + }); + + it('should run all tasks on startup if configured', async () => { + const testFnPlain = jest.fn(); + const testFnInitialDelay = jest.fn(); + const testFnManual = jest.fn(); + const testFnLocal = jest.fn(); + + await startTestBackend({ + features: [ + mockServices.scheduler.factory({ + includeManualTasksOnStartup: true, + includeInitialDelayedTasksOnStartup: true, + }), + createBackendPlugin({ + pluginId: 'tester', + register(reg) { + reg.registerInit({ + deps: { scheduler: coreServices.scheduler }, + async init({ scheduler }) { + scheduler.scheduleTask({ + ...baseOpts, + id: 'test-plain', + fn: testFnPlain, + }); + scheduler.scheduleTask({ + ...baseOpts, + id: 'test-local', + scope: 'local', + fn: testFnLocal, + }); + + // Should not run by default + scheduler.scheduleTask({ + ...baseOpts, + id: 'test-with-initial-delay', + initialDelay: { seconds: 1 }, + fn: testFnInitialDelay, + }); + scheduler.scheduleTask({ + ...baseOpts, + id: 'test-manual', + frequency: { trigger: 'manual' }, + fn: testFnManual, + }); + }, + }); + }, + }), + ], + }); + + expect(testFnPlain).toHaveBeenCalled(); + expect(testFnLocal).toHaveBeenCalled(); + expect(testFnInitialDelay).toHaveBeenCalled(); + expect(testFnManual).toHaveBeenCalled(); + }); + + it('should wait for a specific task to complete', async () => { + const scheduler = new MockSchedulerService(); + const taskFn = jest.fn(); + scheduler.scheduleTask({ + ...baseOpts, + id: 'test', + fn: taskFn, + }); + + const wait = scheduler.waitForTask('test'); + + const isDone = () => + Promise.race([wait.then(() => true), setTimeout(1, false)]); + + expect(taskFn).not.toHaveBeenCalled(); + await expect(isDone()).resolves.toBe(false); + + await scheduler.triggerTask('test'); + + expect(taskFn).toHaveBeenCalled(); + await expect(isDone()).resolves.toBe(true); + }); +}); diff --git a/packages/backend-test-utils/src/services/MockSchedulerService.ts b/packages/backend-test-utils/src/services/MockSchedulerService.ts new file mode 100644 index 0000000000..7648770eb5 --- /dev/null +++ b/packages/backend-test-utils/src/services/MockSchedulerService.ts @@ -0,0 +1,164 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + coreServices, + createServiceFactory, + SchedulerService, + SchedulerServiceTaskDescriptor, + SchedulerServiceTaskInvocationDefinition, + SchedulerServiceTaskRunner, + SchedulerServiceTaskScheduleDefinition, +} from '@backstage/backend-plugin-api'; +import { createDeferred, DeferredPromise } from '@backstage/types'; + +export class MockSchedulerService implements SchedulerService { + readonly #tasks = new Map< + string, + SchedulerServiceTaskInvocationDefinition & + SchedulerServiceTaskScheduleDefinition & { + descriptor: SchedulerServiceTaskDescriptor; + } + >(); + readonly #runningTasks = new Set(); + readonly #deferredTaskCompletions = new Map>(); + + /** + * Creates a service factory for this mock scheduler instance, which can be installed in a test backend + */ + factory(options?: { + skipTaskRunOnStartup?: boolean; + includeManualTasksOnStartup?: boolean; + includeInitialDelayedTasksOnStartup?: boolean; + }) { + return createServiceFactory({ + service: coreServices.scheduler, + deps: { lifecycle: coreServices.lifecycle }, + factory: async ({ lifecycle }) => { + if (!options?.skipTaskRunOnStartup) { + lifecycle.addStartupHook(async () => { + await this.triggerAllTasks({ + includeManualTasks: options?.includeManualTasksOnStartup, + includeInitialDelayedTasks: + options?.includeInitialDelayedTasksOnStartup, + }); + }); + } + return this; + }, + }); + } + + createScheduledTaskRunner( + schedule: SchedulerServiceTaskScheduleDefinition, + ): SchedulerServiceTaskRunner { + return { + run: async task => { + await this.scheduleTask({ ...task, ...schedule }); + }, + }; + } + + async getScheduledTasks(): Promise { + return Array.from(this.#tasks.values()).map(({ descriptor }) => descriptor); + } + + async scheduleTask( + task: SchedulerServiceTaskScheduleDefinition & + SchedulerServiceTaskInvocationDefinition, + ): Promise { + this.#tasks.set(task.id, { + ...task, + descriptor: { + id: task.id, + scope: task.scope ?? 'global', + settings: { version: 1 }, + }, + }); + } + + async triggerTask(id: string): Promise { + const task = this.#tasks.get(id); + if (!task) { + throw new Error(`Task ${id} not found`); + } + if (this.#runningTasks.has(id)) { + return; + } + this.#runningTasks.add(id); + try { + await task.fn(new AbortController().signal); + this.#deferredTaskCompletions.get(id)?.resolve(); + } catch (error) { + this.#deferredTaskCompletions.get(id)?.reject(error); + } finally { + this.#runningTasks.delete(id); + } + } + + /** + * Trigger all tasks that match the given options, and wait for them to complete. + * + * @param options - The options to filter the tasks to trigger + */ + async triggerAllTasks(options?: { + scope?: 'all' | 'global' | 'local'; + includeInitialDelayedTasks?: boolean; + includeManualTasks?: boolean; + }): Promise { + const { + scope = 'all', + includeManualTasks = false, + includeInitialDelayedTasks = false, + } = options ?? {}; + + const selectedTaskIds = new Array(); + for (const task of this.#tasks.values()) { + if (task.initialDelay && !includeInitialDelayedTasks) { + continue; + } + if ('trigger' in task.frequency && task.frequency.trigger === 'manual') { + if (includeManualTasks) { + selectedTaskIds.push(task.id); + } + continue; + } + if (scope === 'all' || scope === task.scope) { + selectedTaskIds.push(task.id); + } + } + + await Promise.all(selectedTaskIds.map(id => this.triggerTask(id))); + } + + /** + * Wait for the task with the given ID to complete. + * + * If the task has not yet been scheduler or started, this will wait for it to be scheduled, started, and completed + * + * @param id - The task ID to wait for + * @returns A promise that resolves when the task is completed + */ + async waitForTask(id: string): Promise { + const existing = this.#deferredTaskCompletions.get(id); + if (existing) { + return existing; + } + const defferred = createDeferred(); + this.#deferredTaskCompletions.set(id, defferred); + return defferred; + } +} diff --git a/packages/backend-test-utils/src/services/mockServices.ts b/packages/backend-test-utils/src/services/mockServices.ts index 06bf8f9a29..587db30934 100644 --- a/packages/backend-test-utils/src/services/mockServices.ts +++ b/packages/backend-test-utils/src/services/mockServices.ts @@ -26,7 +26,6 @@ import { permissionsRegistryServiceFactory } from '@backstage/backend-defaults/p import { rootHealthServiceFactory } from '@backstage/backend-defaults/rootHealth'; import { rootHttpRouterServiceFactory } from '@backstage/backend-defaults/rootHttpRouter'; import { rootLifecycleServiceFactory } from '@backstage/backend-defaults/rootLifecycle'; -import { schedulerServiceFactory } from '@backstage/backend-defaults/scheduler'; import { urlReaderServiceFactory } from '@backstage/backend-defaults/urlReader'; import { AuthService, @@ -57,6 +56,7 @@ import { mockCredentials } from './mockCredentials'; import { MockEventsService } from './MockEventsService'; import { MockPermissionsService } from './MockPermissionsService'; import { simpleMock } from './simpleMock'; +import { MockSchedulerService } from './MockSchedulerService'; /** @internal */ function createLoggerMock() { @@ -496,8 +496,15 @@ export namespace mockServices { })); } + export function scheduler(): MockSchedulerService { + return new MockSchedulerService(); + } export namespace scheduler { - export const factory = () => schedulerServiceFactory; + export const factory = (options?: { + skipTaskRunOnStartup?: boolean; + includeManualTasksOnStartup?: boolean; + includeInitialDelayedTasksOnStartup?: boolean; + }) => new MockSchedulerService().factory(options); export const mock = simpleMock(coreServices.scheduler, () => ({ createScheduledTaskRunner: jest.fn(), getScheduledTasks: jest.fn(),