diff --git a/.changeset/scaffolder-common-required-methods.md b/.changeset/scaffolder-common-required-methods.md new file mode 100644 index 0000000000..10a5736259 --- /dev/null +++ b/.changeset/scaffolder-common-required-methods.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-common': minor +--- + +**BREAKING PRODUCERS**: Made `retry`, `listTasks`, `listTemplatingExtensions`, `dryRun`, and `autocomplete` required methods on the `ScaffolderApi` interface. Implementations of `ScaffolderApi` must now provide these methods. diff --git a/.changeset/scaffolder-react-api-mock.md b/.changeset/scaffolder-react-api-mock.md new file mode 100644 index 0000000000..58561e4e42 --- /dev/null +++ b/.changeset/scaffolder-react-api-mock.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +Added `scaffolderApiMock` test utility, exported from `@backstage/plugin-scaffolder-react/testUtils`. diff --git a/.changeset/scaffolder-service-ref.md b/.changeset/scaffolder-service-ref.md new file mode 100644 index 0000000000..250b471f7e --- /dev/null +++ b/.changeset/scaffolder-service-ref.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-node': patch +--- + +Added `scaffolderServiceRef` and `ScaffolderService` interface for backend plugins that need to interact with the scaffolder API using `BackstageCredentials` instead of raw tokens. diff --git a/plugins/scaffolder-common/report.api.md b/plugins/scaffolder-common/report.api.md index 02df01397c..c4b17c1723 100644 --- a/plugins/scaffolder-common/report.api.md +++ b/plugins/scaffolder-common/report.api.md @@ -58,7 +58,7 @@ export type LogEvent = { // @public export interface ScaffolderApi { // (undocumented) - autocomplete?( + autocomplete( request: { token: string; provider: string; @@ -79,94 +79,6 @@ export interface ScaffolderApi { status?: ScaffolderTaskStatus; }>; // (undocumented) - dryRun?( - request: ScaffolderDryRunOptions, - options?: ScaffolderRequestOptions, - ): Promise; - // (undocumented) - getIntegrationsList( - options: ScaffolderGetIntegrationsListOptions, - ): Promise; - // (undocumented) - getTask( - taskId: string, - options?: ScaffolderRequestOptions, - ): Promise; - // (undocumented) - getTemplateParameterSchema( - templateRef: string, - options?: ScaffolderRequestOptions, - ): Promise; - listActions(options?: ScaffolderRequestOptions): Promise; - // (undocumented) - listTasks?( - request: { - filterByOwnership: 'owned' | 'all'; - limit?: number; - offset?: number; - }, - options?: ScaffolderRequestOptions, - ): Promise<{ - tasks: ScaffolderTask[]; - totalTasks?: number; - }>; - listTemplatingExtensions?( - options?: ScaffolderRequestOptions, - ): Promise; - retry?( - taskId: string, - options?: ScaffolderRequestOptions, - ): Promise<{ - id: string; - }>; - scaffold( - request: ScaffolderScaffoldOptions, - options?: ScaffolderRequestOptions, - ): Promise; - // (undocumented) - streamLogs( - request: ScaffolderStreamLogsOptions, - options?: ScaffolderRequestOptions, - ): Observable; -} - -// @public -export class ScaffolderClient implements ScaffolderApi { - constructor(options: { - discoveryApi: { - getBaseUrl(pluginId: string): Promise; - }; - fetchApi: { - fetch: typeof fetch; - }; - identityApi?: { - getBackstageIdentity(): Promise<{ - type: 'user'; - userEntityRef: string; - ownershipEntityRefs: string[]; - }>; - }; - scmIntegrationsApi: ScmIntegrationRegistry; - useLongPollingLogs?: boolean; - }); - autocomplete(input: { - token: string; - provider: string; - resource: string; - context: Record; - }): Promise<{ - results: { - title?: string; - id: string; - }[]; - }>; - cancelTask( - taskId: string, - options?: ScaffolderRequestOptions, - ): Promise<{ - status?: ScaffolderTaskStatus; - }>; - // (undocumented) dryRun( request: ScaffolderDryRunOptions, options?: ScaffolderRequestOptions, @@ -201,7 +113,98 @@ export class ScaffolderClient implements ScaffolderApi { listTemplatingExtensions( options?: ScaffolderRequestOptions, ): Promise; - retry?( + retry( + taskId: string, + options?: ScaffolderRequestOptions, + ): Promise<{ + id: string; + }>; + scaffold( + request: ScaffolderScaffoldOptions, + options?: ScaffolderRequestOptions, + ): Promise; + // (undocumented) + streamLogs( + request: ScaffolderStreamLogsOptions, + options?: ScaffolderRequestOptions, + ): Observable; +} + +// @public +export class ScaffolderClient implements ScaffolderApi { + constructor(options: { + discoveryApi: { + getBaseUrl(pluginId: string): Promise; + }; + fetchApi: { + fetch: typeof fetch; + }; + identityApi?: { + getBackstageIdentity(): Promise<{ + type: 'user'; + userEntityRef: string; + ownershipEntityRefs: string[]; + }>; + }; + scmIntegrationsApi: ScmIntegrationRegistry; + useLongPollingLogs?: boolean; + }); + autocomplete( + input: { + token: string; + provider: string; + resource: string; + context: Record; + }, + options?: ScaffolderRequestOptions, + ): Promise<{ + results: { + title?: string; + id: string; + }[]; + }>; + cancelTask( + taskId: string, + options?: ScaffolderRequestOptions, + ): Promise<{ + status?: ScaffolderTaskStatus; + }>; + // (undocumented) + dryRun( + request: ScaffolderDryRunOptions, + options?: ScaffolderRequestOptions, + ): Promise; + // (undocumented) + getIntegrationsList( + options: ScaffolderGetIntegrationsListOptions, + ): Promise; + // (undocumented) + getTask( + taskId: string, + options?: ScaffolderRequestOptions, + ): Promise; + // (undocumented) + getTemplateParameterSchema( + templateRef: string, + options?: ScaffolderRequestOptions, + ): Promise; + listActions(options?: ScaffolderRequestOptions): Promise; + // (undocumented) + listTasks( + request: { + filterByOwnership: 'owned' | 'all'; + limit?: number; + offset?: number; + }, + options?: ScaffolderRequestOptions, + ): Promise<{ + tasks: ScaffolderTask[]; + totalTasks?: number; + }>; + listTemplatingExtensions( + options?: ScaffolderRequestOptions, + ): Promise; + retry( taskId: string, options?: ScaffolderRequestOptions, ): Promise<{ diff --git a/plugins/scaffolder-common/src/ScaffolderClient.ts b/plugins/scaffolder-common/src/ScaffolderClient.ts index d40f7a4637..d8aa5106c9 100644 --- a/plugins/scaffolder-common/src/ScaffolderClient.ts +++ b/plugins/scaffolder-common/src/ScaffolderClient.ts @@ -381,7 +381,7 @@ export class ScaffolderClient implements ScaffolderApi { /** * {@inheritdoc ScaffolderApi.retry} */ - async retry?( + async retry( taskId: string, options?: ScaffolderRequestOptions, ): Promise<{ id: string }> { @@ -393,22 +393,28 @@ export class ScaffolderClient implements ScaffolderApi { /** * {@inheritdoc ScaffolderApi.retry} */ - async autocomplete({ - token, - resource, - provider, - context, - }: { - token: string; - provider: string; - resource: string; - context: Record; - }): Promise<{ results: { title?: string; id: string }[] }> { + async autocomplete( + { + token, + resource, + provider, + context, + }: { + token: string; + provider: string; + resource: string; + context: Record; + }, + options?: ScaffolderRequestOptions, + ): Promise<{ results: { title?: string; id: string }[] }> { return await this.requestRequired( - await this.apiClient.autocomplete({ - path: { provider, resource }, - body: { token, context }, - }), + await this.apiClient.autocomplete( + { + path: { provider, resource }, + body: { token, context }, + }, + options, + ), ); } diff --git a/plugins/scaffolder-common/src/api.ts b/plugins/scaffolder-common/src/api.ts index 5425607d34..5e61af5bb4 100644 --- a/plugins/scaffolder-common/src/api.ts +++ b/plugins/scaffolder-common/src/api.ts @@ -285,12 +285,12 @@ export interface ScaffolderApi { * * @param taskId - the id of the task */ - retry?( + retry( taskId: string, options?: ScaffolderRequestOptions, ): Promise<{ id: string }>; - listTasks?( + listTasks( request: { filterByOwnership: 'owned' | 'all'; limit?: number; @@ -311,7 +311,7 @@ export interface ScaffolderApi { /** * Returns a structure describing the available templating extensions. */ - listTemplatingExtensions?( + listTemplatingExtensions( options?: ScaffolderRequestOptions, ): Promise; @@ -320,12 +320,12 @@ export interface ScaffolderApi { options?: ScaffolderRequestOptions, ): Observable; - dryRun?( + dryRun( request: ScaffolderDryRunOptions, options?: ScaffolderRequestOptions, ): Promise; - autocomplete?( + autocomplete( request: { token: string; provider: string; diff --git a/plugins/scaffolder-node/package.json b/plugins/scaffolder-node/package.json index 44924831cb..10af899c19 100644 --- a/plugins/scaffolder-node/package.json +++ b/plugins/scaffolder-node/package.json @@ -27,6 +27,7 @@ "exports": { ".": "./src/index.ts", "./alpha": "./src/alpha/index.ts", + "./testUtils": "./src/testUtils.ts", "./package.json": "./package.json" }, "main": "src/index.ts", @@ -36,6 +37,9 @@ "alpha": [ "src/alpha/index.ts" ], + "testUtils": [ + "src/testUtils.ts" + ], "package.json": [ "package.json" ] @@ -79,6 +83,15 @@ "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@backstage/config": "workspace:^", - "@types/lodash": "^4.14.151" + "@types/lodash": "^4.14.151", + "msw": "^1.0.0" + }, + "peerDependencies": { + "@backstage/backend-test-utils": "workspace:^" + }, + "peerDependenciesMeta": { + "@backstage/backend-test-utils": { + "optional": true + } } } diff --git a/plugins/scaffolder-node/report-testUtils.api.md b/plugins/scaffolder-node/report-testUtils.api.md new file mode 100644 index 0000000000..e4f9c55ee2 --- /dev/null +++ b/plugins/scaffolder-node/report-testUtils.api.md @@ -0,0 +1,122 @@ +## API Report File for "@backstage/plugin-scaffolder-node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackstageCredentials } from '@backstage/backend-plugin-api'; +import { ListActionsResponse } from '@backstage/plugin-scaffolder-common'; +import { ListTemplatingExtensionsResponse } from '@backstage/plugin-scaffolder-common'; +import { LogEvent } from '@backstage/plugin-scaffolder-common'; +import { ScaffolderDryRunOptions } from '@backstage/plugin-scaffolder-common'; +import { ScaffolderDryRunResponse } from '@backstage/plugin-scaffolder-common'; +import { ScaffolderScaffoldOptions } from '@backstage/plugin-scaffolder-common'; +import { ScaffolderScaffoldResponse } from '@backstage/plugin-scaffolder-common'; +import { ScaffolderTask } from '@backstage/plugin-scaffolder-common'; +import { ScaffolderTaskStatus } from '@backstage/plugin-scaffolder-common'; +import { ServiceMock } from '@backstage/backend-test-utils'; +import type { TemplateParameterSchema } from '@backstage/plugin-scaffolder-common'; + +// @public +export interface ScaffolderService { + // (undocumented) + autocomplete( + request: { + token: string; + provider: string; + resource: string; + context: Record; + }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ + results: { + title?: string; + id: string; + }[]; + }>; + // (undocumented) + cancelTask( + request: { + taskId: string; + }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ + status?: ScaffolderTaskStatus; + }>; + // (undocumented) + dryRun( + request: ScaffolderDryRunOptions, + options: ScaffolderServiceRequestOptions, + ): Promise; + // (undocumented) + getLogs( + request: { + taskId: string; + after?: number; + }, + options: ScaffolderServiceRequestOptions, + ): Promise; + // (undocumented) + getTask( + request: { + taskId: string; + }, + options: ScaffolderServiceRequestOptions, + ): Promise; + // (undocumented) + getTemplateParameterSchema( + request: { + templateRef: string; + }, + options: ScaffolderServiceRequestOptions, + ): Promise; + // (undocumented) + listActions( + request?: {}, + options?: ScaffolderServiceRequestOptions, + ): Promise; + // (undocumented) + listTasks( + request: { + createdBy?: string; + limit?: number; + offset?: number; + }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ + items: ScaffolderTask[]; + totalItems: number; + }>; + // (undocumented) + listTemplatingExtensions( + request?: {}, + options?: ScaffolderServiceRequestOptions, + ): Promise; + // (undocumented) + retry( + request: { + taskId: string; + }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ + id: string; + }>; + // (undocumented) + scaffold( + request: ScaffolderScaffoldOptions, + options: ScaffolderServiceRequestOptions, + ): Promise; +} + +// @public +export namespace scaffolderServiceMock { + const mock: ( + partialImpl?: Partial | undefined, + ) => ServiceMock; +} + +// @public (undocumented) +export interface ScaffolderServiceRequestOptions { + // (undocumented) + credentials: BackstageCredentials; +} +``` diff --git a/plugins/scaffolder-node/report.api.md b/plugins/scaffolder-node/report.api.md index cbba30dac7..54282f370d 100644 --- a/plugins/scaffolder-node/report.api.md +++ b/plugins/scaffolder-node/report.api.md @@ -9,15 +9,26 @@ import { Expand } from '@backstage/types'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; +import { ListActionsResponse } from '@backstage/plugin-scaffolder-common'; +import { ListTemplatingExtensionsResponse } from '@backstage/plugin-scaffolder-common'; +import { LogEvent } from '@backstage/plugin-scaffolder-common'; import { LoggerService } from '@backstage/backend-plugin-api'; import { Observable } from '@backstage/types'; import { PermissionCriteria } from '@backstage/plugin-permission-common'; +import { ScaffolderDryRunOptions } from '@backstage/plugin-scaffolder-common'; +import { ScaffolderDryRunResponse } from '@backstage/plugin-scaffolder-common'; +import { ScaffolderScaffoldOptions } from '@backstage/plugin-scaffolder-common'; +import { ScaffolderScaffoldResponse } from '@backstage/plugin-scaffolder-common'; +import { ScaffolderTask } from '@backstage/plugin-scaffolder-common'; +import { ScaffolderTaskStatus } from '@backstage/plugin-scaffolder-common'; import { Schema } from 'jsonschema'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { ScmIntegrations } from '@backstage/integration'; +import { ServiceRef } from '@backstage/backend-plugin-api'; import { SpawnOptionsWithoutStdio } from 'node:child_process'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; import { TemplateInfo } from '@backstage/plugin-scaffolder-common'; +import type { TemplateParameterSchema } from '@backstage/plugin-scaffolder-common'; import { UpdateTaskCheckpointOptions } from '@backstage/plugin-scaffolder-node/alpha'; import { UrlReaderService } from '@backstage/backend-plugin-api'; import { UserEntity } from '@backstage/catalog-model'; @@ -316,6 +327,110 @@ export interface ScaffolderActionsExtensionPoint { // @public export const scaffolderActionsExtensionPoint: ExtensionPoint; +// @public +export interface ScaffolderService { + // (undocumented) + autocomplete( + request: { + token: string; + provider: string; + resource: string; + context: Record; + }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ + results: { + title?: string; + id: string; + }[]; + }>; + // (undocumented) + cancelTask( + request: { + taskId: string; + }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ + status?: ScaffolderTaskStatus; + }>; + // (undocumented) + dryRun( + request: ScaffolderDryRunOptions, + options: ScaffolderServiceRequestOptions, + ): Promise; + // (undocumented) + getLogs( + request: { + taskId: string; + after?: number; + }, + options: ScaffolderServiceRequestOptions, + ): Promise; + // (undocumented) + getTask( + request: { + taskId: string; + }, + options: ScaffolderServiceRequestOptions, + ): Promise; + // (undocumented) + getTemplateParameterSchema( + request: { + templateRef: string; + }, + options: ScaffolderServiceRequestOptions, + ): Promise; + // (undocumented) + listActions( + request?: {}, + options?: ScaffolderServiceRequestOptions, + ): Promise; + // (undocumented) + listTasks( + request: { + createdBy?: string; + limit?: number; + offset?: number; + }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ + items: ScaffolderTask[]; + totalItems: number; + }>; + // (undocumented) + listTemplatingExtensions( + request?: {}, + options?: ScaffolderServiceRequestOptions, + ): Promise; + // (undocumented) + retry( + request: { + taskId: string; + }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ + id: string; + }>; + // (undocumented) + scaffold( + request: ScaffolderScaffoldOptions, + options: ScaffolderServiceRequestOptions, + ): Promise; +} + +// @public +export const scaffolderServiceRef: ServiceRef< + ScaffolderService, + 'plugin', + 'singleton' +>; + +// @public (undocumented) +export interface ScaffolderServiceRequestOptions { + // (undocumented) + credentials: BackstageCredentials; +} + // @public (undocumented) export interface SerializedFile { // (undocumented) diff --git a/plugins/scaffolder-node/src/index.ts b/plugins/scaffolder-node/src/index.ts index a9aa77ba4e..5a518c8628 100644 --- a/plugins/scaffolder-node/src/index.ts +++ b/plugins/scaffolder-node/src/index.ts @@ -25,3 +25,4 @@ export * from './tasks'; export * from './files'; export * from './types'; export * from './extensions'; +export * from './scaffolderService'; diff --git a/plugins/scaffolder-node/src/scaffolderService.test.ts b/plugins/scaffolder-node/src/scaffolderService.test.ts new file mode 100644 index 0000000000..de0870751f --- /dev/null +++ b/plugins/scaffolder-node/src/scaffolderService.test.ts @@ -0,0 +1,205 @@ +/* + * 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 { + createBackendModule, + createServiceFactory, + createServiceRef, +} from '@backstage/backend-plugin-api'; +import { + ServiceFactoryTester, + mockCredentials, + mockServices, + registerMswTestHooks, + startTestBackend, +} from '@backstage/backend-test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { scaffolderServiceRef } from './scaffolderService'; + +describe('scaffolderServiceRef', () => { + const server = setupServer(); + registerMswTestHooks(server); + + it('should return a scaffolder service', async () => { + expect.assertions(1); + const testModule = createBackendModule({ + moduleId: 'test', + pluginId: 'test', + register(env) { + env.registerInit({ + deps: { + scaffolder: scaffolderServiceRef, + }, + async init({ scaffolder }) { + expect(scaffolder.getTask).toBeDefined(); + }, + }); + }, + }); + + await startTestBackend({ + features: [testModule], + }); + }); + + it('should inject token from user credentials', async () => { + expect.assertions(1); + + server.use( + rest.get('*/api/scaffolder/v2/tasks/:taskId', (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + mockCredentials.service.header({ + onBehalfOf: mockCredentials.user(), + targetPluginId: 'scaffolder', + }), + ); + return res( + ctx.json({ + id: 'task-1', + spec: {}, + status: 'completed', + createdAt: '2025-01-01T00:00:00Z', + }), + ); + }), + ); + + const tester = ServiceFactoryTester.from( + createServiceFactory({ + service: createServiceRef({ id: 'unused-dummy' }), + deps: {}, + factory() {}, + }), + { dependencies: [mockServices.discovery.factory()] }, + ); + + const scaffolder = await tester.getService(scaffolderServiceRef); + + await scaffolder.getTask( + { taskId: 'task-1' }, + { + credentials: mockCredentials.user(), + }, + ); + }); + + it('should inject token from service credentials', async () => { + expect.assertions(1); + + server.use( + rest.get('*/api/scaffolder/v2/tasks/:taskId', (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + mockCredentials.service.header({ + onBehalfOf: mockCredentials.service(), + targetPluginId: 'scaffolder', + }), + ); + return res( + ctx.json({ + id: 'task-1', + spec: {}, + status: 'completed', + createdAt: '2025-01-01T00:00:00Z', + }), + ); + }), + ); + + const tester = ServiceFactoryTester.from( + createServiceFactory({ + service: createServiceRef({ id: 'unused-dummy' }), + deps: {}, + factory() {}, + }), + { dependencies: [mockServices.discovery.factory()] }, + ); + + const scaffolder = await tester.getService(scaffolderServiceRef); + + await scaffolder.getTask( + { taskId: 'task-1' }, + { + credentials: mockCredentials.service(), + }, + ); + }); + + it('should pass credentials for direct HTTP calls like getLogs', async () => { + expect.assertions(1); + + server.use( + rest.get('*/api/scaffolder/v2/tasks/:taskId/events', (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + mockCredentials.service.header({ + onBehalfOf: mockCredentials.user(), + targetPluginId: 'scaffolder', + }), + ); + return res(ctx.json([])); + }), + ); + + const tester = ServiceFactoryTester.from( + createServiceFactory({ + service: createServiceRef({ id: 'unused-dummy' }), + deps: {}, + factory() {}, + }), + { dependencies: [mockServices.discovery.factory()] }, + ); + + const scaffolder = await tester.getService(scaffolderServiceRef); + + await scaffolder.getLogs( + { taskId: 'task-1' }, + { credentials: mockCredentials.user() }, + ); + }); + + it('should pass createdBy and pagination params for listTasks', async () => { + expect.assertions(4); + + server.use( + rest.get('*/api/scaffolder/v2/tasks', (req, res, ctx) => { + expect(req.url.searchParams.get('createdBy')).toBe( + 'user:default/guest', + ); + expect(req.url.searchParams.get('limit')).toBe('10'); + expect(req.url.searchParams.get('offset')).toBe('5'); + return res(ctx.json({ tasks: [], totalTasks: 0 })); + }), + ); + + const tester = ServiceFactoryTester.from( + createServiceFactory({ + service: createServiceRef({ id: 'unused-dummy' }), + deps: {}, + factory() {}, + }), + { dependencies: [mockServices.discovery.factory()] }, + ); + + const scaffolder = await tester.getService(scaffolderServiceRef); + + const result = await scaffolder.listTasks( + { createdBy: 'user:default/guest', limit: 10, offset: 5 }, + { credentials: mockCredentials.user() }, + ); + + expect(result).toEqual({ items: [], totalItems: 0 }); + }); +}); diff --git a/plugins/scaffolder-node/src/scaffolderService.ts b/plugins/scaffolder-node/src/scaffolderService.ts new file mode 100644 index 0000000000..b978164c7f --- /dev/null +++ b/plugins/scaffolder-node/src/scaffolderService.ts @@ -0,0 +1,338 @@ +/* + * 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 { + AuthService, + BackstageCredentials, + coreServices, + createServiceFactory, + createServiceRef, + DiscoveryService, +} from '@backstage/backend-plugin-api'; +import { ResponseError } from '@backstage/errors'; +import { ScmIntegrations } from '@backstage/integration'; +import { + ListActionsResponse, + ListTemplatingExtensionsResponse, + LogEvent, + ScaffolderClient, + ScaffolderDryRunOptions, + ScaffolderDryRunResponse, + ScaffolderRequestOptions, + ScaffolderScaffoldOptions, + ScaffolderScaffoldResponse, + ScaffolderTask, + ScaffolderTaskStatus, +} from '@backstage/plugin-scaffolder-common'; +import type { TemplateParameterSchema } from '@backstage/plugin-scaffolder-common'; + +/** + * @public + */ +export interface ScaffolderServiceRequestOptions { + credentials: BackstageCredentials; +} + +/** + * A backend service interface for the scaffolder that uses + * {@link @backstage/backend-plugin-api#BackstageCredentials} instead of tokens. + * + * @public + */ +export interface ScaffolderService { + getTemplateParameterSchema( + request: { templateRef: string }, + options: ScaffolderServiceRequestOptions, + ): Promise; + + scaffold( + request: ScaffolderScaffoldOptions, + options: ScaffolderServiceRequestOptions, + ): Promise; + + getTask( + request: { taskId: string }, + options: ScaffolderServiceRequestOptions, + ): Promise; + + cancelTask( + request: { taskId: string }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ status?: ScaffolderTaskStatus }>; + + retry( + request: { taskId: string }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ id: string }>; + + listTasks( + request: { + createdBy?: string; + limit?: number; + offset?: number; + }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ items: ScaffolderTask[]; totalItems: number }>; + + listActions( + request?: {}, + options?: ScaffolderServiceRequestOptions, + ): Promise; + + listTemplatingExtensions( + request?: {}, + options?: ScaffolderServiceRequestOptions, + ): Promise; + + getLogs( + request: { + taskId: string; + after?: number; + }, + options: ScaffolderServiceRequestOptions, + ): Promise; + + dryRun( + request: ScaffolderDryRunOptions, + options: ScaffolderServiceRequestOptions, + ): Promise; + + autocomplete( + request: { + token: string; + provider: string; + resource: string; + context: Record; + }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ results: { title?: string; id: string }[] }>; +} + +class DefaultScaffolderService implements ScaffolderService { + readonly #auth: AuthService; + readonly #client: ScaffolderClient; + readonly #discovery: DiscoveryService; + + constructor(options: { + auth: AuthService; + client: ScaffolderClient; + discovery: DiscoveryService; + }) { + this.#auth = options.auth; + this.#client = options.client; + this.#discovery = options.discovery; + } + + async getTemplateParameterSchema( + request: { templateRef: string }, + options: ScaffolderServiceRequestOptions, + ): Promise { + return this.#client.getTemplateParameterSchema( + request.templateRef, + await this.#getOptions(options), + ); + } + + async scaffold( + request: ScaffolderScaffoldOptions, + options: ScaffolderServiceRequestOptions, + ): Promise { + return this.#client.scaffold(request, await this.#getOptions(options)); + } + + async getTask( + request: { taskId: string }, + options: ScaffolderServiceRequestOptions, + ): Promise { + return this.#client.getTask( + request.taskId, + await this.#getOptions(options), + ); + } + + async cancelTask( + request: { taskId: string }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ status?: ScaffolderTaskStatus }> { + return this.#client.cancelTask( + request.taskId, + await this.#getOptions(options), + ); + } + + async retry( + request: { taskId: string }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ id: string }> { + return this.#client.retry(request.taskId, await this.#getOptions(options)); + } + + async listTasks( + request: { + createdBy?: string; + limit?: number; + offset?: number; + }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ items: ScaffolderTask[]; totalItems: number }> { + const { token } = await this.#getOptions(options); + const baseUrl = await this.#discovery.getBaseUrl('scaffolder'); + + const params = new URLSearchParams(); + if (request.createdBy) { + params.set('createdBy', request.createdBy); + } + if (request.limit !== undefined) { + params.set('limit', String(request.limit)); + } + if (request.offset !== undefined) { + params.set('offset', String(request.offset)); + } + + const query = params.toString(); + const url = `${baseUrl}/v2/tasks${query ? `?${query}` : ''}`; + + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), + }, + }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + const body = await response.json(); + return { + items: body.tasks, + totalItems: body.totalTasks ?? 0, + }; + } + + async listActions( + _request?: {}, + options?: ScaffolderServiceRequestOptions, + ): Promise { + return this.#client.listActions( + options ? await this.#getOptions(options) : {}, + ); + } + + async listTemplatingExtensions( + _request?: {}, + options?: ScaffolderServiceRequestOptions, + ): Promise { + return this.#client.listTemplatingExtensions( + options ? await this.#getOptions(options) : {}, + ); + } + + async getLogs( + request: { + taskId: string; + after?: number; + }, + options: ScaffolderServiceRequestOptions, + ): Promise { + const { token } = await this.#getOptions(options); + const baseUrl = await this.#discovery.getBaseUrl('scaffolder'); + + const params = new URLSearchParams(); + if (request.after !== undefined) { + params.set('after', String(request.after)); + } + + const query = params.toString(); + const taskId = encodeURIComponent(request.taskId); + const url = `${baseUrl}/v2/tasks/${taskId}/events${ + query ? `?${query}` : '' + }`; + + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), + }, + }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return response.json(); + } + + async dryRun( + request: ScaffolderDryRunOptions, + options: ScaffolderServiceRequestOptions, + ): Promise { + return this.#client.dryRun(request, await this.#getOptions(options)); + } + + async autocomplete( + request: { + token: string; + provider: string; + resource: string; + context: Record; + }, + options: ScaffolderServiceRequestOptions, + ): Promise<{ results: { title?: string; id: string }[] }> { + return this.#client.autocomplete(request, await this.#getOptions(options)); + } + + async #getOptions( + options: ScaffolderServiceRequestOptions, + ): Promise { + return this.#auth.getPluginRequestToken({ + onBehalfOf: options.credentials, + targetPluginId: 'scaffolder', + }); + } +} + +/** + * A service ref for the scaffolder client, to be used by backend plugins + * and modules that need to interact with the scaffolder API. + * + * @public + */ +export const scaffolderServiceRef = createServiceRef({ + id: 'scaffolder-client', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + auth: coreServices.auth, + discovery: coreServices.discovery, + config: coreServices.rootConfig, + }, + async factory({ auth, discovery, config }) { + const integrations = ScmIntegrations.fromConfig(config); + const client = new ScaffolderClient({ + discoveryApi: discovery, + fetchApi: { fetch }, + scmIntegrationsApi: integrations, + }); + return new DefaultScaffolderService({ + auth, + client, + discovery, + }); + }, + }), +}); diff --git a/plugins/scaffolder-node/src/testUtils.ts b/plugins/scaffolder-node/src/testUtils.ts new file mode 100644 index 0000000000..7b1c7fb61d --- /dev/null +++ b/plugins/scaffolder-node/src/testUtils.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +/** + * Backend test helpers for the Scaffolder plugin. + * + * @packageDocumentation + */ + +export type { + ScaffolderService, + ScaffolderServiceRequestOptions, +} from './scaffolderService'; +export { scaffolderServiceMock } from './testUtils/scaffolderServiceMock'; diff --git a/plugins/scaffolder-node/src/testUtils/scaffolderServiceMock.test.ts b/plugins/scaffolder-node/src/testUtils/scaffolderServiceMock.test.ts new file mode 100644 index 0000000000..7145d12e2a --- /dev/null +++ b/plugins/scaffolder-node/src/testUtils/scaffolderServiceMock.test.ts @@ -0,0 +1,35 @@ +/* + * 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 { scaffolderServiceMock } from './scaffolderServiceMock'; + +describe('scaffolderServiceMock', () => { + it('creates a mock with all methods as jest.fn()', () => { + const mock = scaffolderServiceMock.mock(); + + expect(mock.getTask).toHaveBeenCalledTimes(0); + }); + + it('supports overriding individual methods', async () => { + const mock = scaffolderServiceMock.mock({ + getTask: jest.fn().mockResolvedValue({ id: 'task-1' }), + }); + + await expect( + mock.getTask({ taskId: 'task-1' }, { credentials: expect.anything() }), + ).resolves.toEqual(expect.objectContaining({ id: 'task-1' })); + }); +}); diff --git a/plugins/scaffolder-node/src/testUtils/scaffolderServiceMock.ts b/plugins/scaffolder-node/src/testUtils/scaffolderServiceMock.ts new file mode 100644 index 0000000000..7b18c01298 --- /dev/null +++ b/plugins/scaffolder-node/src/testUtils/scaffolderServiceMock.ts @@ -0,0 +1,46 @@ +/* + * 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 { createServiceMock } from '@backstage/backend-test-utils'; +import { scaffolderServiceRef, ScaffolderService } from '../scaffolderService'; + +/** + * A collection of mock functionality for the scaffolder service. + * + * @public + */ +export namespace scaffolderServiceMock { + /** + * Creates a scaffolder service whose methods are mock functions, possibly with + * some of them overloaded by the caller. + */ + export const mock = createServiceMock( + scaffolderServiceRef, + () => ({ + getTemplateParameterSchema: jest.fn(), + scaffold: jest.fn(), + getTask: jest.fn(), + cancelTask: jest.fn(), + retry: jest.fn(), + listTasks: jest.fn(), + listActions: jest.fn(), + listTemplatingExtensions: jest.fn(), + getLogs: jest.fn(), + dryRun: jest.fn(), + autocomplete: jest.fn(), + }), + ); +} diff --git a/plugins/scaffolder-react/package.json b/plugins/scaffolder-react/package.json index 8738088664..f09310ea5e 100644 --- a/plugins/scaffolder-react/package.json +++ b/plugins/scaffolder-react/package.json @@ -31,6 +31,7 @@ "exports": { ".": "./src/index.ts", "./alpha": "./src/alpha.ts", + "./testUtils": "./src/testUtils.ts", "./package.json": "./package.json" }, "main": "src/index.ts", @@ -40,6 +41,9 @@ "alpha": [ "src/alpha.ts" ], + "testUtils": [ + "src/testUtils.ts" + ], "package.json": [ "package.json" ] @@ -115,12 +119,16 @@ "swr": "^2.0.0" }, "peerDependencies": { + "@backstage/frontend-test-utils": "workspace:^", "@types/react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0", "react-router-dom": "^6.30.2" }, "peerDependenciesMeta": { + "@backstage/frontend-test-utils": { + "optional": true + }, "@types/react": { "optional": true } diff --git a/plugins/scaffolder-react/report-testUtils.api.md b/plugins/scaffolder-react/report-testUtils.api.md new file mode 100644 index 0000000000..c33675d93d --- /dev/null +++ b/plugins/scaffolder-react/report-testUtils.api.md @@ -0,0 +1,15 @@ +## API Report File for "@backstage/plugin-scaffolder-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ApiMock } from '@backstage/frontend-test-utils'; +import { ScaffolderApi } from '@backstage/plugin-scaffolder-common'; + +// @public +export namespace scaffolderApiMock { + const mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; +} +``` diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx index b3dc78dba5..96ca2217b9 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx @@ -37,6 +37,9 @@ const scaffolderApiMock: jest.Mocked = { listActions: jest.fn(), listTasks: jest.fn(), autocomplete: jest.fn(), + retry: jest.fn(), + listTemplatingExtensions: jest.fn(), + dryRun: jest.fn(), }; const catalogApi = catalogApiMock.mock(); diff --git a/plugins/scaffolder-react/src/testUtils.ts b/plugins/scaffolder-react/src/testUtils.ts new file mode 100644 index 0000000000..bc94b5447e --- /dev/null +++ b/plugins/scaffolder-react/src/testUtils.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * Frontend test helpers for the Scaffolder plugin. + * + * @packageDocumentation + */ + +export { scaffolderApiMock } from './testUtils/scaffolderApiMock'; diff --git a/plugins/scaffolder-react/src/testUtils/scaffolderApiMock.test.ts b/plugins/scaffolder-react/src/testUtils/scaffolderApiMock.test.ts new file mode 100644 index 0000000000..4d2ae09d35 --- /dev/null +++ b/plugins/scaffolder-react/src/testUtils/scaffolderApiMock.test.ts @@ -0,0 +1,37 @@ +/* + * 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 { scaffolderApiMock } from './scaffolderApiMock'; + +describe('scaffolderApiMock', () => { + it('creates a mock with all methods as jest.fn()', () => { + const mock = scaffolderApiMock.mock(); + + expect(mock.getTask).toHaveBeenCalledTimes(0); + expect(mock.scaffold).toHaveBeenCalledTimes(0); + expect(mock.listActions).toHaveBeenCalledTimes(0); + }); + + it('supports overriding individual methods', async () => { + const mock = scaffolderApiMock.mock({ + getTask: jest.fn().mockResolvedValue({ id: 'task-1' }), + }); + + await expect(mock.getTask('task-1')).resolves.toEqual( + expect.objectContaining({ id: 'task-1' }), + ); + }); +}); diff --git a/plugins/scaffolder-react/src/testUtils/scaffolderApiMock.ts b/plugins/scaffolder-react/src/testUtils/scaffolderApiMock.ts new file mode 100644 index 0000000000..5103301079 --- /dev/null +++ b/plugins/scaffolder-react/src/testUtils/scaffolderApiMock.ts @@ -0,0 +1,44 @@ +/* + * 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 { createApiMock } from '@backstage/frontend-test-utils'; +import { scaffolderApiRef } from '../api/ref'; + +/** + * A collection of mock functionality for the scaffolder API. + * + * @public + */ +export namespace scaffolderApiMock { + /** + * Creates a scaffolder API whose methods are mock functions, possibly with + * some of them overloaded by the caller. + */ + export const mock = createApiMock(scaffolderApiRef, () => ({ + getTemplateParameterSchema: jest.fn(), + scaffold: jest.fn(), + getTask: jest.fn(), + cancelTask: jest.fn(), + retry: jest.fn(), + listTasks: jest.fn(), + getIntegrationsList: jest.fn(), + listActions: jest.fn(), + listTemplatingExtensions: jest.fn(), + streamLogs: jest.fn(), + dryRun: jest.fn(), + autocomplete: jest.fn(), + })); +} diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.test.tsx index 016d775c43..883aab8754 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.test.tsx @@ -38,6 +38,9 @@ describe('TemplateEditorToolbar', () => { listActions: jest.fn(), listTasks: jest.fn(), autocomplete: jest.fn(), + retry: jest.fn(), + listTemplatingExtensions: jest.fn(), + dryRun: jest.fn(), }; scaffolderApiMock.listActions.mockResolvedValue([ diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx index 6836259dd8..b8d750c419 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx @@ -54,6 +54,9 @@ const scaffolderApiMock: jest.Mocked = { listActions: jest.fn(), listTasks: jest.fn(), autocomplete: jest.fn(), + retry: jest.fn(), + listTemplatingExtensions: jest.fn(), + dryRun: jest.fn(), }; const scaffolderDecoratorsMock: jest.Mocked = { diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index a86b44e724..201073e8a9 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -34,6 +34,9 @@ const scaffolderApiMock: jest.Mocked = { listActions: jest.fn(), listTasks: jest.fn(), autocomplete: jest.fn(), + retry: jest.fn(), + listTemplatingExtensions: jest.fn(), + dryRun: jest.fn(), }; const mockPermissionApi = { authorize: jest.fn() }; diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.test.tsx b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.test.tsx index 2c0680b5f8..e125c96c98 100644 --- a/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.test.tsx @@ -40,6 +40,8 @@ const scaffolderApiMock: jest.Mocked = { listTemplatingExtensions, listTasks: jest.fn(), autocomplete: jest.fn(), + retry: jest.fn(), + dryRun: jest.fn(), }; const mockPermissionApi = { authorize: jest.fn() }; diff --git a/yarn.lock b/yarn.lock index 2c84d37780..3f6c5e083f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7104,12 +7104,18 @@ __metadata: isomorphic-git: "npm:^1.23.0" jsonschema: "npm:^1.5.0" lodash: "npm:^4.17.21" + msw: "npm:^1.0.0" p-limit: "npm:^3.1.0" tar: "npm:^7.5.6" winston: "npm:^3.2.1" winston-transport: "npm:^4.7.0" zod: "npm:^3.25.76" zod-to-json-schema: "npm:^3.25.1" + peerDependencies: + "@backstage/backend-test-utils": "workspace:^" + peerDependenciesMeta: + "@backstage/backend-test-utils": + optional: true languageName: unknown linkType: soft @@ -7171,11 +7177,14 @@ __metadata: zod: "npm:^3.25.76" zod-to-json-schema: "npm:^3.25.1" peerDependencies: + "@backstage/frontend-test-utils": "workspace:^" "@types/react": ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 react-router-dom: ^6.30.2 peerDependenciesMeta: + "@backstage/frontend-test-utils": + optional: true "@types/react": optional: true languageName: unknown