diff --git a/.changeset/beige-rats-cheer.md b/.changeset/beige-rats-cheer.md new file mode 100644 index 0000000000..c523ffa104 --- /dev/null +++ b/.changeset/beige-rats-cheer.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': patch +--- + +Added `RootLifecycleService` and `rootLifecycleServiceRef`, as well as added a `labels` option to the existing `LifecycleServiceShutdownHook`. diff --git a/.changeset/blue-carrots-cross.md b/.changeset/blue-carrots-cross.md new file mode 100644 index 0000000000..fdf91e673c --- /dev/null +++ b/.changeset/blue-carrots-cross.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Removed unnecessary usage of `ThemeProvider` from the `ExampleComponent` test in the plugin template. diff --git a/.changeset/clever-years-hang.md b/.changeset/clever-years-hang.md new file mode 100644 index 0000000000..31db564c52 --- /dev/null +++ b/.changeset/clever-years-hang.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-test-utils': patch +'@backstage/backend-defaults': patch +--- + +Include implementations for the new `rootLifecycleServiceRef`. diff --git a/.changeset/cool-kings-raise.md b/.changeset/cool-kings-raise.md new file mode 100644 index 0000000000..18344b128c --- /dev/null +++ b/.changeset/cool-kings-raise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-bazaar': patch +--- + +Added `isBazaarAvailable` helper to be used with the `EntitySwitch`. diff --git a/.changeset/eleven-trains-double.md b/.changeset/eleven-trains-double.md new file mode 100644 index 0000000000..ec07372678 --- /dev/null +++ b/.changeset/eleven-trains-double.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-graph': patch +--- + +The link from the `CatalogGraphCard` to the `CatalogGraphPage` no longer includes an explicit `maxDepth` parameter, letting the `CatalogGraphPage` choose the initial `maxDepth` instead. diff --git a/.changeset/famous-experts-sneeze.md b/.changeset/famous-experts-sneeze.md new file mode 100644 index 0000000000..a6f2762dba --- /dev/null +++ b/.changeset/famous-experts-sneeze.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-common': minor +--- + +**BREAKING**: Removed deprecated `read` method from the `UrlReader` interface. All implementations should use the `readUrl` method instead. + +Migrated `UrlReader` and related types to `backend/backend-plugin-api`, types remain re-exported from `backend-common` for now. diff --git a/.changeset/good-foxes-fail.md b/.changeset/good-foxes-fail.md new file mode 100644 index 0000000000..5d31161e64 --- /dev/null +++ b/.changeset/good-foxes-fail.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-catalog-backend-module-github': patch +--- + +Added support for event based updates in the `GithubOrgEntityProvider`! +Based on webhook events from GitHub the affected `User` or `Group` entity will be refreshed. +This includes adding new entities, refreshing existing ones, and removing obsolete ones. + +Please find more information at +https://backstage.io/docs/integrations/github/org#installation-with-events-support diff --git a/.changeset/khaki-baboons-impress.md b/.changeset/khaki-baboons-impress.md new file mode 100644 index 0000000000..4fbcb17a3a --- /dev/null +++ b/.changeset/khaki-baboons-impress.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': patch +--- + +Migrate `UrlReader` into this package to gradually remove the dependency on backend-common. diff --git a/.changeset/late-forks-rescue.md b/.changeset/late-forks-rescue.md new file mode 100644 index 0000000000..294412a711 --- /dev/null +++ b/.changeset/late-forks-rescue.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-explore-backend': patch +'@backstage/plugin-explore': patch +--- + +Updated `README.md` examples diff --git a/.changeset/nasty-planets-end.md b/.changeset/nasty-planets-end.md new file mode 100644 index 0000000000..b361b28199 --- /dev/null +++ b/.changeset/nasty-planets-end.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-events-backend': patch +--- + +The default event broker will now catch and log errors thrown by the `onEvent` method of subscribers. The returned promise from `publish` method will also not resolve until all subscribers have handled the event. diff --git a/.changeset/ninety-phones-tell.md b/.changeset/ninety-phones-tell.md new file mode 100644 index 0000000000..692ed27c23 --- /dev/null +++ b/.changeset/ninety-phones-tell.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app-backend': patch +--- + +The warning for missing app contents is now logged as an error instead, but only in production. diff --git a/.changeset/rude-chicken-itch.md b/.changeset/rude-chicken-itch.md new file mode 100644 index 0000000000..bf22d63921 --- /dev/null +++ b/.changeset/rude-chicken-itch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-openapi': patch +--- + +Updated internal usage of UrlReader interface. diff --git a/.changeset/tidy-flies-cheer.md b/.changeset/tidy-flies-cheer.md new file mode 100644 index 0000000000..78de902e63 --- /dev/null +++ b/.changeset/tidy-flies-cheer.md @@ -0,0 +1,7 @@ +--- +'@backstage/config': patch +--- + +Adds the ability to coerce values to their boolean representatives. +Values such as `"true"` `1` `on` and `y` will become `true` when using `getBoolean` and the opposites `false`. +This happens particularly when such parameters are used with environmental substitution as environment variables are always strings. diff --git a/.changeset/twenty-nails-camp.md b/.changeset/twenty-nails-camp.md new file mode 100644 index 0000000000..3a8e2e4c3f --- /dev/null +++ b/.changeset/twenty-nails-camp.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Updated implementations for the new `RootLifecycleService`. diff --git a/ADOPTERS.md b/ADOPTERS.md index 9ee67e9bed..efa0003f2d 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -224,4 +224,4 @@ _You can do this by using the [Adopter form](https://info.backstage.spotify.com/ | [FanDuel](https://fanduel.com) | [Diego Herrera](https://github.com/diegoh), [Christy Campbell](https://github.com/FD-ChristopherCampbell) | We use backstage as our developer portal to provide visibility of our software, ownership, strategy, and the state of maturity across disciplines. | | [Operate First](https://www.operate-first.cloud/) | [Tom Coufal](https://github.com/tumido), [Sam Kopecky](https://github.com/samokopecky) | Backstage provides us with a public service catalog and serves as a gateway to our community cloud. Our instance is publicly available to everyone [here](https://service-catalog.operate-first.cloud/) ([source](https://github.com/operate-first/service-catalog)) | | [Tractable AI](https://tractable.ai/) | [Stephan Schielke](https://github.com/stephanschielke) | We are hitting a critical point in our scale (100+ engineers) and need to get a handle on discoverability and ownership. The Service Catalog, TechDocs and Search are essential to us to achieve that. | -| [Garanti BBVA](https://www.garantibbva.com.tr/) | [Caglar Cataloglu](https://github.com/crozwise) | We are using Backstage focusing on improving experience of developer, minimizing friction from idea to code. We call our portal as "Hyperspace" and very excited for our community (2000+ engineers) that finally we have a platform to boost our productivity! +| [Garanti BBVA Teknoloji](https://www.linkedin.com/company/garanti-teknoloji/) | [Caglar Cataloglu](https://github.com/crozwise) | We are using Backstage focusing on improving experience of developers, minimizing friction from idea to production. We call our portal as "Hyperspace" and very excited for our community (2000+ engineers) that finally we have a platform to boost our productivity! diff --git a/docs/features/software-catalog/external-integrations.md b/docs/features/software-catalog/external-integrations.md index 66813cc4d2..da4eb3d229 100644 --- a/docs/features/software-catalog/external-integrations.md +++ b/docs/features/software-catalog/external-integrations.md @@ -111,10 +111,10 @@ export class FrobsProvider implements EntityProvider { throw new Error('Not initialized'); } - const raw = await this.reader.read( + const response = await this.reader.readUrl( `https://frobs-${this.env}.example.com/data`, ); - const data = JSON.parse(raw.toString()); + const data = JSON.parse(await response.buffer()).toString(); /** [5] **/ const entities: Entity[] = frobsToEntities(data); @@ -525,8 +525,8 @@ export class SystemXReaderProcessor implements CatalogProcessor { // API. If you prefer, you can just use plain fetch here // (from the node-fetch package), or any other method of // your choosing. - const data = await this.reader.read(location.target); - const json = JSON.parse(data.toString()); + const response = await this.reader.readUrl(location.target); + const json = JSON.parse((await response.buffer()).toString()); // Repeatedly call emit(processingResult.entity(location, )) } catch (error) { const message = `Unable to read ${location.type}, ${error}`; @@ -627,7 +627,7 @@ export class SystemXReaderProcessor implements CatalogProcessor { // We send the ETag from the previous run if it exists. // The previous ETag will be set in the headers for the outgoing request and system-x // is going to throw NOT_MODIFIED (HTTP 304) if the ETag matches. - const response = await this.reader.readUrl?.(location.target, { + const response = await this.reader.readUrl(location.target, { etag: cacheItem?.etag, }); if (!response) { diff --git a/docs/integrations/github/org.md b/docs/integrations/github/org.md index 9e3c450adc..0aa9997bae 100644 --- a/docs/integrations/github/org.md +++ b/docs/integrations/github/org.md @@ -17,7 +17,7 @@ entities that mirror your org setup. > provide authentication. See the > [GitHub auth provider](../../auth/github/provider.md) for that. -## Installation +## Installation without Events Support This guide will use the Entity Provider method. If you for some reason prefer the Processor method (not recommended), it is described separately below. @@ -60,6 +60,52 @@ schedule it: + ); ``` +## Installation with Events Support + +Please follow the installation instructions at + +- https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md +- https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-github/README.md + +Additionally, you need to decide how you want to receive events from external sources like + +- [via HTTP endpoint](https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md) +- [via an AWS SQS queue](https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-aws-sqs/README.md) + +Set up your provider + +```diff +// packages/backend/src/plugins/catalogEventBasedProviders.ts ++import { GithubOrgEntityProvider } from '@backstage/plugin-catalog-backend-module-github'; + import { EntityProvider } from '@backstage/plugin-catalog-node'; + import { EventSubscriber } from '@backstage/plugin-events-node'; + import { PluginEnvironment } from '../types'; + export default async function createCatalogEventBasedProviders( +- _: PluginEnvironment, ++ env: PluginEnvironment, + ): Promise> { + const providers: Array< + (EntityProvider & EventSubscriber) | Array + > = []; +- // add your event-based entity providers here ++ providers.push( ++ GithubOrgEntityProvider.fromConfig(env.config, { ++ id: 'production', ++ orgUrl: 'https://github.com/backstage', ++ logger: env.logger, ++ schedule: env.scheduler.createScheduledTaskRunner({ ++ frequency: { minutes: 60 }, ++ timeout: { minutes: 15 }, ++ }), ++ }), ++ ); + return providers.flat(); + } +``` + +You can check the official docs to [configure your webhook](https://docs.github.com/en/developers/webhooks-and-events/webhooks/creating-webhooks) and to [secure your request](https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks). +The webhook will need to be configured to forward `organization`,`team` and `membership` events. + ## Configuration As mentioned above, you also must have some configuration in your app-config diff --git a/docs/overview/versioning-policy.md b/docs/overview/versioning-policy.md index ad5421585e..fdb3a974b1 100644 --- a/docs/overview/versioning-policy.md +++ b/docs/overview/versioning-policy.md @@ -116,9 +116,8 @@ For packages at version `1.0.0` or above, the following policy also applies: before it can be removed. - The release of breaking changes document a clear upgrade path in the changelog, both when deprecations are introduced and when they are removed. -- Exports that have been marked as `@alpha` or `@beta` may receive breaking - changes without a deprecation period, but the changes must still adhere to - semver. +- Breaking changes to `@alpha` or `@beta` exports must result in at least a minor + version bump, and may be done without a deprecation period. ### Changes that are Not Considered Breaking diff --git a/docs/plugins/url-reader.md b/docs/plugins/url-reader.md index 815674091b..595c87ab94 100644 --- a/docs/plugins/url-reader.md +++ b/docs/plugins/url-reader.md @@ -60,15 +60,7 @@ The generic interface of a URL Reader instance looks like this. ```ts export type UrlReader = { /* Used to read a single file and return its content. */ - read(url: string): Promise; - /** - * A replacement for the read method that supports options and complex responses. - * - * Use this whenever it is available, as the read method will be deprecated and - * eventually removed in the future. - */ - readUrl?(url: string, options?: ReadUrlOptions): Promise; - + readUrl(url: string, options?: ReadUrlOptions): Promise; /* Used to read a file tree and download as a directory. */ readTree(url: string, options?: ReadTreeOptions): Promise; /* Used to search a file in a tree. */ @@ -102,8 +94,8 @@ backend plugins. Once the reader instance is available inside the plugin, one of its methods can directly be used with a URL. Some example usages - -- [`read`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts#L24-L33) - - Catalog using the `read` method to read the CODEOWNERS file in a repository. +- [`readUrl`](https://github.com/backstage/backstage/blob/a7607b5/plugins/catalog-backend/src/modules/codeowners/lib/read.ts#L24-L33) - + Catalog using the `readUrl` method to read the CODEOWNERS file in a repository. - [`readTree`](https://github.com/backstage/backstage/blob/84a8788/plugins/techdocs-node/src/helpers.ts#L146-L167) - TechDocs using the `readTree` method to download markdown files in order to generate the documentation site. @@ -155,11 +147,9 @@ all the methods of the `UrlReader` interface should be implemented. However it is okay to start by implementing just one of them and create issues for the remaining. -#### read +#### `readUrl` -NOTE: Use `readUrl` instead of `read`. - -`read` method expects a user-friendly URL, something which can be copied from +`readUrl` method expects a user-friendly URL, something which can be copied from the browser naturally when a person is browsing the provider in their browser. - ✅ Valid URL : @@ -168,18 +158,10 @@ the browser naturally when a person is browsing the provider in their browser. `https://raw.githubusercontent.com/backstage/backstage/master/ADOPTERS.md` - ❌ Not a valid URL : `https://github.com/backstage/backstage/ADOPTERS.md` -Upon receiving the URL, `read` converts the user-friendly URL into an API URL +Upon receiving the URL, `readUrl` converts the user-friendly URL into an API URL which can be used to request the provider's API. -`read` then makes an authenticated request to the provider API and returns the -file's content. - -#### `readUrl` - -`readUrl` is a new interface that allows complex response objects and is -intended to replace the `read` method. This new method is currently optional to -implement which allows for a soft migration to `readUrl` instead of `read` in -the future. +`readUrl` then makes an authenticated request to the provider API and returns the response containing the file's contents and ETag(if the provider supports it). #### `readTree` diff --git a/microsite-next/yarn.lock b/microsite-next/yarn.lock index c7dcd41c28..3a632b3079 100644 --- a/microsite-next/yarn.lock +++ b/microsite-next/yarn.lock @@ -6749,11 +6749,11 @@ __metadata: linkType: hard "json5@npm:^2.1.2, json5@npm:^2.2.1": - version: 2.2.1 - resolution: "json5@npm:2.2.1" + version: 2.2.3 + resolution: "json5@npm:2.2.3" bin: json5: lib/cli.js - checksum: 74b8a23b102a6f2bf2d224797ae553a75488b5adbaee9c9b6e5ab8b510a2fc6e38f876d4c77dea672d4014a44b2399e15f2051ac2b37b87f74c0c7602003543b + checksum: 2a7436a93393830bce797d4626275152e37e877b265e94ca69c99e3d20c2b9dab021279146a39cdb700e71b2dd32a4cebd1514cd57cee102b1af906ce5040349 languageName: node linkType: hard diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md index 33e732cf05..ef88d9a9d0 100644 --- a/packages/backend-app-api/api-report.md +++ b/packages/backend-app-api/api-report.md @@ -83,6 +83,11 @@ export const permissionsFactory: ( options?: undefined, ) => ServiceFactory; +// @public +export const rootLifecycleFactory: ( + options?: undefined, +) => ServiceFactory; + // @public (undocumented) export const rootLoggerFactory: ( options?: undefined, diff --git a/packages/backend-app-api/src/services/implementations/index.ts b/packages/backend-app-api/src/services/implementations/index.ts index 2c26097f48..c87011d570 100644 --- a/packages/backend-app-api/src/services/implementations/index.ts +++ b/packages/backend-app-api/src/services/implementations/index.ts @@ -26,4 +26,5 @@ export { tokenManagerFactory } from './tokenManagerService'; export { urlReaderFactory } from './urlReaderService'; export { httpRouterFactory } from './httpRouterService'; export { lifecycleFactory } from './lifecycleService'; +export { rootLifecycleFactory } from './rootLifecycleService'; export type { HttpRouterFactoryOptions } from './httpRouterService'; diff --git a/packages/backend-app-api/src/services/implementations/lifecycleService.ts b/packages/backend-app-api/src/services/implementations/lifecycleService.ts index 317a2e45b8..bb864679b8 100644 --- a/packages/backend-app-api/src/services/implementations/lifecycleService.ts +++ b/packages/backend-app-api/src/services/implementations/lifecycleService.ts @@ -14,65 +14,10 @@ * limitations under the License. */ import { - LifecycleService, createServiceFactory, coreServices, - loggerToWinstonLogger, LifecycleServiceShutdownHook, } from '@backstage/backend-plugin-api'; -import { Logger } from 'winston'; - -const CALLBACKS = ['SIGTERM', 'SIGINT', 'beforeExit']; -export class BackendLifecycleImpl { - constructor(private readonly logger: Logger) { - CALLBACKS.map(signal => process.on(signal, () => this.shutdown())); - } - - #isCalled = false; - #shutdownTasks: Array = - []; - - addShutdownHook( - options: LifecycleServiceShutdownHook & { pluginId: string }, - ): void { - this.#shutdownTasks.push(options); - } - - async shutdown(): Promise { - if (this.#isCalled) { - return; - } - this.#isCalled = true; - - this.logger.info(`Running ${this.#shutdownTasks.length} shutdown tasks...`); - await Promise.all( - this.#shutdownTasks.map(hook => - Promise.resolve() - .then(() => hook.fn()) - .catch(e => { - this.logger.error( - `Shutdown hook registered by plugin '${hook.pluginId}' failed with: ${e}`, - ); - }) - .then(() => - this.logger.info( - `Successfully ran shutdown hook registered by plugin ${hook.pluginId}`, - ), - ), - ), - ); - } -} - -class PluginScopedLifecycleImpl implements LifecycleService { - constructor( - private readonly lifecycle: BackendLifecycleImpl, - private readonly pluginId: string, - ) {} - addShutdownHook(options: LifecycleServiceShutdownHook): void { - this.lifecycle.addShutdownHook({ ...options, pluginId: this.pluginId }); - } -} /** * Allows plugins to register shutdown hooks that are run when the process is about to exit. @@ -80,15 +25,20 @@ class PluginScopedLifecycleImpl implements LifecycleService { export const lifecycleFactory = createServiceFactory({ service: coreServices.lifecycle, deps: { - logger: coreServices.rootLogger, - plugin: coreServices.pluginMetadata, + rootLifecycle: coreServices.rootLifecycle, + pluginMetadata: coreServices.pluginMetadata, }, - async factory({ logger }) { - const rootLifecycle = new BackendLifecycleImpl( - loggerToWinstonLogger(logger), - ); - return async ({ plugin }) => { - return new PluginScopedLifecycleImpl(rootLifecycle, plugin.getId()); + async factory({ rootLifecycle }) { + return async ({ pluginMetadata }) => { + const plugin = pluginMetadata.getId(); + return { + addShutdownHook(options: LifecycleServiceShutdownHook): void { + rootLifecycle.addShutdownHook({ + ...options, + labels: { ...options?.labels, plugin }, + }); + }, + }; }; }, }); diff --git a/packages/backend-app-api/src/services/implementations/lifecycleService.test.ts b/packages/backend-app-api/src/services/implementations/rootLifecycleService.test.ts similarity index 91% rename from packages/backend-app-api/src/services/implementations/lifecycleService.test.ts rename to packages/backend-app-api/src/services/implementations/rootLifecycleService.test.ts index c0b69a3bef..686827fecd 100644 --- a/packages/backend-app-api/src/services/implementations/lifecycleService.test.ts +++ b/packages/backend-app-api/src/services/implementations/rootLifecycleService.test.ts @@ -15,14 +15,14 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { BackendLifecycleImpl } from './lifecycleService'; +import { BackendLifecycleImpl } from './rootLifecycleService'; describe('lifecycleService', () => { it('should execute registered shutdown hook', async () => { const service = new BackendLifecycleImpl(getVoidLogger()); const hook = jest.fn(); service.addShutdownHook({ - pluginId: 'test', + labels: { plugin: 'test' }, fn: async () => { hook(); }, @@ -37,7 +37,7 @@ describe('lifecycleService', () => { it('should not throw errors', async () => { const service = new BackendLifecycleImpl(getVoidLogger()); service.addShutdownHook({ - pluginId: 'test', + labels: { plugin: 'test' }, fn: async () => { throw new Error('oh no'); }, diff --git a/packages/backend-app-api/src/services/implementations/rootLifecycleService.ts b/packages/backend-app-api/src/services/implementations/rootLifecycleService.ts new file mode 100644 index 0000000000..c4ad4678ba --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/rootLifecycleService.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2022 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 { + createServiceFactory, + coreServices, + loggerToWinstonLogger, + LifecycleServiceShutdownHook, + RootLifecycleService, +} from '@backstage/backend-plugin-api'; +import { Logger } from 'winston'; + +const CALLBACKS = ['SIGTERM', 'SIGINT', 'beforeExit']; +export class BackendLifecycleImpl implements RootLifecycleService { + constructor(private readonly logger: Logger) { + CALLBACKS.map(signal => process.on(signal, () => this.shutdown())); + } + + #isCalled = false; + #shutdownTasks: Array = []; + + addShutdownHook(options: LifecycleServiceShutdownHook): void { + this.#shutdownTasks.push(options); + } + + async shutdown(): Promise { + if (this.#isCalled) { + return; + } + this.#isCalled = true; + + this.logger.info(`Running ${this.#shutdownTasks.length} shutdown tasks...`); + await Promise.all( + this.#shutdownTasks.map(async hook => { + try { + await hook.fn(); + this.logger.info(`Shutdown hook succeeded`, hook.labels); + } catch (error) { + this.logger.error(`Shutdown hook failed, ${error}`, hook.labels); + } + }), + ); + } +} + +/** + * Allows plugins to register shutdown hooks that are run when the process is about to exit. + * @public */ +export const rootLifecycleFactory = createServiceFactory({ + service: coreServices.rootLifecycle, + deps: { + logger: coreServices.rootLogger, + }, + async factory({ logger }) { + return new BackendLifecycleImpl(loggerToWinstonLogger(logger)); + }, +}); diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index 478cb30ff8..69512ba4c2 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -20,7 +20,7 @@ import { coreServices, ServiceRef, } from '@backstage/backend-plugin-api'; -import { BackendLifecycleImpl } from '../services/implementations/lifecycleService'; +import { BackendLifecycleImpl } from '../services/implementations/rootLifecycleService'; import { BackendRegisterInit, EnumerableServiceHolder, @@ -182,14 +182,13 @@ export class BackendInitializer { } const lifecycleService = await this.#serviceHolder.get( - coreServices.lifecycle, + coreServices.rootLifecycle, 'root', ); // TODO(Rugvip): Find a better way to do this - const lifecycle = (lifecycleService as any)?.lifecycle; - if (lifecycle instanceof BackendLifecycleImpl) { - await lifecycle.shutdown(); + if (lifecycleService instanceof BackendLifecycleImpl) { + await lifecycleService.shutdown(); } else { throw new Error('Unexpected lifecycle service implementation'); } diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 784f3295c6..abb45b0fe7 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -33,9 +33,19 @@ import { MergeResult } from 'isomorphic-git'; import { PushResult } from 'isomorphic-git'; import { Readable } from 'stream'; import { ReadCommitResult } from 'isomorphic-git'; +import { ReadTreeOptions } from '@backstage/backend-plugin-api'; +import { ReadTreeResponse } from '@backstage/backend-plugin-api'; +import { ReadTreeResponseDirOptions } from '@backstage/backend-plugin-api'; +import { ReadTreeResponseFile } from '@backstage/backend-plugin-api'; +import { ReadUrlOptions } from '@backstage/backend-plugin-api'; +import { ReadUrlResponse } from '@backstage/backend-plugin-api'; import { RequestHandler } from 'express'; import { Router } from 'express'; +import { SearchOptions } from '@backstage/backend-plugin-api'; +import { SearchResponse } from '@backstage/backend-plugin-api'; +import { SearchResponseFile } from '@backstage/backend-plugin-api'; import { Server } from 'http'; +import { UrlReaderService as UrlReader } from '@backstage/backend-plugin-api'; import { V1PodTemplateSpec } from '@kubernetes/client-node'; import * as winston from 'winston'; import { Writable } from 'stream'; @@ -533,30 +543,11 @@ export type ReaderFactory = (options: { treeResponseFactory: ReadTreeResponseFactory; }) => UrlReaderPredicateTuple[]; -// @public -export type ReadTreeOptions = { - filter?( - path: string, - info?: { - size: number; - }, - ): boolean; - etag?: string; - signal?: AbortSignal; -}; +export { ReadTreeOptions }; -// @public -export type ReadTreeResponse = { - files(): Promise; - archive(): Promise; - dir(options?: ReadTreeResponseDirOptions): Promise; - etag: string; -}; +export { ReadTreeResponse }; -// @public -export type ReadTreeResponseDirOptions = { - targetDir?: string; -}; +export { ReadTreeResponseDirOptions }; // @public export interface ReadTreeResponseFactory { @@ -587,24 +578,11 @@ export type ReadTreeResponseFactoryOptions = { ) => boolean; }; -// @public -export type ReadTreeResponseFile = { - path: string; - content(): Promise; -}; +export { ReadTreeResponseFile }; -// @public -export type ReadUrlOptions = { - etag?: string; - signal?: AbortSignal; -}; +export { ReadUrlOptions }; -// @public -export type ReadUrlResponse = { - buffer(): Promise; - stream?(): Readable; - etag?: string; -}; +export { ReadUrlResponse }; // @public export class ReadUrlResponseFactory { @@ -652,23 +630,11 @@ export type RunContainerOptions = { pullImage?: boolean; }; -// @public -export type SearchOptions = { - etag?: string; - signal?: AbortSignal; -}; +export { SearchOptions }; -// @public -export type SearchResponse = { - files: SearchResponseFile[]; - etag: string; -}; +export { SearchResponse }; -// @public -export type SearchResponseFile = { - url: string; - content(): Promise; -}; +export { SearchResponseFile }; // @public export class ServerTokenManager implements TokenManager { @@ -755,13 +721,7 @@ export interface TokenManager { }>; } -// @public -export type UrlReader = { - read(url: string): Promise; - readUrl(url: string, options?: ReadUrlOptions): Promise; - readTree(url: string, options?: ReadTreeOptions): Promise; - search(url: string, options?: SearchOptions): Promise; -}; +export { UrlReader }; // @public export type UrlReaderPredicateTuple = { diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 0af96cc41e..ca9b652fd0 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -34,6 +34,7 @@ "test:kubernetes": "backstage-cli package test -t KubernetesContainerRunner --no-watch" }, "dependencies": { + "@backstage/backend-plugin-api": "workspace:^", "@backstage/cli-common": "workspace:^", "@backstage/config": "workspace:^", "@backstage/config-loader": "workspace:^", diff --git a/packages/backend-common/src/reading/AwsS3UrlReader.ts b/packages/backend-common/src/reading/AwsS3UrlReader.ts index b9fc58ef5c..dd7a3da3d4 100644 --- a/packages/backend-common/src/reading/AwsS3UrlReader.ts +++ b/packages/backend-common/src/reading/AwsS3UrlReader.ts @@ -117,7 +117,7 @@ export function parseUrl( } /** - * Implements a {@link UrlReader} for AWS S3 buckets. + * Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for AWS S3 buckets. * * @public */ diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 140496c181..08e3917b38 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -40,7 +40,7 @@ import { import { ReadUrlResponseFactory } from './ReadUrlResponseFactory'; /** - * Implements a {@link UrlReader} for Azure repos. + * Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for Azure repos. * * @public */ diff --git a/packages/backend-common/src/reading/BitbucketCloudUrlReader.ts b/packages/backend-common/src/reading/BitbucketCloudUrlReader.ts index 7bc4cd9c11..b83e012423 100644 --- a/packages/backend-common/src/reading/BitbucketCloudUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketCloudUrlReader.ts @@ -42,7 +42,7 @@ import { import { ReadUrlResponseFactory } from './ReadUrlResponseFactory'; /** - * Implements a {@link UrlReader} for files from Bitbucket Cloud. + * Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files from Bitbucket Cloud. * * @public */ diff --git a/packages/backend-common/src/reading/BitbucketServerUrlReader.ts b/packages/backend-common/src/reading/BitbucketServerUrlReader.ts index bcab226b0f..a97c3b39bb 100644 --- a/packages/backend-common/src/reading/BitbucketServerUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketServerUrlReader.ts @@ -41,7 +41,7 @@ import { import { ReadUrlResponseFactory } from './ReadUrlResponseFactory'; /** - * Implements a {@link UrlReader} for files from Bitbucket Server APIs. + * Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files from Bitbucket Server APIs. * * @public */ diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index 805697bf27..2b0325d644 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -43,7 +43,7 @@ import { import { ReadUrlResponseFactory } from './ReadUrlResponseFactory'; /** - * Implements a {@link UrlReader} for files from Bitbucket v1 and v2 APIs, such + * Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files from Bitbucket v1 and v2 APIs, such * as the one exposed by Bitbucket Cloud itself. * * @public diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts index d38cc510b1..20b78d63c3 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.ts @@ -63,7 +63,7 @@ const parsePortPredicate = (port: string | undefined) => { }; /** - * A {@link UrlReader} that does a plain fetch of the URL. + * A {@link @backstage/backend-plugin-api#UrlReaderService} that does a plain fetch of the URL. * * @public */ diff --git a/packages/backend-common/src/reading/GerritUrlReader.ts b/packages/backend-common/src/reading/GerritUrlReader.ts index 59b33bb2cb..5d86e5d9ac 100644 --- a/packages/backend-common/src/reading/GerritUrlReader.ts +++ b/packages/backend-common/src/reading/GerritUrlReader.ts @@ -52,7 +52,7 @@ const createTemporaryDirectory = async (workDir: string): Promise => await fs.mkdtemp(joinPath(workDir, '/gerrit-clone-')); /** - * Implements a {@link UrlReader} for files in Gerrit. + * Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files in Gerrit. * * @remarks * To be able to link to Git contents for Gerrit providers in a user friendly diff --git a/packages/backend-common/src/reading/GiteaUrlReader.ts b/packages/backend-common/src/reading/GiteaUrlReader.ts index da6b791949..1471bab608 100644 --- a/packages/backend-common/src/reading/GiteaUrlReader.ts +++ b/packages/backend-common/src/reading/GiteaUrlReader.ts @@ -36,7 +36,7 @@ import { import { Readable } from 'stream'; /** - * Implements a {@link UrlReader} for the Gitea v1 api. + * Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for the Gitea v1 api. * * @public */ diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 7abf1b2518..d91c35621d 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -51,7 +51,7 @@ export type GhBlobResponse = RestEndpointMethodTypes['git']['getBlob']['response']['data']; /** - * Implements a {@link UrlReader} for files through the GitHub v3 APIs, such as + * Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files through the GitHub v3 APIs, such as * the one exposed by GitHub itself. * * @public diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index c80f1b566c..bebc7453d0 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -42,7 +42,7 @@ import { trimEnd, trimStart } from 'lodash'; import { ReadUrlResponseFactory } from './ReadUrlResponseFactory'; /** - * Implements a {@link UrlReader} for files on GitLab. + * Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files on GitLab. * * @public */ diff --git a/packages/backend-common/src/reading/GoogleGcsUrlReader.ts b/packages/backend-common/src/reading/GoogleGcsUrlReader.ts index 56b15a60e7..c9f5a58895 100644 --- a/packages/backend-common/src/reading/GoogleGcsUrlReader.ts +++ b/packages/backend-common/src/reading/GoogleGcsUrlReader.ts @@ -51,7 +51,7 @@ const parseURL = ( }; /** - * Implements a {@link UrlReader} for files on Google GCS. + * Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files on Google GCS. * * @public */ diff --git a/packages/backend-common/src/reading/UrlReaderPredicateMux.test.ts b/packages/backend-common/src/reading/UrlReaderPredicateMux.test.ts index 38473557e0..89dac93fff 100644 --- a/packages/backend-common/src/reading/UrlReaderPredicateMux.test.ts +++ b/packages/backend-common/src/reading/UrlReaderPredicateMux.test.ts @@ -70,7 +70,6 @@ describe('UrlReaderPredicateMux', () => { mux.register({ predicate: url => url.hostname === 'foo', reader: { - read: jest.fn(), readUrl: jest.fn(), readTree: jest.fn(), search: jest.fn(), diff --git a/packages/backend-common/src/reading/UrlReaderPredicateMux.ts b/packages/backend-common/src/reading/UrlReaderPredicateMux.ts index d5d30605b2..a7c2e5f983 100644 --- a/packages/backend-common/src/reading/UrlReaderPredicateMux.ts +++ b/packages/backend-common/src/reading/UrlReaderPredicateMux.ts @@ -45,18 +45,6 @@ export class UrlReaderPredicateMux implements UrlReader { this.readers.push(tuple); } - async read(url: string): Promise { - const parsed = new URL(url); - - for (const { predicate, reader } of this.readers) { - if (predicate(parsed)) { - return reader.read(url); - } - } - - throw new NotAllowedError(notAllowedMessage(url)); - } - async readUrl( url: string, options?: ReadUrlOptions, diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts index 9fb403e7e9..a567eef412 100644 --- a/packages/backend-common/src/reading/UrlReaders.ts +++ b/packages/backend-common/src/reading/UrlReaders.ts @@ -32,7 +32,7 @@ import { AwsS3UrlReader } from './AwsS3UrlReader'; import { GiteaUrlReader } from './GiteaUrlReader'; /** - * Creation options for {@link UrlReaders}. + * Creation options for {@link @backstage/backend-plugin-api#UrlReaderService}. * * @public */ @@ -46,13 +46,13 @@ export type UrlReadersOptions = { }; /** - * Helps construct {@link UrlReader}s. + * Helps construct {@link @backstage/backend-plugin-api#UrlReaderService}s. * * @public */ export class UrlReaders { /** - * Creates a custom {@link UrlReader} wrapper for your own set of factories. + * Creates a custom {@link @backstage/backend-plugin-api#UrlReaderService} wrapper for your own set of factories. */ static create(options: UrlReadersOptions): UrlReader { const { logger, config, factories } = options; @@ -73,7 +73,7 @@ export class UrlReaders { } /** - * Creates a {@link UrlReader} wrapper that includes all the default factories + * Creates a {@link @backstage/backend-plugin-api#UrlReaderService} wrapper that includes all the default factories * from this package. * * Any additional factories passed will be loaded before the default ones. diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts index bce737f3c7..640b549532 100644 --- a/packages/backend-common/src/reading/types.ts +++ b/packages/backend-common/src/reading/types.ts @@ -17,56 +17,38 @@ import { Readable } from 'stream'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; +import { + UrlReaderService, + ReadTreeResponse, +} from '@backstage/backend-plugin-api'; + +export type { + UrlReaderService as UrlReader, + ReadTreeOptions, + ReadTreeResponse, + ReadTreeResponseDirOptions, + ReadTreeResponseFile, + ReadUrlResponse, + ReadUrlOptions, + SearchOptions, + SearchResponse, + SearchResponseFile, +} from '@backstage/backend-plugin-api'; /** - * A generic interface for fetching plain data from URLs. - * - * @public - */ -export type UrlReader = { - /** - * Reads a single file and return its content. - * @deprecated use readUrl instead. - */ - read(url: string): Promise; - - /** - * Reads a single file and return its content. - * - * @remarks - * - * This is a replacement for the read method that supports options and - * complex responses. - * - * Use this as the read method will be removed in a future release. - */ - readUrl(url: string, options?: ReadUrlOptions): Promise; - - /** - * Reads a full or partial file tree. - */ - readTree(url: string, options?: ReadTreeOptions): Promise; - - /** - * Searches for a file in a tree using a glob pattern. - */ - search(url: string, options?: SearchOptions): Promise; -}; - -/** - * A predicate that decides whether a specific {@link UrlReader} can handle a + * A predicate that decides whether a specific {@link @backstage/backend-plugin-api#UrlReaderService} can handle a * given URL. * * @public */ export type UrlReaderPredicateTuple = { predicate: (url: URL) => boolean; - reader: UrlReader; + reader: UrlReaderService; }; /** * A factory function that can read config to construct zero or more - * {@link UrlReader}s along with a predicate for when it should be used. + * {@link @backstage/backend-plugin-api#UrlReaderService}s along with a predicate for when it should be used. * * @public */ @@ -76,72 +58,6 @@ export type ReaderFactory = (options: { treeResponseFactory: ReadTreeResponseFactory; }) => UrlReaderPredicateTuple[]; -/** - * An options object for readUrl operations. - * - * @public - */ -export type ReadUrlOptions = { - /** - * An ETag which can be provided to check whether a - * {@link UrlReader.readUrl} response has changed from a previous execution. - * - * @remarks - * - * In the {@link UrlReader.readUrl} response, an ETag is returned along with - * the data. The ETag is a unique identifier of the data, usually the commit - * SHA or ETag from the target. - * - * When an ETag is given in ReadUrlOptions, {@link UrlReader.readUrl} will - * first compare the ETag against the ETag of the target. If they match, - * {@link UrlReader.readUrl} will throw a - * {@link @backstage/errors#NotModifiedError} indicating that the response - * will not differ from the previous response which included this particular - * ETag. If they do not match, {@link UrlReader.readUrl} will return the rest - * of the response along with a new ETag. - */ - etag?: string; - - /** - * An abort signal to pass down to the underlying request. - * - * @remarks - * - * Not all reader implementations may take this field into account. - */ - signal?: AbortSignal; -}; - -/** - * A response object for {@link UrlReader.readUrl} operations. - * - * @public - */ -export type ReadUrlResponse = { - /** - * Returns the data that was read from the remote URL. - */ - buffer(): Promise; - - /** - * Returns the data that was read from the remote URL as a Readable stream. - * - * @remarks - * - * This method will be required in a future release. - */ - stream?(): Readable; - - /** - * Etag returned by content provider. - * - * @remarks - * - * Can be used to compare and cache responses when doing subsequent calls. - */ - etag?: string; -}; - /** * An options object for {@link ReadUrlResponseFactory} factory methods. * @@ -151,119 +67,6 @@ export type ReadUrlResponseFactoryFromStreamOptions = { etag?: string; }; -/** - * An options object for {@link UrlReader.readTree} operations. - * - * @public - */ -export type ReadTreeOptions = { - /** - * A filter that can be used to select which files should be included. - * - * @remarks - * - * The path passed to the filter function is the relative path from the URL - * that the file tree is fetched from, without any leading '/'. - * - * For example, given the URL https://github.com/my/repo/tree/master/my-dir, a file - * at https://github.com/my/repo/blob/master/my-dir/my-subdir/my-file.txt will - * be represented as my-subdir/my-file.txt - * - * If no filter is provided, all files are extracted. - */ - filter?(path: string, info?: { size: number }): boolean; - - /** - * An ETag which can be provided to check whether a - * {@link UrlReader.readTree} response has changed from a previous execution. - * - * @remarks - * - * In the {@link UrlReader.readTree} response, an ETag is returned along with - * the tree blob. The ETag is a unique identifier of the tree blob, usually - * the commit SHA or ETag from the target. - * - * When an ETag is given as a request option, {@link UrlReader.readTree} will - * first compare the ETag against the ETag on the target branch. If they - * match, {@link UrlReader.readTree} will throw a - * {@link @backstage/errors#NotModifiedError} indicating that the response - * will not differ from the previous response which included this particular - * ETag. If they do not match, {@link UrlReader.readTree} will return the - * rest of the response along with a new ETag. - */ - etag?: string; - - /** - * An abort signal to pass down to the underlying request. - * - * @remarks - * - * Not all reader implementations may take this field into account. - */ - signal?: AbortSignal; -}; - -/** - * Options that control {@link ReadTreeResponse.dir} execution. - * - * @public - */ -export type ReadTreeResponseDirOptions = { - /** - * The directory to write files to. - * - * @remarks - * - * Defaults to the OS tmpdir, or `backend.workingDirectory` if set in config. - */ - targetDir?: string; -}; - -/** - * A response object for {@link UrlReader.readTree} operations. - * - * @public - */ -export type ReadTreeResponse = { - /** - * Returns an array of all the files inside the tree, and corresponding - * functions to read their content. - */ - files(): Promise; - - /** - * Returns the tree contents as a binary archive, using a stream. - */ - archive(): Promise; - - /** - * Extracts the tree response into a directory and returns the path of the - * directory. - * - * **NOTE**: It is the responsibility of the caller to remove the directory after use. - */ - dir(options?: ReadTreeResponseDirOptions): Promise; - - /** - * Etag returned by content provider. - * - * @remarks - * - * Can be used to compare and cache responses when doing subsequent calls. - */ - etag: string; -}; - -/** - * Represents a single file in a {@link UrlReader.readTree} response. - * - * @public - */ -export type ReadTreeResponseFile = { - path: string; - content(): Promise; -}; - /** * Options that control execution of {@link ReadTreeResponseFactory} methods. * @@ -315,66 +118,3 @@ export interface ReadTreeResponseFactory { options: FromReadableArrayOptions, ): Promise; } - -/** - * An options object for search operations. - * - * @public - */ -export type SearchOptions = { - /** - * An etag can be provided to check whether the search response has changed from a previous execution. - * - * In the search() response, an etag is returned along with the files. The etag is a unique identifier - * of the current tree, usually the commit SHA or etag from the target. - * - * When an etag is given in SearchOptions, search will first compare the etag against the etag - * on the target branch. If they match, search will throw a NotModifiedError indicating that the search - * response will not differ from the previous response which included this particular etag. If they mismatch, - * search will return the rest of SearchResponse along with a new etag. - */ - etag?: string; - - /** - * An abort signal to pass down to the underlying request. - * - * @remarks - * - * Not all reader implementations may take this field into account. - */ - signal?: AbortSignal; -}; - -/** - * The output of a search operation. - * - * @public - */ -export type SearchResponse = { - /** - * The files that matched the search query. - */ - files: SearchResponseFile[]; - - /** - * A unique identifier of the current remote tree, usually the commit SHA or etag from the target. - */ - etag: string; -}; - -/** - * Represents a single file in a search response. - * - * @public - */ -export type SearchResponseFile = { - /** - * The full URL to the file. - */ - url: string; - - /** - * The binary contents of the file. - */ - content(): Promise; -}; diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index 9fde31e3bd..a37a8c32b2 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -23,6 +23,7 @@ import { discoveryFactory, httpRouterFactory, lifecycleFactory, + rootLifecycleFactory, loggerFactory, permissionsFactory, rootLoggerFactory, @@ -45,6 +46,7 @@ export const defaultServiceFactories = [ urlReaderFactory, httpRouterFactory, lifecycleFactory, + rootLifecycleFactory, ]; /** diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 545728b386..1b7e5a4b7e 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -3,6 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +/// + import { Config } from '@backstage/config'; import { Handler } from 'express'; import { Logger } from 'winston'; @@ -12,9 +14,9 @@ import { PluginCacheManager } from '@backstage/backend-common'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; +import { Readable } from 'stream'; import { TokenManager } from '@backstage/backend-common'; import { TransportStreamOptions } from 'winston-transport'; -import { UrlReader } from '@backstage/backend-common'; // @public (undocumented) export interface BackendFeature { @@ -89,6 +91,7 @@ declare namespace coreServices { tokenManagerServiceRef as tokenManager, permissionsServiceRef as permissions, schedulerServiceRef as scheduler, + rootLifecycleServiceRef as rootLifecycle, rootLoggerServiceRef as rootLogger, pluginMetadataServiceRef as pluginMetadata, lifecycleServiceRef as lifecycle, @@ -200,6 +203,7 @@ const lifecycleServiceRef: ServiceRef; // @public (undocumented) export type LifecycleServiceShutdownHook = { fn: () => void | Promise; + labels?: Record; }; // @public (undocumented) @@ -245,6 +249,56 @@ export interface PluginMetadataService { // @public (undocumented) const pluginMetadataServiceRef: ServiceRef; +// @public +export type ReadTreeOptions = { + filter?( + path: string, + info?: { + size: number; + }, + ): boolean; + etag?: string; + signal?: AbortSignal; +}; + +// @public +export type ReadTreeResponse = { + files(): Promise; + archive(): Promise; + dir(options?: ReadTreeResponseDirOptions): Promise; + etag: string; +}; + +// @public +export type ReadTreeResponseDirOptions = { + targetDir?: string; +}; + +// @public +export type ReadTreeResponseFile = { + path: string; + content(): Promise; +}; + +// @public +export type ReadUrlOptions = { + etag?: string; + signal?: AbortSignal; +}; + +// @public +export type ReadUrlResponse = { + buffer(): Promise; + stream?(): Readable; + etag?: string; +}; + +// @public (undocumented) +export type RootLifecycleService = LifecycleService; + +// @public (undocumented) +const rootLifecycleServiceRef: ServiceRef; + // @public (undocumented) export type RootLoggerService = LoggerService; @@ -257,6 +311,24 @@ export type SchedulerService = PluginTaskScheduler; // @public (undocumented) const schedulerServiceRef: ServiceRef; +// @public +export type SearchOptions = { + etag?: string; + signal?: AbortSignal; +}; + +// @public +export type SearchResponse = { + files: SearchResponseFile[]; + etag: string; +}; + +// @public +export type SearchResponseFile = { + url: string; + content(): Promise; +}; + // @public (undocumented) export type ServiceFactory = | { @@ -307,9 +379,13 @@ export type TypesToServiceRef = { [key in keyof T]: ServiceRef; }; -// @public (undocumented) -export type UrlReaderService = UrlReader; +// @public +export type UrlReaderService = { + readUrl(url: string, options?: ReadUrlOptions): Promise; + readTree(url: string, options?: ReadTreeOptions): Promise; + search(url: string, options?: SearchOptions): Promise; +}; // @public (undocumented) -const urlReaderServiceRef: ServiceRef; +const urlReaderServiceRef: ServiceRef; ``` diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index d5a5953806..a2b1a04d41 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -24,6 +24,7 @@ export { discoveryServiceRef as discovery } from './discoveryServiceRef'; export { tokenManagerServiceRef as tokenManager } from './tokenManagerServiceRef'; export { permissionsServiceRef as permissions } from './permissionsServiceRef'; export { schedulerServiceRef as scheduler } from './schedulerServiceRef'; +export { rootLifecycleServiceRef as rootLifecycle } from './rootLifecycleServiceRef'; export { rootLoggerServiceRef as rootLogger } from './rootLoggerServiceRef'; export { pluginMetadataServiceRef as pluginMetadata } from './pluginMetadataServiceRef'; export { lifecycleServiceRef as lifecycle } from './lifecycleServiceRef'; diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 3cf2ab4c77..953d4b28ad 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -29,7 +29,19 @@ export type { export type { LoggerService, LogMeta } from './loggerServiceRef'; export type { PermissionsService } from './permissionsServiceRef'; export type { PluginMetadataService } from './pluginMetadataServiceRef'; +export type { RootLifecycleService } from './rootLifecycleServiceRef'; export type { RootLoggerService } from './rootLoggerServiceRef'; export type { SchedulerService } from './schedulerServiceRef'; export type { TokenManagerService } from './tokenManagerServiceRef'; -export type { UrlReaderService } from './urlReaderServiceRef'; +export type { + ReadTreeOptions, + ReadTreeResponse, + ReadTreeResponseDirOptions, + ReadTreeResponseFile, + ReadUrlResponse, + ReadUrlOptions, + SearchOptions, + SearchResponse, + SearchResponseFile, + UrlReaderService, +} from './urlReaderServiceRef'; diff --git a/packages/backend-plugin-api/src/services/definitions/lifecycleServiceRef.ts b/packages/backend-plugin-api/src/services/definitions/lifecycleServiceRef.ts index 4061cc1da2..e54f650fcc 100644 --- a/packages/backend-plugin-api/src/services/definitions/lifecycleServiceRef.ts +++ b/packages/backend-plugin-api/src/services/definitions/lifecycleServiceRef.ts @@ -21,6 +21,9 @@ import { createServiceRef } from '../system/types'; **/ export type LifecycleServiceShutdownHook = { fn: () => void | Promise; + + /** Labels to help identify the shutdown hook */ + labels?: Record; }; /** diff --git a/packages/backend-plugin-api/src/services/definitions/rootLifecycleServiceRef.ts b/packages/backend-plugin-api/src/services/definitions/rootLifecycleServiceRef.ts new file mode 100644 index 0000000000..e0d5355983 --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/rootLifecycleServiceRef.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2022 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 { createServiceRef } from '../system/types'; +import { LifecycleService } from './lifecycleServiceRef'; + +/** @public */ +export type RootLifecycleService = LifecycleService; + +/** + * @public + */ +export const rootLifecycleServiceRef = createServiceRef({ + id: 'core.rootLifecycle', + scope: 'root', +}); diff --git a/packages/backend-plugin-api/src/services/definitions/urlReaderServiceRef.ts b/packages/backend-plugin-api/src/services/definitions/urlReaderServiceRef.ts index 4fc3680763..db296b5807 100644 --- a/packages/backend-plugin-api/src/services/definitions/urlReaderServiceRef.ts +++ b/packages/backend-plugin-api/src/services/definitions/urlReaderServiceRef.ts @@ -15,10 +15,271 @@ */ import { createServiceRef } from '../system/types'; -import { UrlReader } from '@backstage/backend-common'; +import { Readable } from 'stream'; -/** @public */ -export type UrlReaderService = UrlReader; +/** + * A generic interface for fetching plain data from URLs. + * + * @public + */ +export type UrlReaderService = { + /** + * Reads a single file and return its content. + */ + readUrl(url: string, options?: ReadUrlOptions): Promise; + + /** + * Reads a full or partial file tree. + */ + readTree(url: string, options?: ReadTreeOptions): Promise; + + /** + * Searches for a file in a tree using a glob pattern. + */ + search(url: string, options?: SearchOptions): Promise; +}; + +/** + * An options object for readUrl operations. + * + * @public + */ +export type ReadUrlOptions = { + /** + * An ETag which can be provided to check whether a + * {@link UrlReaderService.readUrl} response has changed from a previous execution. + * + * @remarks + * + * In the {@link UrlReaderService.readUrl} response, an ETag is returned along with + * the data. The ETag is a unique identifier of the data, usually the commit + * SHA or ETag from the target. + * + * When an ETag is given in ReadUrlOptions, {@link UrlReaderService.readUrl} will + * first compare the ETag against the ETag of the target. If they match, + * {@link UrlReaderService.readUrl} will throw a + * {@link @backstage/errors#NotModifiedError} indicating that the response + * will not differ from the previous response which included this particular + * ETag. If they do not match, {@link UrlReaderService.readUrl} will return the rest + * of the response along with a new ETag. + */ + etag?: string; + + /** + * An abort signal to pass down to the underlying request. + * + * @remarks + * + * Not all reader implementations may take this field into account. + */ + signal?: AbortSignal; +}; + +/** + * A response object for {@link UrlReaderService.readUrl} operations. + * + * @public + */ +export type ReadUrlResponse = { + /** + * Returns the data that was read from the remote URL. + */ + buffer(): Promise; + + /** + * Returns the data that was read from the remote URL as a Readable stream. + * + * @remarks + * + * This method will be required in a future release. + */ + stream?(): Readable; + + /** + * Etag returned by content provider. + * + * @remarks + * + * Can be used to compare and cache responses when doing subsequent calls. + */ + etag?: string; +}; + +/** + * An options object for {@link UrlReaderService.readTree} operations. + * + * @public + */ +export type ReadTreeOptions = { + /** + * A filter that can be used to select which files should be included. + * + * @remarks + * + * The path passed to the filter function is the relative path from the URL + * that the file tree is fetched from, without any leading '/'. + * + * For example, given the URL https://github.com/my/repo/tree/master/my-dir, a file + * at https://github.com/my/repo/blob/master/my-dir/my-subdir/my-file.txt will + * be represented as my-subdir/my-file.txt + * + * If no filter is provided, all files are extracted. + */ + filter?(path: string, info?: { size: number }): boolean; + + /** + * An ETag which can be provided to check whether a + * {@link UrlReaderService.readTree} response has changed from a previous execution. + * + * @remarks + * + * In the {@link UrlReaderService.readTree} response, an ETag is returned along with + * the tree blob. The ETag is a unique identifier of the tree blob, usually + * the commit SHA or ETag from the target. + * + * When an ETag is given as a request option, {@link UrlReaderService.readTree} will + * first compare the ETag against the ETag on the target branch. If they + * match, {@link UrlReaderService.readTree} will throw a + * {@link @backstage/errors#NotModifiedError} indicating that the response + * will not differ from the previous response which included this particular + * ETag. If they do not match, {@link UrlReaderService.readTree} will return the + * rest of the response along with a new ETag. + */ + etag?: string; + + /** + * An abort signal to pass down to the underlying request. + * + * @remarks + * + * Not all reader implementations may take this field into account. + */ + signal?: AbortSignal; +}; + +/** + * Options that control {@link ReadTreeResponse.dir} execution. + * + * @public + */ +export type ReadTreeResponseDirOptions = { + /** + * The directory to write files to. + * + * @remarks + * + * Defaults to the OS tmpdir, or `backend.workingDirectory` if set in config. + */ + targetDir?: string; +}; + +/** + * A response object for {@link UrlReaderService.readTree} operations. + * + * @public + */ +export type ReadTreeResponse = { + /** + * Returns an array of all the files inside the tree, and corresponding + * functions to read their content. + */ + files(): Promise; + + /** + * Returns the tree contents as a binary archive, using a stream. + */ + archive(): Promise; + + /** + * Extracts the tree response into a directory and returns the path of the + * directory. + * + * **NOTE**: It is the responsibility of the caller to remove the directory after use. + */ + dir(options?: ReadTreeResponseDirOptions): Promise; + + /** + * Etag returned by content provider. + * + * @remarks + * + * Can be used to compare and cache responses when doing subsequent calls. + */ + etag: string; +}; + +/** + * Represents a single file in a {@link UrlReaderService.readTree} response. + * + * @public + */ +export type ReadTreeResponseFile = { + path: string; + content(): Promise; +}; + +/** + * An options object for search operations. + * + * @public + */ +export type SearchOptions = { + /** + * An etag can be provided to check whether the search response has changed from a previous execution. + * + * In the search() response, an etag is returned along with the files. The etag is a unique identifier + * of the current tree, usually the commit SHA or etag from the target. + * + * When an etag is given in SearchOptions, search will first compare the etag against the etag + * on the target branch. If they match, search will throw a NotModifiedError indicating that the search + * response will not differ from the previous response which included this particular etag. If they mismatch, + * search will return the rest of SearchResponse along with a new etag. + */ + etag?: string; + + /** + * An abort signal to pass down to the underlying request. + * + * @remarks + * + * Not all reader implementations may take this field into account. + */ + signal?: AbortSignal; +}; + +/** + * The output of a search operation. + * + * @public + */ +export type SearchResponse = { + /** + * The files that matched the search query. + */ + files: SearchResponseFile[]; + + /** + * A unique identifier of the current remote tree, usually the commit SHA or etag from the target. + */ + etag: string; +}; + +/** + * Represents a single file in a search response. + * + * @public + */ +export type SearchResponseFile = { + /** + * The full URL to the file. + */ + url: string; + + /** + * The binary contents of the file. + */ + content(): Promise; +}; /** * @public diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.ts index 1d401bbf6f..647ebc9dda 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.ts @@ -18,6 +18,7 @@ import { Backend, createSpecializedBackend, lifecycleFactory, + rootLifecycleFactory, loggerFactory, rootLoggerFactory, } from '@backstage/backend-app-api'; @@ -57,6 +58,7 @@ const defaultServiceFactories = [ rootLoggerFactory(), loggerFactory(), lifecycleFactory(), + rootLifecycleFactory(), ]; const backendInstancesToCleanUp = new Array(); diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs index ae626c0d57..b04e8a2f48 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs @@ -1,7 +1,5 @@ import React from 'react'; import { ExampleComponent } from './ExampleComponent'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { screen } from '@testing-library/react'; @@ -23,11 +21,7 @@ describe('ExampleComponent', () => { }); it('should render', async () => { - await renderInTestApp( - - - , - ); + await renderInTestApp(); expect(screen.getByText('Welcome to {{ id }}!')).toBeInTheDocument(); }); }); diff --git a/packages/config/src/reader.test.ts b/packages/config/src/reader.test.ts index c0627a200b..c4c2a4ecbf 100644 --- a/packages/config/src/reader.test.ts +++ b/packages/config/src/reader.test.ts @@ -22,6 +22,15 @@ const DATA = { one: 1, true: true, false: false, + yes: 'yes', + no: 'no', + y: 'y', + n: 'n', + on: 'on', + off: 'off', + zeroString: '0', + oneString: '1', + stringFalse: 'false', null: null, string: 'string', emptyString: '', @@ -53,6 +62,17 @@ function expectValidValues(config: ConfigReader) { expect(config.getOptional('true')).toBe(true); expect(config.getBoolean('true')).toBe(true); expect(config.getBoolean('false')).toBe(false); + expect(config.getBoolean('stringFalse')).toBe(false); + expect(config.getBoolean('zero')).toBe(false); + expect(config.getBoolean('one')).toBe(true); + expect(config.getBoolean('zeroString')).toBe(false); + expect(config.getBoolean('oneString')).toBe(true); + expect(config.getBoolean('yes')).toBe(true); + expect(config.getBoolean('no')).toBe(false); + expect(config.getBoolean('y')).toBe(true); + expect(config.getBoolean('n')).toBe(false); + expect(config.getBoolean('on')).toBe(true); + expect(config.getBoolean('off')).toBe(false); expect(config.getString('string')).toBe('string'); expect(config.get('strings')).toEqual(['string1', 'string2']); expect(config.getStringArray('strings')).toEqual(['string1', 'string2']); @@ -86,7 +106,7 @@ function expectValidValues(config: ConfigReader) { function expectInvalidValues(config: ConfigReader) { expect(() => config.getBoolean('string')).toThrow( - "Invalid type in config for key 'string' in 'ctx', got string, wanted boolean", + "Unable to convert config value for key 'string' in 'ctx' to a boolean", ); expect(() => config.getNumber('string')).toThrow( "Unable to convert config value for key 'string' in 'ctx' to a number", diff --git a/packages/config/src/reader.ts b/packages/config/src/reader.ts index 1ce54590b5..4a570b6977 100644 --- a/packages/config/src/reader.ts +++ b/packages/config/src/reader.ts @@ -280,10 +280,25 @@ export class ConfigReader implements Config { /** {@inheritdoc Config.getOptionalBoolean} */ getOptionalBoolean(key: string): boolean | undefined { - return this.readConfigValue( + const value = this.readConfigValue( key, - value => typeof value === 'boolean' || { expected: 'boolean' }, + val => + typeof val === 'boolean' || + typeof val === 'number' || + typeof val === 'string' || { expected: 'boolean' }, ); + if (typeof value === 'boolean' || value === undefined) { + return value; + } + const valueString = String(value).trim(); + + if (/^(?:y|yes|true|1|on)$/i.test(valueString)) { + return true; + } + if (/^(?:n|no|false|0|off)$/i.test(valueString)) { + return false; + } + throw new Error(errors.convert(this.fullKey(key), this.context, 'boolean')); } /** {@inheritdoc Config.getString} */ diff --git a/plugins/airbrake/dev/components/ApiBar/ApiBar.tsx b/plugins/airbrake/dev/components/ApiBar/ApiBar.tsx index 7a4ee42f14..da3bbb1e46 100644 --- a/plugins/airbrake/dev/components/ApiBar/ApiBar.tsx +++ b/plugins/airbrake/dev/components/ApiBar/ApiBar.tsx @@ -14,12 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { - createTheme, - makeStyles, - MuiThemeProvider, - TextField, -} from '@material-ui/core'; +import { makeStyles, TextField } from '@material-ui/core'; import { Context } from '../ContextProvider'; const useStyles = makeStyles({ @@ -28,30 +23,12 @@ const useStyles = makeStyles({ gap: '1em', flexWrap: 'wrap', }, -}); - -const textFieldTheme = createTheme({ - palette: { - type: 'dark', - primary: { - light: '#fff', - main: '#fff', - dark: '#fff', - contrastText: '#fff', - }, - secondary: { - light: '#fff', - main: '#fff', - dark: '#fff', - contrastText: '#fff', - }, - action: { - disabled: '#fff', - }, - text: { - primary: '#fff', - secondary: '#fff', - }, + label: { + color: '#fff !important', + }, + outline: { + color: '#fff !important', + borderColor: '#fff !important', }, }); @@ -62,16 +39,16 @@ export const ApiBar = () => { {value => (
- - - value.setProjectId?.(parseInt(e.target.value, 10) || undefined) - } - /> - + + value.setProjectId?.(parseInt(e.target.value, 10) || undefined) + } + />
)}
diff --git a/plugins/allure/src/components/AllureReportComponent/AllureReportComponent.test.tsx b/plugins/allure/src/components/AllureReportComponent/AllureReportComponent.test.tsx index e6355d861f..a21ecbe10e 100644 --- a/plugins/allure/src/components/AllureReportComponent/AllureReportComponent.test.tsx +++ b/plugins/allure/src/components/AllureReportComponent/AllureReportComponent.test.tsx @@ -15,8 +15,6 @@ */ import React from 'react'; import { AllureReportComponent } from './AllureReportComponent'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { @@ -39,17 +37,15 @@ describe('ExampleComponent', () => { it('should render', async () => { const rendered = await renderInTestApp( - - - - - , + + + , ); expect(rendered.getByText('Missing Annotation')).toBeInTheDocument(); }); diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index de299fd895..4e94138439 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -96,9 +96,11 @@ export async function createRouter( const staticDir = resolvePath(appDistDir, 'static'); if (!(await fs.pathExists(staticDir))) { - logger.warn( - `Can't serve static app content from ${staticDir}, directory doesn't exist`, - ); + if (process.env.NODE_ENV === 'production') { + logger.error( + `Can't serve static app content from ${staticDir}, directory doesn't exist`, + ); + } return Router(); } diff --git a/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts b/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts index 74932c9653..4b912a8340 100644 --- a/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts +++ b/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts @@ -255,7 +255,6 @@ describe('replaceReadme', () => { }), readTree: jest.fn(), search: jest.fn(), - read: jest.fn(), }; const result = await replaceReadme( diff --git a/plugins/bazaar/README.md b/plugins/bazaar/README.md index 2ad141935e..218a5a886c 100644 --- a/plugins/bazaar/README.md +++ b/plugins/bazaar/README.md @@ -49,17 +49,20 @@ Add a **Bazaar icon** to the Sidebar to easily access the Bazaar. In `packages/a Add a **Bazaar card** to the overview tab on the `packages/app/src/components/catalog/EntityPage.tsx` add: ```diff -+ import { EntityBazaarInfoCard } from '@backstage/plugin-bazaar'; ++ import { EntityBazaarInfoCard, isBazaarAvailable } from '@backstage/plugin-bazaar'; const overviewContent = ( - -+ -+ -+ ++ ++ ++ ++ ++ ++ ++ {/* ...other entity-cards */} ``` diff --git a/plugins/bazaar/api-report.md b/plugins/bazaar/api-report.md index d7c463888a..afbb7b5504 100644 --- a/plugins/bazaar/api-report.md +++ b/plugins/bazaar/api-report.md @@ -5,7 +5,9 @@ ```ts /// +import { ApiHolder } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { Entity } from '@backstage/catalog-model'; import { RouteRef } from '@backstage/core-plugin-api'; // @public (undocumented) @@ -36,6 +38,14 @@ export const bazaarPlugin: BackstagePlugin< // @public (undocumented) export const EntityBazaarInfoCard: () => JSX.Element | null; +// @public (undocumented) +export const isBazaarAvailable: ( + entity: Entity, + context: { + apis: ApiHolder; + }, +) => Promise; + // @public (undocumented) export const SortView: () => JSX.Element; diff --git a/plugins/bazaar/src/api.ts b/plugins/bazaar/src/api.ts index 33455e69fc..a2e32e274a 100644 --- a/plugins/bazaar/src/api.ts +++ b/plugins/bazaar/src/api.ts @@ -14,7 +14,9 @@ * limitations under the License. */ +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { + ApiHolder, createApiRef, DiscoveryApi, FetchApi, @@ -46,6 +48,25 @@ export interface BazaarApi { deleteProject(id: number): Promise; } +/** @public */ +export const isBazaarAvailable = async ( + entity: Entity, + context: { apis: ApiHolder }, +): Promise => { + const bazaarClient = context.apis.get(bazaarApiRef); + if (bazaarClient === undefined) { + return false; + } + const entityRef = stringifyEntityRef({ + kind: entity.kind, + name: entity.metadata.name, + namespace: entity.metadata.namespace, + }); + const response = await bazaarClient.getProjectByRef(entityRef); + const project = await response.json(); + return project.data.length > 0; +}; + export class BazaarClient implements BazaarApi { private readonly identityApi: IdentityApi; private readonly discoveryApi: DiscoveryApi; diff --git a/plugins/bazaar/src/index.ts b/plugins/bazaar/src/index.ts index 0014c66b6d..c0378e60bf 100644 --- a/plugins/bazaar/src/index.ts +++ b/plugins/bazaar/src/index.ts @@ -15,6 +15,7 @@ */ export { bazaarPlugin, BazaarPage } from './plugin'; +export { isBazaarAvailable } from './api'; export { BazaarOverviewCard } from './components/BazaarOverviewCard'; export type { BazaarOverviewCardProps } from './components/BazaarOverviewCard'; export { EntityBazaarInfoCard } from './components/EntityBazaarInfoCard'; diff --git a/plugins/catalog-backend-module-github/api-report.md b/plugins/catalog-backend-module-github/api-report.md index 8ccfdb09ee..78ffd5b018 100644 --- a/plugins/catalog-backend-module-github/api-report.md +++ b/plugins/catalog-backend-module-github/api-report.md @@ -178,7 +178,9 @@ export class GitHubOrgEntityProvider extends GithubOrgEntityProvider { } // @public -export class GithubOrgEntityProvider implements EntityProvider { +export class GithubOrgEntityProvider + implements EntityProvider, EventSubscriber +{ constructor(options: { id: string; orgUrl: string; @@ -197,7 +199,11 @@ export class GithubOrgEntityProvider implements EntityProvider { ): GithubOrgEntityProvider; // (undocumented) getProviderName(): string; + // (undocumented) + onEvent(params: EventParams): Promise; read(options?: { logger?: Logger }): Promise; + // (undocumented) + supportsEventTopics(): string[]; } // @public @deprecated (undocumented) diff --git a/plugins/catalog-backend-module-github/src/lib/github.test.ts b/plugins/catalog-backend-module-github/src/lib/github.test.ts index d07d2f6af8..387dc8a2ee 100644 --- a/plugins/catalog-backend-module-github/src/lib/github.test.ts +++ b/plugins/catalog-backend-module-github/src/lib/github.test.ts @@ -29,6 +29,9 @@ import { QueryResponse, GithubUser, GithubTeam, + createAddEntitiesOperation, + createRemoveEntitiesOperation, + createReplaceEntitiesOperation, } from './github'; import fetch from 'node-fetch'; @@ -557,4 +560,107 @@ describe('github', () => { ).resolves.toEqual(output); }); }); + + describe('createAddEntitiesOperation', () => { + it('create a function to add deferred entities to a delta operation', () => { + const operation = createAddEntitiesOperation('my-id', 'host'); + + const userEntity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'githubuser', + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/githubuser', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/githubuser', + 'github.com/user-login': 'githubuser', + }, + }, + spec: { + memberOf: ['new-team'], + }, + }; + expect(operation('org', [userEntity])).toEqual({ + added: [ + { + locationKey: 'github-org-provider:my-id', + entity: userEntity, + }, + ], + removed: [], + }); + }); + }); + + describe('createRemoveEntitiesOperation', () => { + it('create a function to remove deferred entities to a delta operation', () => { + const operation = createRemoveEntitiesOperation('my-id', 'host'); + + const userEntity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'githubuser', + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/githubuser', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/githubuser', + 'github.com/user-login': 'githubuser', + }, + }, + spec: { + memberOf: ['new-team'], + }, + }; + expect(operation('org', [userEntity])).toEqual({ + removed: [ + { + locationKey: 'github-org-provider:my-id', + entity: userEntity, + }, + ], + added: [], + }); + }); + }); + describe('createReplaceEntitiesOperation', () => { + it('create a function to replace deferred entities to a delta operation', () => { + const operation = createReplaceEntitiesOperation('my-id', 'host'); + + const userEntity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'githubuser', + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/githubuser', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/githubuser', + 'github.com/user-login': 'githubuser', + }, + }, + spec: { + memberOf: ['new-team'], + }, + }; + expect(operation('org', [userEntity])).toEqual({ + removed: [ + { + locationKey: 'github-org-provider:my-id', + entity: userEntity, + }, + ], + added: [ + { + locationKey: 'github-org-provider:my-id', + entity: userEntity, + }, + ], + }); + }); + }); }); diff --git a/plugins/catalog-backend-module-github/src/lib/github.ts b/plugins/catalog-backend-module-github/src/lib/github.ts index 4d583d0730..f719e3d506 100644 --- a/plugins/catalog-backend-module-github/src/lib/github.ts +++ b/plugins/catalog-backend-module-github/src/lib/github.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import { Entity, GroupEntity, UserEntity } from '@backstage/catalog-model'; import { GithubCredentialType } from '@backstage/integration'; import { graphql } from '@octokit/graphql'; import { @@ -24,6 +24,9 @@ import { TransformerContext, UserTransformer, } from './defaultTransformers'; +import { withLocations } from '../providers/GithubOrgEntityProvider'; + +import { DeferredEntity } from '@backstage/plugin-catalog-backend'; // Graphql types @@ -191,7 +194,14 @@ export async function getOrganizationTeams( parentTeam { slug } members(first: 100, membership: IMMEDIATE) { pageInfo { hasNextPage } - nodes { login } + nodes { + avatarUrl, + bio, + email, + login, + name, + organizationVerifiedDomainEmails(login: $org) + } } } } @@ -238,6 +248,164 @@ export async function getOrganizationTeams( return { groups }; } +export async function getOrganizationTeamsFromUsers( + client: typeof graphql, + org: string, + userLogins: string[], + teamTransformer: TeamTransformer = defaultOrganizationTeamTransformer, +): Promise<{ + groups: GroupEntity[]; +}> { + const query = ` + query teams($org: String!, $cursor: String, $userLogins: [String!] = "") { + organization(login: $org) { + teams(first: 100, after: $cursor, userLogins: $userLogins) { + pageInfo { + hasNextPage + endCursor + } + nodes { + slug + combinedSlug + name + description + avatarUrl + editTeamUrl + parentTeam { + slug + } + members(first: 100, membership: IMMEDIATE) { + pageInfo { + hasNextPage + } + nodes { + avatarUrl, + bio, + email, + login, + name, + organizationVerifiedDomainEmails(login: $org) + } + } + } + } + } +}`; + + const materialisedTeams = async ( + item: GithubTeamResponse, + ctx: TransformerContext, + ): Promise => { + const memberNames: GithubUser[] = []; + + if (!item.members.pageInfo.hasNextPage) { + // We got all the members in one go, run the fast path + for (const user of item.members.nodes) { + memberNames.push(user); + } + } else { + // There were more than a hundred immediate members - run the slow + // path of fetching them explicitly + const { members } = await getTeamMembers(ctx.client, ctx.org, item.slug); + for (const userLogin of members) { + memberNames.push(userLogin); + } + } + + const team: GithubTeam = { + ...item, + members: memberNames, + }; + + return await teamTransformer(team, ctx); + }; + + const groups = await queryWithPaging( + client, + query, + org, + r => r.organization?.teams, + materialisedTeams, + { org, userLogins }, + ); + + return { groups }; +} + +export async function getOrganizationTeam( + client: typeof graphql, + org: string, + teamSlug: string, + teamTransformer: TeamTransformer = defaultOrganizationTeamTransformer, +): Promise<{ + group: GroupEntity; +}> { + const query = ` + query teams($org: String!, $teamSlug: String!) { + organization(login: $org) { + team(slug:$teamSlug) { + slug + combinedSlug + name + description + avatarUrl + editTeamUrl + parentTeam { slug } + members(first: 100, membership: IMMEDIATE) { + pageInfo { hasNextPage } + nodes { login } + } + } + } + }`; + + const materialisedTeam = async ( + item: GithubTeamResponse, + ctx: TransformerContext, + ): Promise => { + const memberNames: GithubUser[] = []; + + if (!item.members.pageInfo.hasNextPage) { + // We got all the members in one go, run the fast path + for (const user of item.members.nodes) { + memberNames.push(user); + } + } else { + // There were more than a hundred immediate members - run the slow + // path of fetching them explicitly + const { members } = await getTeamMembers(ctx.client, ctx.org, item.slug); + for (const userLogin of members) { + memberNames.push(userLogin); + } + } + + const team: GithubTeam = { + ...item, + members: memberNames, + }; + + return await teamTransformer(team, ctx); + }; + + const response: QueryResponse = await client(query, { + org, + teamSlug, + }); + + if (!response.organization?.team) + throw new Error(`Found no match for group ${teamSlug}`); + + const group = await materialisedTeam(response.organization?.team, { + query, + client, + org, + }); + + if (!group) throw new Error(`Can't transform for group ${teamSlug}`); + + return { group }; +} + export async function getOrganizationRepositories( client: typeof graphql, org: string, @@ -349,6 +517,7 @@ export async function getTeamMembers( * * @param client - The octokit client * @param query - The query to execute + * @param org - The slug of the org to read * @param connection - A function that, given the response, picks out the actual * Connection object that's being iterated * @param transformer - A function that, given one of the nodes in the Connection, @@ -406,3 +575,39 @@ export async function queryWithPaging< return result; } + +export type DeferredEntitiesBuilder = ( + org: string, + entities: Entity[], +) => { added: DeferredEntity[]; removed: DeferredEntity[] }; + +export const createAddEntitiesOperation = + (id: string, host: string) => (org: string, entities: Entity[]) => ({ + removed: [], + added: entities.map(entity => ({ + locationKey: `github-org-provider:${id}`, + entity: withLocations(`https://${host}`, org, entity), + })), + }); + +export const createRemoveEntitiesOperation = + (id: string, host: string) => (org: string, entities: Entity[]) => ({ + added: [], + removed: entities.map(entity => ({ + locationKey: `github-org-provider:${id}`, + entity: withLocations(`https://${host}`, org, entity), + })), + }); + +export const createReplaceEntitiesOperation = + (id: string, host: string) => (org: string, entities: Entity[]) => { + const entitiesToReplace = entities.map(entity => ({ + locationKey: `github-org-provider:${id}`, + entity: withLocations(`https://${host}`, org, entity), + })); + + return { + removed: entitiesToReplace, + added: entitiesToReplace, + }; + }; diff --git a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts index 0b8765b93e..c24cd573e0 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts @@ -22,6 +22,7 @@ import { } from '@backstage/integration'; import { EntityProviderConnection } from '@backstage/plugin-catalog-backend'; import { graphql } from '@octokit/graphql'; +import { EventParams } from '@backstage/plugin-events-node'; import { GithubOrgEntityProvider, withLocations, @@ -237,4 +238,1079 @@ describe('GithubOrgEntityProvider', () => { }); }); }); + + describe('receiving events from github', () => { + afterEach(() => jest.resetAllMocks()); + + it('should apply delta added on receive a new member in the organization', async () => { + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + refresh: jest.fn(), + }; + + const logger = getVoidLogger(); + const gitHubConfig: GithubIntegrationConfig = { + host: 'github.com', + }; + + const mockGetCredentials = jest.fn().mockReturnValue({ + headers: { token: 'blah' }, + type: 'app', + }); + + const githubCredentialsProvider: GithubCredentialsProvider = { + getCredentials: mockGetCredentials, + }; + + const entityProvider = new GithubOrgEntityProvider({ + id: 'my-id', + githubCredentialsProvider, + orgUrl: 'https://github.com/backstage', + gitHubConfig, + logger, + }); + + entityProvider.connect(entityProviderConnection); + + const expectedEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'githubuser', + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/githubuser', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/githubuser', + 'github.com/user-login': 'githubuser', + }, + }, + spec: { + memberOf: [], + profile: { + displayName: 'githubuser', + email: 'user1@test.com', + picture: 'https://avatars.githubusercontent.com/u/83820368', + }, + }, + }; + + const event: EventParams = { + topic: 'github.organization', + eventPayload: { + action: 'member_added', + membership: { + user: { + name: 'githubuser', + login: 'githubuser', + avatar_url: 'https://avatars.githubusercontent.com/u/83820368', + email: 'user1@test.com', + }, + }, + organization: { + login: 'test-org', + }, + }, + }; + + await entityProvider.onEvent(event); + + expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1); + expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ + type: 'delta', + added: [ + { + locationKey: 'github-org-provider:my-id', + entity: expectedEntity, + }, + ], + removed: [], + }); + }); + + it('should apply delta removed on receive a removed member in the organization', async () => { + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + refresh: jest.fn(), + }; + + const logger = getVoidLogger(); + const gitHubConfig: GithubIntegrationConfig = { + host: 'github.com', + }; + + const mockGetCredentials = jest.fn().mockReturnValue({ + headers: { token: 'blah' }, + type: 'app', + }); + + const githubCredentialsProvider: GithubCredentialsProvider = { + getCredentials: mockGetCredentials, + }; + + const entityProvider = new GithubOrgEntityProvider({ + id: 'my-id', + githubCredentialsProvider, + orgUrl: 'https://github.com/backstage', + gitHubConfig, + logger, + }); + + entityProvider.connect(entityProviderConnection); + + const expectedEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'githubuser', + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/githubuser', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/githubuser', + 'github.com/user-login': 'githubuser', + }, + }, + spec: { + memberOf: [], + profile: { + displayName: 'githubuser', + email: 'user1@test.com', + picture: 'https://avatars.githubusercontent.com/u/83820368', + }, + }, + }; + + const event: EventParams = { + topic: 'github.organization', + eventPayload: { + action: 'member_removed', + membership: { + user: { + name: 'githubuser', + login: 'githubuser', + avatar_url: 'https://avatars.githubusercontent.com/u/83820368', + email: 'user1@test.com', + }, + }, + organization: { + login: 'test-org', + }, + }, + }; + + await entityProvider.onEvent(event); + + expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1); + expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ + type: 'delta', + removed: [ + { + locationKey: 'github-org-provider:my-id', + entity: expectedEntity, + }, + ], + added: [], + }); + }); + + it('should apply delta added on receive a created team', async () => { + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + refresh: jest.fn(), + }; + + const logger = getVoidLogger(); + const gitHubConfig: GithubIntegrationConfig = { + host: 'github.com', + }; + + const mockGetCredentials = jest.fn().mockReturnValue({ + headers: { token: 'blah' }, + type: 'app', + }); + + const githubCredentialsProvider: GithubCredentialsProvider = { + getCredentials: mockGetCredentials, + }; + + const entityProvider = new GithubOrgEntityProvider({ + id: 'my-id', + githubCredentialsProvider, + orgUrl: 'https://github.com/backstage', + gitHubConfig, + logger, + }); + + entityProvider.connect(entityProviderConnection); + + const expectedEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'new-team', + description: 'description from the new team', + annotations: { + 'backstage.io/edit-url': + 'https://github.com/orgs/test-org/teams/new-team/edit', + 'backstage.io/managed-by-location': + 'url:https://github.com/orgs/test-org/teams/new-team', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/orgs/test-org/teams/new-team', + 'github.com/team-slug': 'test-org/new-team', + }, + }, + spec: { + type: 'team', + children: [], + members: [], + parent: 'father-team', + profile: { + displayName: 'New Team', + }, + }, + }; + + const event: EventParams = { + topic: 'github.team', + eventPayload: { + action: 'created', + team: { + name: 'New Team', + slug: 'new-team', + description: 'description from the new team', + html_url: 'https://github.com/orgs/test-org/teams/new-team', + parent: { + slug: 'father-team', + }, + }, + organization: { + login: 'test-org', + }, + }, + }; + + await entityProvider.onEvent(event); + + expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1); + expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ + type: 'delta', + added: [ + { + locationKey: 'github-org-provider:my-id', + entity: expectedEntity, + }, + ], + removed: [], + }); + }); + + it('should apply delta removed on receive a deleted team', async () => { + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + refresh: jest.fn(), + }; + + const logger = getVoidLogger(); + const gitHubConfig: GithubIntegrationConfig = { + host: 'github.com', + }; + + const mockGetCredentials = jest.fn().mockReturnValue({ + headers: { token: 'blah' }, + type: 'app', + }); + + const githubCredentialsProvider: GithubCredentialsProvider = { + getCredentials: mockGetCredentials, + }; + + const entityProvider = new GithubOrgEntityProvider({ + id: 'my-id', + githubCredentialsProvider, + orgUrl: 'https://github.com/backstage', + gitHubConfig, + logger, + }); + + entityProvider.connect(entityProviderConnection); + + const expectedEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'new-team', + description: 'description from the new team', + annotations: { + 'backstage.io/edit-url': + 'https://github.com/orgs/test-org/teams/new-team/edit', + 'backstage.io/managed-by-location': + 'url:https://github.com/orgs/test-org/teams/new-team', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/orgs/test-org/teams/new-team', + 'github.com/team-slug': 'test-org/new-team', + }, + }, + spec: { + type: 'team', + children: [], + members: [], + parent: 'father-team', + profile: { + displayName: 'New Team', + }, + }, + }; + + const event: EventParams = { + topic: 'github.team', + eventPayload: { + action: 'deleted', + team: { + databaseId: 1, + name: 'New Team', + slug: 'new-team', + description: 'description from the new team', + html_url: 'https://github.com/orgs/test-org/teams/new-team', + parent: { + slug: 'father-team', + }, + }, + organization: { + login: 'test-org', + }, + }, + }; + + await entityProvider.onEvent(event); + + expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1); + expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ + type: 'delta', + removed: [ + { + locationKey: 'github-org-provider:my-id', + entity: expectedEntity, + }, + ], + added: [], + }); + }); + + it('should apply delta on receive a edited team', async () => { + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + refresh: jest.fn(), + }; + + const logger = getVoidLogger(); + const gitHubConfig: GithubIntegrationConfig = { + host: 'github.com', + }; + + const mockGetCredentials = jest.fn().mockReturnValue({ + headers: { token: 'blah' }, + type: 'app', + }); + + const githubCredentialsProvider: GithubCredentialsProvider = { + getCredentials: mockGetCredentials, + }; + + const entityProvider = new GithubOrgEntityProvider({ + id: 'my-id', + githubCredentialsProvider, + orgUrl: 'https://github.com/backstage', + gitHubConfig, + logger, + }); + + const mockClient = jest.fn(); + + mockClient + .mockResolvedValueOnce({ + organization: { + team: { + slug: 'team', + combinedSlug: 'blah/team', + name: 'Team', + description: 'The one and only team', + avatarUrl: 'http://example.com/team.jpeg', + parentTeam: { + slug: 'parent', + combinedSlug: '', + members: { pageInfo: { hasNextPage: false }, nodes: [] }, + }, + members: { + pageInfo: { hasNextPage: false }, + nodes: [{ login: 'a' }, { login: 'githubuser' }], + }, + }, + }, + }) + .mockResolvedValueOnce({ + organization: { + membersWithRole: { + pageInfo: { hasNextPage: false }, + nodes: [ + { + login: 'a', + name: 'b', + bio: 'c', + email: 'd', + avatarUrl: 'e', + }, + { + login: 'githubuser', + name: 'githubuser', + bio: 'githubuser', + email: 'd', + avatarUrl: 'e', + }, + ], + }, + }, + }) + .mockResolvedValueOnce({ + organization: { + teams: { + pageInfo: { hasNextPage: false }, + nodes: [ + { + slug: 'team', + combinedSlug: 'blah/team', + name: 'Team', + description: 'The one and only team', + avatarUrl: 'http://example.com/team.jpeg', + parentTeam: { + slug: 'parent', + combinedSlug: '', + members: { pageInfo: { hasNextPage: false }, nodes: [] }, + }, + members: { + pageInfo: { hasNextPage: false }, + nodes: [{ login: 'a' }, { login: 'githubuser' }], + }, + }, + ], + }, + }, + }); + + (graphql.defaults as jest.Mock).mockReturnValue(mockClient); + + entityProvider.connect(entityProviderConnection); + + const event: EventParams = { + topic: 'github.team', + eventPayload: { + action: 'edited', + changes: { + name: { + from: 'mygroup with spaces', + }, + }, + team: { + node_id: 'xpto', + name: 'New Team', + slug: 'new-team', + description: 'description from the new team', + html_url: 'https://github.com/orgs/test-org/teams/new-team', + parent: { + slug: 'father-team', + }, + }, + organization: { + login: 'test-org', + }, + }, + }; + + await entityProvider.onEvent(event); + await new Promise(process.nextTick); + + expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1); + expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ + added: [ + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/a', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/a', + 'github.com/user-login': 'a', + }, + name: 'a', + description: 'c', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + spec: { + profile: { + displayName: 'b', + email: 'd', + picture: 'e', + }, + memberOf: ['team'], + }, + }, + }, + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/githubuser', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/githubuser', + 'github.com/user-login': 'githubuser', + }, + name: 'githubuser', + description: 'githubuser', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + spec: { + profile: { + displayName: 'githubuser', + email: 'd', + picture: 'e', + }, + memberOf: ['team'], + }, + }, + }, + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/orgs/backstage/teams/team', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/orgs/backstage/teams/team', + 'github.com/team-slug': 'blah/team', + }, + name: 'team', + description: 'The one and only team', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'team', + profile: { + displayName: 'Team', + picture: 'http://example.com/team.jpeg', + }, + children: [], + parent: 'parent', + members: ['a', 'githubuser'], + }, + }, + }, + ], + removed: [ + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/orgs/backstage/teams/mygroup-with-spaces', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/orgs/backstage/teams/mygroup-with-spaces', + }, + name: 'mygroup-with-spaces', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'team', + profile: { + displayName: 'Team', + picture: 'http://example.com/team.jpeg', + }, + children: [], + parent: 'parent', + members: ['a', 'githubuser'], + }, + }, + }, + ], + type: 'delta', + }); + }); + + it('should apply delta on receive a membership added', async () => { + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + refresh: jest.fn(), + }; + + const logger = getVoidLogger(); + const gitHubConfig: GithubIntegrationConfig = { + host: 'github.com', + }; + + const mockGetCredentials = jest.fn().mockReturnValue({ + headers: { token: 'blah' }, + type: 'app', + }); + + const githubCredentialsProvider: GithubCredentialsProvider = { + getCredentials: mockGetCredentials, + }; + + const entityProvider = new GithubOrgEntityProvider({ + id: 'my-id', + githubCredentialsProvider, + orgUrl: 'https://github.com/backstage', + gitHubConfig, + logger, + }); + + const mockClient = jest.fn(); + + mockClient + .mockResolvedValueOnce({ + organization: { + team: { + slug: 'team', + combinedSlug: 'blah/team', + name: 'Team', + description: 'The one and only team', + avatarUrl: 'http://example.com/team.jpeg', + parentTeam: { + slug: 'parent', + combinedSlug: '', + members: { pageInfo: { hasNextPage: false }, nodes: [] }, + }, + members: { + pageInfo: { hasNextPage: false }, + nodes: [{ login: 'a' }, { login: 'githubuser' }], + }, + }, + }, + }) + .mockResolvedValueOnce({ + organization: { + membersWithRole: { + pageInfo: { hasNextPage: false }, + nodes: [ + { + login: 'a', + name: 'b', + bio: 'c', + email: 'd', + avatarUrl: 'e', + }, + { + login: 'githubuser', + name: 'githubuser', + bio: 'githubuser', + email: 'd', + avatarUrl: 'e', + }, + ], + }, + }, + }) + .mockResolvedValueOnce({ + organization: { + teams: { + pageInfo: { hasNextPage: false }, + nodes: [ + { + slug: 'team', + combinedSlug: 'blah/team', + name: 'Team', + description: 'The one and only team', + avatarUrl: 'http://example.com/team.jpeg', + parentTeam: { + slug: 'parent', + combinedSlug: '', + members: { pageInfo: { hasNextPage: false }, nodes: [] }, + }, + members: { + pageInfo: { hasNextPage: false }, + nodes: [{ login: 'a' }, { login: 'githubuser' }], + }, + }, + ], + }, + }, + }); + + (graphql.defaults as jest.Mock).mockReturnValue(mockClient); + entityProvider.connect(entityProviderConnection); + + const event: EventParams = { + topic: 'github.membership', + eventPayload: { + action: 'added', + team: { + name: 'New Team', + slug: 'new-team', + description: 'description from the new team', + html_url: 'https://github.com/orgs/test-org/teams/new-team', + parent: { + slug: 'father-team', + }, + }, + member: { + login: 'githubuser', + }, + organization: { + login: 'test-org', + }, + }, + }; + + await entityProvider.onEvent(event); + await new Promise(process.nextTick); + + expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1); + expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ + added: [ + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/githubuser', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/githubuser', + 'github.com/user-login': 'githubuser', + }, + name: 'githubuser', + description: 'githubuser', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + spec: { + profile: { + displayName: 'githubuser', + email: 'd', + picture: 'e', + }, + memberOf: ['team'], + }, + }, + }, + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/orgs/backstage/teams/team', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/orgs/backstage/teams/team', + 'github.com/team-slug': 'blah/team', + }, + name: 'team', + description: 'The one and only team', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'team', + profile: { + displayName: 'Team', + picture: 'http://example.com/team.jpeg', + }, + children: [], + parent: 'parent', + members: ['a', 'githubuser'], + }, + }, + }, + ], + removed: [ + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/githubuser', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/githubuser', + 'github.com/user-login': 'githubuser', + }, + name: 'githubuser', + description: 'githubuser', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + spec: { + profile: { + displayName: 'githubuser', + email: 'd', + picture: 'e', + }, + memberOf: ['team'], + }, + }, + }, + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/orgs/backstage/teams/team', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/orgs/backstage/teams/team', + 'github.com/team-slug': 'blah/team', + }, + name: 'team', + description: 'The one and only team', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'team', + profile: { + displayName: 'Team', + picture: 'http://example.com/team.jpeg', + }, + children: [], + parent: 'parent', + members: ['a', 'githubuser'], + }, + }, + }, + ], + type: 'delta', + }); + }); + + it('should apply delta on receive a membership removed', async () => { + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + refresh: jest.fn(), + }; + + const logger = getVoidLogger(); + const gitHubConfig: GithubIntegrationConfig = { + host: 'github.com', + }; + + const mockGetCredentials = jest.fn().mockReturnValue({ + headers: { token: 'blah' }, + type: 'app', + }); + + const githubCredentialsProvider: GithubCredentialsProvider = { + getCredentials: mockGetCredentials, + }; + + const entityProvider = new GithubOrgEntityProvider({ + id: 'my-id', + githubCredentialsProvider, + orgUrl: 'https://github.com/backstage', + gitHubConfig, + logger, + }); + + const mockClient = jest.fn(); + + mockClient + .mockResolvedValueOnce({ + organization: { + team: { + slug: 'team', + combinedSlug: 'blah/team', + name: 'Team', + description: 'The one and only team', + avatarUrl: 'http://example.com/team.jpeg', + parentTeam: { + slug: 'parent', + combinedSlug: '', + members: { pageInfo: { hasNextPage: false }, nodes: [] }, + }, + members: { + pageInfo: { hasNextPage: false }, + nodes: [{ login: 'a' }], + }, + }, + }, + }) + .mockResolvedValueOnce({ + organization: { + membersWithRole: { + pageInfo: { hasNextPage: false }, + nodes: [ + { + login: 'githubuser', + name: 'githubuser', + bio: 'githubuser', + email: 'd', + avatarUrl: 'e', + }, + ], + }, + }, + }) + .mockResolvedValueOnce({ + organization: { + teams: { + pageInfo: { hasNextPage: false }, + nodes: [], + }, + }, + }); + + (graphql.defaults as jest.Mock).mockReturnValue(mockClient); + entityProvider.connect(entityProviderConnection); + + const event: EventParams = { + topic: 'github.membership', + eventPayload: { + action: 'removed', + team: { + name: 'New Team', + slug: 'new-team', + description: 'description from the new team', + html_url: 'https://github.com/orgs/test-org/teams/new-team', + parent: { + slug: 'father-team', + }, + }, + member: { + login: 'githubuser', + }, + organization: { + login: 'test-org', + }, + }, + }; + + await entityProvider.onEvent(event); + await new Promise(process.nextTick); + + expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1); + expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ + added: [ + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/githubuser', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/githubuser', + 'github.com/user-login': 'githubuser', + }, + name: 'githubuser', + description: 'githubuser', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + spec: { + profile: { + displayName: 'githubuser', + email: 'd', + picture: 'e', + }, + memberOf: [], + }, + }, + }, + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/orgs/backstage/teams/team', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/orgs/backstage/teams/team', + 'github.com/team-slug': 'blah/team', + }, + name: 'team', + description: 'The one and only team', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'team', + profile: { + displayName: 'Team', + picture: 'http://example.com/team.jpeg', + }, + children: [], + parent: 'parent', + members: ['a'], + }, + }, + }, + ], + removed: [ + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/githubuser', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/githubuser', + 'github.com/user-login': 'githubuser', + }, + name: 'githubuser', + description: 'githubuser', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + spec: { + profile: { + displayName: 'githubuser', + email: 'd', + picture: 'e', + }, + memberOf: [], + }, + }, + }, + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/orgs/backstage/teams/team', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/orgs/backstage/teams/team', + 'github.com/team-slug': 'blah/team', + }, + name: 'team', + description: 'The one and only team', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'team', + profile: { + displayName: 'Team', + picture: 'http://example.com/team.jpeg', + }, + children: [], + parent: 'parent', + members: ['a'], + }, + }, + }, + ], + type: 'delta', + }); + }); + }); }); diff --git a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts index 210df46363..46ad089625 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts @@ -28,22 +28,43 @@ import { ScmIntegrations, SingleInstanceGithubCredentialsProvider, } from '@backstage/integration'; +import { EventParams } from '@backstage/plugin-events-node'; +import { EventSubscriber } from '@backstage/plugin-events-node'; import { EntityProvider, EntityProviderConnection, } from '@backstage/plugin-catalog-backend'; import { graphql } from '@octokit/graphql'; +import { + OrganizationEvent, + OrganizationMemberAddedEvent, + OrganizationMemberRemovedEvent, + TeamEvent, + TeamEditedEvent, + MembershipEvent, +} from '@octokit/webhooks-types'; import { merge } from 'lodash'; import * as uuid from 'uuid'; import { Logger } from 'winston'; import { assignGroupsToUsers, buildOrgHierarchy, + defaultOrganizationTeamTransformer, + defaultUserTransformer, getOrganizationTeams, getOrganizationUsers, + GithubTeam, parseGithubOrgUrl, } from '../lib'; -import { TeamTransformer, UserTransformer } from '../lib/defaultTransformers'; +import { TeamTransformer, UserTransformer } from '../lib'; +import { + createAddEntitiesOperation, + createRemoveEntitiesOperation, + createReplaceEntitiesOperation, + DeferredEntitiesBuilder, + getOrganizationTeam, + getOrganizationTeamsFromUsers, +} from '../lib/github'; /** * Options for {@link GithubOrgEntityProvider}. @@ -101,13 +122,14 @@ export interface GithubOrgEntityProviderOptions { teamTransformer?: TeamTransformer; } -// TODO: Consider supporting an (optional) webhook that reacts on org changes /** * Ingests org data (users and groups) from GitHub. * * @public */ -export class GithubOrgEntityProvider implements EntityProvider { +export class GithubOrgEntityProvider + implements EntityProvider, EventSubscriber +{ private readonly credentialsProvider: GithubCredentialsProvider; private connection?: EntityProviderConnection; private scheduleFn?: () => Promise; @@ -224,6 +246,306 @@ export class GithubOrgEntityProvider implements EntityProvider { markCommitComplete(); } + /** {@inheritdoc @backstage/plugin-events-node#EventSubscriber.onEvent} */ + async onEvent(params: EventParams): Promise { + const { logger } = this.options; + logger.debug(`Received event from ${params.topic}`); + + const addEntitiesOperation = createAddEntitiesOperation( + this.options.id, + this.options.gitHubConfig.host, + ); + const removeEntitiesOperation = createRemoveEntitiesOperation( + this.options.id, + this.options.gitHubConfig.host, + ); + + const replaceEntitiesOperation = createReplaceEntitiesOperation( + this.options.id, + this.options.gitHubConfig.host, + ); + + // handle change users in the org + // https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#organization + if (params.topic.includes('organization')) { + const orgEvent = params.eventPayload as OrganizationEvent; + + if ( + orgEvent.action === 'member_added' || + orgEvent.action === 'member_removed' + ) { + const createDeltaOperation = + orgEvent.action === 'member_added' + ? addEntitiesOperation + : removeEntitiesOperation; + await this.onMemberChangeInOrganization(orgEvent, createDeltaOperation); + } + } + + // handle change teams in the org + // https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#team + if (params.topic.includes('team')) { + const teamEvent = params.eventPayload as TeamEvent; + if (teamEvent.action === 'created' || teamEvent.action === 'deleted') { + const createDeltaOperation = + teamEvent.action === 'created' + ? addEntitiesOperation + : removeEntitiesOperation; + await this.onTeamChangeInOrganization(teamEvent, createDeltaOperation); + } else if (teamEvent.action === 'edited') { + await this.onTeamEditedInOrganization( + teamEvent, + replaceEntitiesOperation, + ); + } + } + + // handle change membership in the org + // https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#membership + if (params.topic.includes('membership')) { + const membershipEvent = params.eventPayload as MembershipEvent; + this.onMembershipChangedInOrganization( + membershipEvent, + replaceEntitiesOperation, + ); + } + + return; + } + + /** {@inheritdoc @backstage/plugin-events-node#EventSubscriber.supportsEventTopics} */ + supportsEventTopics(): string[] { + return ['github.organization', 'github.team', 'github.membership']; + } + + private async onTeamEditedInOrganization( + event: TeamEditedEvent, + createDeltaOperation: DeferredEntitiesBuilder, + ) { + if (!this.connection) { + throw new Error('Not initialized'); + } + + const teamSlug = event.team.slug; + const { headers, type: tokenType } = + await this.credentialsProvider.getCredentials({ + url: this.options.orgUrl, + }); + const client = graphql.defaults({ + baseUrl: this.options.gitHubConfig.apiBaseUrl, + headers, + }); + + const { org } = parseGithubOrgUrl(this.options.orgUrl); + const { group } = await getOrganizationTeam( + client, + org, + teamSlug, + this.options.teamTransformer, + ); + + const { users } = await getOrganizationUsers( + client, + org, + tokenType, + this.options.userTransformer, + ); + + const usersFromChangedGroup = group.spec.members || []; + const usersToRebuild = users.filter(u => + usersFromChangedGroup.includes(u.metadata.name), + ); + + const { groups } = await getOrganizationTeamsFromUsers( + client, + org, + usersToRebuild.map(u => u.metadata.name), + this.options.teamTransformer, + ); + + assignGroupsToUsers(usersToRebuild, groups); + buildOrgHierarchy(groups); + + const oldName = event.changes.name?.from || ''; + const oldSlug = oldName.toLowerCase().replaceAll(/\s/gi, '-'); + + const { removed } = createDeltaOperation(org, [ + { + ...group, + metadata: { + name: oldSlug, + }, + }, + ]); + const { added } = createDeltaOperation(org, [...usersToRebuild, ...groups]); + await this.connection.applyMutation({ + type: 'delta', + removed, + added, + }); + } + + private async onMembershipChangedInOrganization( + event: MembershipEvent, + createDeltaOperation: DeferredEntitiesBuilder, + ) { + if (!this.connection) { + throw new Error('Not initialized'); + } + + // The docs are saying I will receive the slug for the removed event, + // but the types don't reflect that, + // so I will just check to be sure the slug is there + // https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#membership + if (!('slug' in event.team)) { + return; + } + + const teamSlug = event.team.slug; + const userLogin = event.member.login; + const { headers, type: tokenType } = + await this.credentialsProvider.getCredentials({ + url: this.options.orgUrl, + }); + const client = graphql.defaults({ + baseUrl: this.options.gitHubConfig.apiBaseUrl, + headers, + }); + + const { org } = parseGithubOrgUrl(this.options.orgUrl); + const { group } = await getOrganizationTeam( + client, + org, + teamSlug, + this.options.teamTransformer, + ); + + const { users } = await getOrganizationUsers( + client, + org, + tokenType, + this.options.userTransformer, + ); + + const usersToRebuild = users.filter(u => u.metadata.name === userLogin); + + const { groups } = await getOrganizationTeamsFromUsers( + client, + org, + [userLogin], + this.options.teamTransformer, + ); + + // we include group because the removed event need to update the old group too + if (!groups.some(g => g.metadata.name === group.metadata.name)) { + groups.push(group); + } + + assignGroupsToUsers(usersToRebuild, groups); + buildOrgHierarchy(groups); + + const { added, removed } = createDeltaOperation(org, [ + ...usersToRebuild, + ...groups, + ]); + await this.connection.applyMutation({ + type: 'delta', + removed, + added, + }); + } + + private async onTeamChangeInOrganization( + event: TeamEvent, + createDeltaOperation: DeferredEntitiesBuilder, + ) { + if (!this.connection) { + throw new Error('Not initialized'); + } + + const organizationTeamTransformer = + this.options.teamTransformer || defaultOrganizationTeamTransformer; + const { name, html_url: url, description, slug } = event.team; + const org = event.organization.login; + const { headers } = await this.credentialsProvider.getCredentials({ + url: this.options.orgUrl, + }); + const client = graphql.defaults({ + baseUrl: this.options.gitHubConfig.apiBaseUrl, + headers, + }); + + const group = (await organizationTeamTransformer( + { + name, + slug, + editTeamUrl: `${url}/edit`, + combinedSlug: `${org}/${slug}`, + description: description || undefined, + parentTeam: { slug: event.team?.parent?.slug || '' } as GithubTeam, + // entity will be removed + members: [], + }, + { + org, + client, + query: '', + }, + )) as Entity; + + const { added, removed } = createDeltaOperation(org, [group]); + + await this.connection.applyMutation({ + type: 'delta', + removed, + added, + }); + } + + private async onMemberChangeInOrganization( + event: OrganizationMemberAddedEvent | OrganizationMemberRemovedEvent, + createDeltaOperation: DeferredEntitiesBuilder, + ) { + if (!this.connection) { + throw new Error('Not initialized'); + } + + const userTransformer = + this.options.userTransformer || defaultUserTransformer; + const { name, avatar_url: avatarUrl, email, login } = event.membership.user; + const org = event.organization.login; + const { headers } = await this.credentialsProvider.getCredentials({ + url: this.options.orgUrl, + }); + const client = graphql.defaults({ + baseUrl: this.options.gitHubConfig.apiBaseUrl, + headers, + }); + + const user = (await userTransformer( + { + name, + avatarUrl, + login, + email: email || undefined, + // we don't have this information in the event, so the refresh will handle that for us + organizationVerifiedDomainEmails: [], + }, + { + org, + client, + query: '', + }, + )) as Entity; + + const { added, removed } = createDeltaOperation(org, [user]); + await this.connection.applyMutation({ + type: 'delta', + removed, + added, + }); + } + private schedule(schedule: GithubOrgEntityProviderOptions['schedule']) { if (!schedule || schedule === 'manual') { return; diff --git a/plugins/catalog-backend-module-openapi/src/OpenApiRefProcessor.ts b/plugins/catalog-backend-module-openapi/src/OpenApiRefProcessor.ts index 6298aea77d..9dcadad61d 100644 --- a/plugins/catalog-backend-module-openapi/src/OpenApiRefProcessor.ts +++ b/plugins/catalog-backend-module-openapi/src/OpenApiRefProcessor.ts @@ -84,10 +84,14 @@ export class OpenApiRefProcessor implements CatalogProcessor { this.logger.debug(`Bundling OpenAPI specification from ${location.target}`); try { + const read = async (url: string) => { + const { buffer } = await this.reader.readUrl(url); + return await buffer(); + }; const bundledSpec = await bundleFileWithRefs( definition.toString(), location.target, - this.reader.read, + read, resolveUrl, ); diff --git a/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts b/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts index 903ac1ac6a..352ebf5df9 100644 --- a/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts +++ b/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts @@ -32,7 +32,6 @@ const integrations = ScmIntegrations.fromConfig(new ConfigReader({})); describe('PlaceholderProcessor', () => { const reader: jest.Mocked = { - read: jest.fn(), readTree: jest.fn(), search: jest.fn(), readUrl: jest.fn(), diff --git a/plugins/catalog-backend/src/modules/core/UrlReaderProcessor.test.ts b/plugins/catalog-backend/src/modules/core/UrlReaderProcessor.test.ts index 14c5710afc..c9575ce5cd 100644 --- a/plugins/catalog-backend/src/modules/core/UrlReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/modules/core/UrlReaderProcessor.test.ts @@ -194,7 +194,6 @@ describe('UrlReaderProcessor', () => { const logger = getVoidLogger(); const reader: jest.Mocked = { - read: jest.fn(), readUrl: jest.fn(), readTree: jest.fn(), search: jest.fn().mockImplementation(async () => []), diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx index 27b67102e4..adee03e298 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx @@ -133,7 +133,7 @@ describe('', () => { expect(button).toBeInTheDocument(); expect(button.closest('a')).toHaveAttribute( 'href', - '/catalog-graph?rootEntityRefs%5B%5D=b%3Ad%2Fc&maxDepth=2&unidirectional=true&mergeRelations=true&direction=LR', + '/catalog-graph?rootEntityRefs%5B%5D=b%3Ad%2Fc&unidirectional=true&mergeRelations=true&direction=LR', ); }); @@ -157,7 +157,7 @@ describe('', () => { expect(button).toBeInTheDocument(); expect(button.closest('a')).toHaveAttribute( 'href', - '/catalog-graph?rootEntityRefs%5B%5D=b%3Ad%2Fc&maxDepth=3&unidirectional=true&mergeRelations=false&direction=LR', + '/catalog-graph?rootEntityRefs%5B%5D=b%3Ad%2Fc&unidirectional=true&mergeRelations=false&direction=LR', ); }); diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx index 34d3174cb3..b9a15eb569 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx @@ -111,7 +111,6 @@ export const CatalogGraphCard = (props: { const catalogGraphParams = qs.stringify( { rootEntityRefs: [stringifyEntityRef(entity)], - maxDepth: maxDepth + 1, unidirectional, mergeRelations, kinds, diff --git a/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx b/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx index be784ae3e8..509a3f3741 100644 --- a/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx +++ b/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx @@ -14,23 +14,16 @@ * limitations under the License. */ -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; +import { renderInTestApp } from '@backstage/test-utils'; import CloudIcon from '@material-ui/icons/Cloud'; -import { render, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import React from 'react'; import { IconLink } from './IconLink'; describe('IconLink', () => { - it('should render an icon link', () => { - render( - - - , + it('should render an icon link', async () => { + await renderInTestApp( + , ); expect(screen.getByText('I am Link')).toBeInTheDocument(); diff --git a/plugins/code-climate/src/components/CodeClimateTable/CodeClimateTable.test.tsx b/plugins/code-climate/src/components/CodeClimateTable/CodeClimateTable.test.tsx index 70d34d0298..e5703a993e 100644 --- a/plugins/code-climate/src/components/CodeClimateTable/CodeClimateTable.test.tsx +++ b/plugins/code-climate/src/components/CodeClimateTable/CodeClimateTable.test.tsx @@ -15,18 +15,14 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; import { CodeClimateTable } from './CodeClimateTable'; import { mockData } from '../../api/mock/mock-api'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; describe('CodeClimateTable', () => { it('should render values in a table', async () => { - const table = await render( - - - , + const table = await renderInTestApp( + , ); expect(await table.findByText('3 months')).toBeInTheDocument(); expect(await table.findByText('88%')).toBeInTheDocument(); diff --git a/plugins/codescene/src/components/CodeScenePageComponent/CodeScenePageComponent.test.tsx b/plugins/codescene/src/components/CodeScenePageComponent/CodeScenePageComponent.test.tsx index d8ffca841d..a1d7690ca8 100644 --- a/plugins/codescene/src/components/CodeScenePageComponent/CodeScenePageComponent.test.tsx +++ b/plugins/codescene/src/components/CodeScenePageComponent/CodeScenePageComponent.test.tsx @@ -15,8 +15,6 @@ */ import React from 'react'; import { CodeScenePageComponent } from './CodeScenePageComponent'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { rootRouteRef } from '../../routes'; @@ -54,9 +52,7 @@ describe('CodeScenePageComponent', () => { it('should render', async () => { const rendered = await renderInTestApp( - - - + , { mountedRoutes: { diff --git a/plugins/codescene/src/components/CodeSceneProjectDetailsPage/CodeSceneProjectDetailsPage.test.tsx b/plugins/codescene/src/components/CodeSceneProjectDetailsPage/CodeSceneProjectDetailsPage.test.tsx index 9ace0f1224..8ea1a0235f 100644 --- a/plugins/codescene/src/components/CodeSceneProjectDetailsPage/CodeSceneProjectDetailsPage.test.tsx +++ b/plugins/codescene/src/components/CodeSceneProjectDetailsPage/CodeSceneProjectDetailsPage.test.tsx @@ -15,8 +15,6 @@ */ import React from 'react'; import { CodeSceneProjectDetailsPage } from './CodeSceneProjectDetailsPage'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { @@ -90,9 +88,7 @@ describe('CodeSceneProjectDetailsPage', () => { it('should render', async () => { const rendered = await renderInTestApp( - - - + , ); expect(rendered.getByText('CodeScene: test-project')).toBeInTheDocument(); diff --git a/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.test.tsx b/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.test.tsx index 4c9ee9ed42..40aee57312 100644 --- a/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.test.tsx +++ b/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.test.tsx @@ -15,8 +15,6 @@ */ import React from 'react'; import { ProjectsComponent } from './ProjectsComponent'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { rootRouteRef } from '../../routes'; @@ -90,9 +88,7 @@ describe('ProjectsComponent', () => { it('should render', async () => { const rendered = await renderInTestApp( - - - + , { mountedRoutes: { diff --git a/plugins/events-backend/src/service/InMemoryEventBroker.test.ts b/plugins/events-backend/src/service/InMemoryEventBroker.test.ts index 68a6f63a72..861cdda940 100644 --- a/plugins/events-backend/src/service/InMemoryEventBroker.test.ts +++ b/plugins/events-backend/src/service/InMemoryEventBroker.test.ts @@ -16,6 +16,7 @@ import { getVoidLogger } from '@backstage/backend-common'; import { TestEventSubscriber } from '@backstage/plugin-events-backend-test-utils'; +import { EventParams, EventSubscriber } from '@backstage/plugin-events-node'; import { InMemoryEventBroker } from './InMemoryEventBroker'; const logger = getVoidLogger(); @@ -63,4 +64,51 @@ describe('InMemoryEventBroker', () => { eventPayload: { test: 'topicC' }, }); }); + + it('logs errors from subscribers', async () => { + const topic = 'testTopic'; + + const subscriber1 = new (class Subscriber1 implements EventSubscriber { + supportsEventTopics() { + return [topic]; + } + async onEvent(event: EventParams) { + throw new Error(`NOPE ${event.eventPayload}`); + } + })(); + const subscriber2 = new (class Subscriber2 implements EventSubscriber { + supportsEventTopics() { + return [topic]; + } + async onEvent(event: EventParams) { + throw new Error(`NOPE ${event.eventPayload}`); + } + })(); + + const errorSpy = jest.spyOn(logger, 'error'); + const eventBroker = new InMemoryEventBroker(logger); + + eventBroker.subscribe(subscriber1); + await eventBroker.publish({ topic, eventPayload: '1' }); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith( + 'Subscriber "Subscriber1" failed to process event', + new Error('NOPE 1'), + ); + + eventBroker.subscribe(subscriber2); + await eventBroker.publish({ topic, eventPayload: '2' }); + + // With two subscribers we should not halt on the first error but call all subscribers + expect(errorSpy).toHaveBeenCalledTimes(3); + expect(errorSpy).toHaveBeenCalledWith( + 'Subscriber "Subscriber1" failed to process event', + new Error('NOPE 2'), + ); + expect(errorSpy).toHaveBeenCalledWith( + 'Subscriber "Subscriber2" failed to process event', + new Error('NOPE 2'), + ); + }); }); diff --git a/plugins/events-backend/src/service/InMemoryEventBroker.ts b/plugins/events-backend/src/service/InMemoryEventBroker.ts index 90c7d912fe..04be01f658 100644 --- a/plugins/events-backend/src/service/InMemoryEventBroker.ts +++ b/plugins/events-backend/src/service/InMemoryEventBroker.ts @@ -42,7 +42,18 @@ export class InMemoryEventBroker implements EventBroker { ); const subscribed = this.subscribers[params.topic] ?? []; - subscribed.forEach(subscriber => subscriber.onEvent(params)); + await Promise.all( + subscribed.map(async subscriber => { + try { + await subscriber.onEvent(params); + } catch (error) { + this.logger.error( + `Subscriber "${subscriber.constructor.name}" failed to process event`, + error, + ); + } + }), + ); } subscribe( diff --git a/plugins/explore-backend/README.md b/plugins/explore-backend/README.md index d1714806f4..3edf48249a 100644 --- a/plugins/explore-backend/README.md +++ b/plugins/explore-backend/README.md @@ -11,32 +11,32 @@ for these tools. ```bash # From your Backstage root directory -yarn add --cwd packages/backend @backstage/plugin-explore-backend +yarn add --cwd packages/backend @backstage/plugin-explore-backend @backstage/plugin-explore-common ``` ### Adding the plugin to your `packages/backend` You'll need to add the plugin to the router in your `backend` package. You can -do this by creating a file called `packages/backend/src/plugins/explore.ts` +do this by creating a file called `packages/backend/src/plugins/explore.ts` with the following content: ```ts import { createRouter, StaticExploreToolProvider, } from '@backstage/plugin-explore-backend'; +import { ExploreTool } from '@backstage/plugin-explore-common'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; // List of tools you want to surface in the Explore plugin "Tools" page. -const tools: ExploreTool[] = [ +const exploreTools: ExploreTool[] = [ { title: 'New Relic', - description:'new relic plugin', + description: 'new relic plugin', url: '/newrelic', image: 'https://i.imgur.com/L37ikrX.jpg', tags: ['newrelic', 'proxy', 'nerdGraph'], }, - ... ]; export default async function createPlugin( diff --git a/plugins/explore/README.md b/plugins/explore/README.md index 0204f34714..2388d97ac1 100644 --- a/plugins/explore/README.md +++ b/plugins/explore/README.md @@ -68,6 +68,7 @@ ready to make modifications, add the following code snippet to add the ```diff +import { ToolSearchResultListItem } from '@backstage/plugin-explore'; ++import BuildIcon from '@material-ui/icons/Build'; const SearchPage = () => { ... diff --git a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx index f3cca5642f..7c5259d96b 100644 --- a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx +++ b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx @@ -16,8 +16,6 @@ import { ExploreTool } from '@backstage/plugin-explore-common'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { exploreApiRef } from '../../api'; @@ -29,11 +27,9 @@ describe('', () => { }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - - - {children} - - + + {children} + ); beforeEach(() => { diff --git a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx index e158bcf6ad..5aa59a1cae 100644 --- a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx @@ -15,8 +15,6 @@ */ import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; import React from 'react'; import { gitOpsApiRef, GitOpsRestApi } from '../../api'; import ProfileCatalog from './ProfileCatalog'; @@ -47,11 +45,9 @@ describe('ProfileCatalog', () => { ); const { getByText } = await renderInTestApp( - - - - - , + + + , ); expect(getByText('Create GitOps-managed Cluster')).toBeInTheDocument(); diff --git a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.test.tsx b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.test.tsx index 7724a2ae23..3156662bf3 100644 --- a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.test.tsx +++ b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.test.tsx @@ -15,41 +15,33 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; import { GraphiQLBrowser } from './GraphiQLBrowser'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; -jest.mock('graphiql', () => () => ''); +jest.mock('graphiql', () => ({ GraphiQL: () => '' })); describe('GraphiQLBrowser', () => { - it('should render error text if there are no endpoints', () => { - const rendered = render( - - - , - ); + it('should render error text if there are no endpoints', async () => { + const rendered = await renderInTestApp(); rendered.getByText('No endpoints available'); }); - it('should render endpoint tabs', () => { - const rendered = render( - - - , + it('should render endpoint tabs', async () => { + const rendered = await renderInTestApp( + , ); rendered.getByText('Endpoint A'); rendered.getByText('Endpoint B'); diff --git a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx index 90f8edd4ad..9923facc7b 100644 --- a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx +++ b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx @@ -16,13 +16,9 @@ import React from 'react'; import { GraphiQLPage } from './GraphiQLPage'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; import { act } from '@testing-library/react'; -import { renderWithEffects, TestApiProvider } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { GraphQLBrowseApi, graphQlBrowseApiRef } from '../../lib/api'; -import { configApiRef } from '@backstage/core-plugin-api'; -import { ConfigReader } from '@backstage/core-app-api'; jest.mock('../GraphiQLBrowser', () => ({ GraphiQLBrowser: () => '', @@ -38,17 +34,9 @@ describe('GraphiQLPage', () => { }, }; - const rendered = await renderWithEffects( - - - - - , + const rendered = await renderInTestApp( + + , , ); act(() => { @@ -66,16 +54,9 @@ describe('GraphiQLPage', () => { }, }; - const rendered = await renderWithEffects( - - - - + const rendered = await renderInTestApp( + + , ); @@ -90,16 +71,9 @@ describe('GraphiQLPage', () => { }, }; - const rendered = await renderWithEffects( - - - - + const rendered = await renderInTestApp( + + , ); diff --git a/plugins/home/src/homePageComponents/HeaderWorldClock/HeaderWorldClock.test.tsx b/plugins/home/src/homePageComponents/HeaderWorldClock/HeaderWorldClock.test.tsx index db92fbb89f..03f19fb046 100644 --- a/plugins/home/src/homePageComponents/HeaderWorldClock/HeaderWorldClock.test.tsx +++ b/plugins/home/src/homePageComponents/HeaderWorldClock/HeaderWorldClock.test.tsx @@ -17,8 +17,6 @@ import { renderInTestApp } from '@backstage/test-utils'; import { HeaderWorldClock, ClockConfig } from './HeaderWorldClock'; import React from 'react'; -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; describe('HeaderWorldClock with valid Time Zones', () => { it('displays Time Zones as expected', async () => { @@ -42,9 +40,7 @@ describe('HeaderWorldClock with valid Time Zones', () => { ]; const rendered = await renderInTestApp( - - - , + , ); expect(rendered.getByText('NYC')).toBeInTheDocument(); @@ -59,9 +55,7 @@ describe('HeaderWorldClock with no Time Zones provided', () => { const clockConfigs: ClockConfig[] = []; const rendered = await renderInTestApp( - - - , + , ); expect(rendered.container).toBeEmptyDOMElement(); @@ -78,9 +72,7 @@ describe('HeaderWorldClock with invalid Time Zone', () => { ]; const rendered = await renderInTestApp( - - - , + , ); expect(rendered.getByText('GMT')).toBeInTheDocument(); @@ -105,12 +97,10 @@ describe('HeaderWorldClock with custom Time Format', () => { }; const rendered = await renderInTestApp( - - - , + , ); expect(rendered.getByText('09:10')).toBeInTheDocument(); diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx index 3e34eed6e3..59892e1475 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx @@ -16,11 +16,8 @@ import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; -import { render } from '@testing-library/react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; import { lighthouseApiRef, LighthouseRestApi, @@ -30,36 +27,18 @@ import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; import * as data from '../../__fixtures__/website-list-response.json'; import { AuditListForEntity } from './AuditListForEntity'; -import { ApiProvider } from '@backstage/core-app-api'; -import { errorApiRef } from '@backstage/core-plugin-api'; -import { TestApiRegistry } from '@backstage/test-utils'; - jest.mock('../../hooks/useWebsiteForEntity', () => ({ useWebsiteForEntity: jest.fn(), })); +const useWebsiteForEntityMock = useWebsiteForEntity as jest.Mock; + const websiteListResponse = data as WebsiteListResponse; const entityWebsite = websiteListResponse.items[0]; describe('', () => { - let apis: TestApiRegistry; - - const mockErrorApi: jest.Mocked = { - post: jest.fn(), - error$: jest.fn(), - }; - - beforeEach(() => { - apis = TestApiRegistry.from( - [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], - [errorApiRef, mockErrorApi], - ); - - (useWebsiteForEntity as jest.Mock).mockReturnValue({ - value: entityWebsite, - loading: false, - error: null, - }); + afterEach(() => { + jest.resetAllMocks(); }); const entity: Entity = { @@ -78,66 +57,55 @@ describe('', () => { }, }; - const subject = () => - render( - - - - - - - - - , - ); + const subject = () => ( + + + + + + ); it('renders the audit list for the entity', async () => { - const { findByText } = subject(); + useWebsiteForEntityMock.mockReturnValue({ + value: entityWebsite, + loading: false, + error: null, + }); + const { findByText } = await renderInTestApp(subject()); expect(await findByText(entityWebsite.url)).toBeInTheDocument(); }); - describe('where the data is loading', () => { - beforeEach(() => { - (useWebsiteForEntity as jest.Mock).mockReturnValue({ - value: null, - loading: true, - error: null, - }); + it('renders a Progress element where the data is loading', async () => { + useWebsiteForEntityMock.mockReturnValue({ + value: null, + loading: true, + error: null, }); - it('renders a Progress element', async () => { - const { findByTestId } = subject(); - expect(await findByTestId('progress')).toBeInTheDocument(); - }); + const { findByTestId } = await renderInTestApp(subject()); + expect(await findByTestId('progress')).toBeInTheDocument(); }); - describe('where there is an error loading data', () => { - beforeEach(() => { - (useWebsiteForEntity as jest.Mock).mockReturnValue({ - value: null, - loading: false, - error: 'error', - }); - }); - - it('renders nothing', async () => { - const { queryByTestId } = subject(); - expect(queryByTestId('AuditListTable')).toBeNull(); + it('renders nothing where there is an error loading data', async () => { + useWebsiteForEntityMock.mockReturnValue({ + value: null, + loading: false, + error: 'error', }); + const { queryByTestId } = await renderInTestApp(subject()); + expect(queryByTestId('AuditListTable')).toBeNull(); }); - describe('where there is not data', () => { - beforeEach(() => { - (useWebsiteForEntity as jest.Mock).mockReturnValue({ - value: null, - loading: false, - error: null, - }); + it('renders nothing where there is not data', async () => { + useWebsiteForEntityMock.mockReturnValue({ + value: null, + loading: false, + error: null, }); - it('renders nothing', async () => { - const { queryByTestId } = subject(); - expect(queryByTestId('AuditListTable')).toBeNull(); - }); + const { queryByTestId } = await renderInTestApp(subject()); + expect(queryByTestId('AuditListTable')).toBeNull(); }); }); diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx index ab1e2683b6..c59458f3f3 100644 --- a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx +++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx @@ -16,11 +16,8 @@ import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; -import { render } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; import { AuditCompleted, LighthouseCategoryId, @@ -64,22 +61,15 @@ describe('', () => { }, }; - const subject = () => - render( - - - - - - - , - ); - describe('where the last audit completed successfully', () => { const audit = entityWebsite.lastAudit as AuditCompleted; it('renders the performance data for the audit', async () => { - const { findByText } = subject(); + const { findByText } = await renderInTestApp( + + + , + ); expect(await findByText(audit.url)).toBeInTheDocument(); expect(await findByText(audit.status)).toBeInTheDocument(); for (const category of Object.keys(audit.categories)) { @@ -101,7 +91,11 @@ describe('', () => { }); it('renders the performance data for the audit', async () => { - const { findByText } = subject(); + const { findByText } = await renderInTestApp( + + + , + ); expect(await findByText('N/A')).toBeInTheDocument(); }); }); @@ -119,7 +113,11 @@ describe('', () => { }); it('renders the url and status of the audit', async () => { - const { findByText } = subject(); + const { findByText } = await renderInTestApp( + + + , + ); expect(await findByText(audit.url)).toBeInTheDocument(); expect(await findByText(audit.status)).toBeInTheDocument(); }); @@ -135,7 +133,11 @@ describe('', () => { }); it('renders a Progress element', async () => { - const { findByTestId } = subject(); + const { findByTestId } = await renderInTestApp( + + + , + ); expect(await findByTestId('progress')).toBeInTheDocument(); }); }); @@ -150,7 +152,11 @@ describe('', () => { }); it('renders nothing', async () => { - const { queryByTestId } = subject(); + const { queryByTestId } = await renderInTestApp( + + + , + ); expect(queryByTestId('AuditListTable')).toBeNull(); }); }); @@ -165,7 +171,11 @@ describe('', () => { }); it('renders nothing', async () => { - const { queryByTestId } = subject(); + const { queryByTestId } = await renderInTestApp( + + + , + ); expect(queryByTestId('AuditListTable')).toBeNull(); }); }); diff --git a/plugins/playlist/src/components/PlaylistCard/PlaylistCard.test.tsx b/plugins/playlist/src/components/PlaylistCard/PlaylistCard.test.tsx index 177cc23b81..5a34aa93e2 100644 --- a/plugins/playlist/src/components/PlaylistCard/PlaylistCard.test.tsx +++ b/plugins/playlist/src/components/PlaylistCard/PlaylistCard.test.tsx @@ -16,8 +16,6 @@ import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; import React from 'react'; import { rootRouteRef } from '../../routes'; @@ -26,20 +24,18 @@ import { PlaylistCard } from './PlaylistCard'; describe('', () => { it('renders playlist info', async () => { const rendered = await renderInTestApp( - - - , + , { mountedRoutes: { '/playlists': rootRouteRef, diff --git a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts index bfa4aa0507..15e5d25183 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts +++ b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts @@ -75,7 +75,6 @@ describe('fetch:cookiecutter', () => { const mockReader: UrlReader = { readUrl: jest.fn(), - read: jest.fn(), readTree: jest.fn(), search: jest.fn(), }; diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts index 98ac3a403f..56f85d002e 100644 --- a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts @@ -74,7 +74,6 @@ describe('fetch:rails', () => { }; const mockReader: UrlReader = { - read: jest.fn(), readUrl: jest.fn(), readTree: jest.fn(), search: jest.fn(), diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/helpers.test.ts index 50ec94e099..86d5f17803 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/helpers.test.ts @@ -39,7 +39,6 @@ describe('fetchContent helper', () => { const readTree = jest.fn(); const reader: UrlReader = { - read: jest.fn(), readUrl: jest.fn(), readTree, search: jest.fn(), diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts index 1595578666..0469ffec06 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts @@ -34,7 +34,6 @@ describe('fetch:plain', () => { ); const reader: UrlReader = { readUrl: jest.fn(), - read: jest.fn(), readTree: jest.fn(), search: jest.fn(), }; diff --git a/plugins/scaffolder/src/components/TaskPage/IconLink.test.tsx b/plugins/scaffolder/src/components/TaskPage/IconLink.test.tsx index f7ce850cd8..0b6092c37d 100644 --- a/plugins/scaffolder/src/components/TaskPage/IconLink.test.tsx +++ b/plugins/scaffolder/src/components/TaskPage/IconLink.test.tsx @@ -14,23 +14,15 @@ * limitations under the License. */ -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; +import { renderInTestApp } from '@backstage/test-utils'; import CloudIcon from '@material-ui/icons/Cloud'; -import { render } from '@testing-library/react'; import React from 'react'; import { IconLink } from './IconLink'; describe('IconLink', () => { - it('should render an icon link', () => { - const rendered = render( - - - , + it('should render an icon link', async () => { + const rendered = await renderInTestApp( + , ); expect(rendered.getByText('I am Link')).toBeInTheDocument(); diff --git a/plugins/search-backend-node/src/collators/NewlineDelimitedJsonCollatorFactory.test.ts b/plugins/search-backend-node/src/collators/NewlineDelimitedJsonCollatorFactory.test.ts index c08db8f28e..addcb87497 100644 --- a/plugins/search-backend-node/src/collators/NewlineDelimitedJsonCollatorFactory.test.ts +++ b/plugins/search-backend-node/src/collators/NewlineDelimitedJsonCollatorFactory.test.ts @@ -53,7 +53,6 @@ describe('DefaultCatalogCollatorFactory', () => { readable._read = () => {}; reader = { search: jest.fn(), - read: jest.fn(), readTree: jest.fn(), readUrl: jest.fn(), }; diff --git a/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx b/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx index ff1960aa4f..22cb5cf0a0 100644 --- a/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx +++ b/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx @@ -16,10 +16,8 @@ import { ErrorCell } from './ErrorCell'; import React from 'react'; -import { render } from '@testing-library/react'; import mockIssue from '../../api/mock/sentry-issue-mock.json'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; +import { renderInTestApp } from '@backstage/test-utils'; describe('Sentry error cell component', () => { it('should render a link that lead to Sentry', async () => { @@ -33,11 +31,7 @@ describe('Sentry error cell component', () => { userCount: 2, permalink: 'http://example.com', }; - const cell = render( - - - , - ); + const cell = await renderInTestApp(); const errorType = await cell.findByText('Exception'); expect(errorType.closest('a')).toHaveAttribute( 'href', @@ -53,11 +47,7 @@ describe('Sentry error cell component', () => { userCount: 2, permalink: 'http://example.com', }; - const cell = render( - - - , - ); + const cell = await renderInTestApp(); const errorType = await cell.findByText('Exception: Could not load cr...'); expect(errorType.closest('a')).toHaveAttribute( 'href', diff --git a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx index 2516d37788..647208f141 100644 --- a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx +++ b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx @@ -15,12 +15,10 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; import SentryIssuesTable from './SentryIssuesTable'; import { SentryIssue } from '../../api'; import mockIssue from '../../api/mock/sentry-issue-mock.json'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; +import { renderInTestApp } from '@backstage/test-utils'; describe('SentryIssuesTable', () => { it('should render headers in a table', async () => { @@ -35,19 +33,17 @@ describe('SentryIssuesTable', () => { userCount: 2, }, ]; - const table = await render( - - - , + const table = await renderInTestApp( + , ); expect(await table.findByText('Error')).toBeInTheDocument(); expect(await table.findByText('Graph')).toBeInTheDocument(); @@ -68,19 +64,17 @@ describe('SentryIssuesTable', () => { userCount: 202, }, ]; - const table = await render( - - - , + const table = await renderInTestApp( + , ); expect(await table.findByText('Exception')).toBeInTheDocument(); expect(await table.findByText('exception was thrown')).toBeInTheDocument(); @@ -99,19 +93,17 @@ describe('SentryIssuesTable', () => { userCount: 202, }, ]; - const table = await render( - - - , + const table = await renderInTestApp( + , ); expect(await table.findByText('Last 24h')).toBeInTheDocument(); }); diff --git a/plugins/sonarqube/src/components/SonarQubeContentPage/SonarQubeContentPage.test.tsx b/plugins/sonarqube/src/components/SonarQubeContentPage/SonarQubeContentPage.test.tsx index 22175d14bc..59258e5312 100644 --- a/plugins/sonarqube/src/components/SonarQubeContentPage/SonarQubeContentPage.test.tsx +++ b/plugins/sonarqube/src/components/SonarQubeContentPage/SonarQubeContentPage.test.tsx @@ -16,8 +16,6 @@ import { EntityProvider } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; import React from 'react'; import { isSonarQubeAvailable, @@ -44,7 +42,7 @@ const Providers = ({ kind: 'Component', }} > - {children} + {children}
); diff --git a/plugins/tech-radar/src/components/Radar/Radar.test.tsx b/plugins/tech-radar/src/components/Radar/Radar.test.tsx index 1b9e10ae5b..b76eb20a16 100644 --- a/plugins/tech-radar/src/components/Radar/Radar.test.tsx +++ b/plugins/tech-radar/src/components/Radar/Radar.test.tsx @@ -15,9 +15,7 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; +import { renderInTestApp } from '@backstage/test-utils'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; import Radar, { Props } from './Radar'; @@ -48,12 +46,8 @@ describe('Radar', () => { GetBBoxPolyfill.remove(); }); - it('should render', () => { - const rendered = render( - - - , - ); + it('should render', async () => { + const rendered = await renderInTestApp(); const svg = rendered.container.querySelector('svg'); expect(svg).not.toBeNull(); diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx index 9af1f5caff..ab8070c146 100644 --- a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx @@ -15,9 +15,7 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; +import { renderInTestApp } from '@backstage/test-utils'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; import RadarBubble, { Props } from './RadarBubble'; @@ -38,13 +36,11 @@ describe('RadarBubble', () => { GetBBoxPolyfill.remove(); }); - it('should render', () => { - const rendered = render( - - - - - , + it('should render', async () => { + const rendered = await renderInTestApp( + + + , ); expect(rendered.getByText(minProps.text)).toBeInTheDocument(); diff --git a/plugins/tech-radar/src/components/RadarComponent.test.tsx b/plugins/tech-radar/src/components/RadarComponent.test.tsx index 3d3b1bfd17..4bb207a1de 100644 --- a/plugins/tech-radar/src/components/RadarComponent.test.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.test.tsx @@ -15,10 +15,7 @@ */ import React from 'react'; -import { act, render, waitFor } from '@testing-library/react'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; -import { TestApiProvider, withLogCollector } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import GetBBoxPolyfill from '../utils/polyfills/getBBox'; import { RadarComponent } from './RadarComponent'; @@ -36,7 +33,9 @@ describe('RadarComponent', () => { }); class MockClient implements TechRadarApi { + constructor(private delay = 0) {} async load(): Promise { + await new Promise(resolve => setTimeout(resolve, this.delay)); return { entries: [], quadrants: [], @@ -45,87 +44,52 @@ describe('RadarComponent', () => { } } - const mockClient = new MockClient(); - it('should render a progress bar', async () => { jest.useFakeTimers(); - const errorApi = { post: () => {} }; - const { getByTestId, findByTestId } = render( - - - - - , + const { findByTestId } = await renderInTestApp( + + + , ); - act(() => { - jest.advanceTimersByTime(250); - }); - expect(getByTestId('progress')).toBeInTheDocument(); + jest.advanceTimersByTime(250); + await expect(findByTestId('progress')).resolves.toBeInTheDocument(); + + jest.advanceTimersByTime(250); + await expect(findByTestId('tech-radar-svg')).resolves.toBeInTheDocument(); - await findByTestId('tech-radar-svg'); jest.useRealTimers(); }); it('should call the errorApi if load fails', async () => { const errorApi = { post: jest.fn() }; + const mockClient = new MockClient(); jest .spyOn(mockClient, 'load') .mockRejectedValue(new Error('404 Page Not Found')); - const { queryByTestId } = render( - - - - - , + const { queryByTestId } = await renderInTestApp( + + + , ); - await waitFor(() => !queryByTestId('progress')); - expect(errorApi.post).toHaveBeenCalledTimes(1); expect(errorApi.post).toHaveBeenCalledWith(new Error('404 Page Not Found')); expect(queryByTestId('tech-radar-svg')).not.toBeInTheDocument(); }); - - it('should not render without errorApiRef', () => { - expect( - withLogCollector(['error'], () => { - expect(() => { - render( - - - - - , - ); - }).toThrow(); - }).error[0], - ).toMatchObject({ - detail: new Error('No implementation available for apiRef{core.error}'), - }); - }); }); diff --git a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.test.tsx b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.test.tsx index 7249b9f194..7f2dcbe6ac 100644 --- a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.test.tsx +++ b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.test.tsx @@ -15,11 +15,10 @@ */ import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; +import { screen } from '@testing-library/react'; import { Props, RadarDescription } from './RadarDescription'; +import { renderInTestApp } from '@backstage/test-utils'; const minProps: Props = { open: true, @@ -29,12 +28,8 @@ const minProps: Props = { }; describe('RadarDescription', () => { - it('should render', () => { - render( - - - , - ); + it('should render', async () => { + await renderInTestApp(); const radarDescription = screen.getByTestId('radar-description'); expect(radarDescription).toBeInTheDocument(); diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx index b1e468899d..36f304330d 100644 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx @@ -15,10 +15,9 @@ */ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; import userEvent from '@testing-library/user-event'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; import RadarEntry, { Props } from './RadarEntry'; @@ -45,13 +44,11 @@ describe('RadarEntry', () => { GetBBoxPolyfill.remove(); }); - it('should render link only', () => { - render( - - - - - , + it('should render link only', async () => { + await renderInTestApp( + + + , ); const radarEntry = screen.getByTestId('radar-entry'); @@ -62,12 +59,10 @@ describe('RadarEntry', () => { }); it('should render with description', async () => { - render( - - - - - , + await renderInTestApp( + + + , ); await userEvent.click(screen.getByRole('button')); @@ -80,17 +75,15 @@ describe('RadarEntry', () => { expect(screen.getByText(String(minProps.value))).toBeInTheDocument(); }); - it('should render blip with url equal to # if description present', () => { + it('should render blip with url equal to # if description present', async () => { const withUrl = { ...optionalProps, url: 'http://backstage.io', }; - render( - - - - - , + await renderInTestApp( + + + , ); expect(screen.getByRole('button')).toHaveAttribute('href', '#'); diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx index b76405f2bf..df56e2ad2c 100644 --- a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx @@ -15,9 +15,7 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; +import { renderInTestApp } from '@backstage/test-utils'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; import RadarFooter, { Props } from './RadarFooter'; @@ -36,13 +34,11 @@ describe('RadarFooter', () => { GetBBoxPolyfill.remove(); }); - it('should render', () => { - const rendered = render( - - - - - , + it('should render', async () => { + const rendered = await renderInTestApp( + + + , ); const radarFooter = rendered.getByTestId('radar-footer'); const { x, y } = minProps; diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx index d5636d74b8..2e0a5a225c 100644 --- a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx @@ -15,9 +15,7 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; +import { renderInTestApp } from '@backstage/test-utils'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; import RadarGrid, { Props } from './RadarGrid'; @@ -36,13 +34,11 @@ describe('RadarGrid', () => { GetBBoxPolyfill.remove(); }); - it('should render', () => { - const rendered = render( - - - - - , + it('should render', async () => { + const rendered = await renderInTestApp( + + + , ); expect(rendered.getByTestId('radar-grid-x-line')).toBeInTheDocument(); diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx index e0b5f09a63..de8bfd15d7 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx @@ -14,9 +14,7 @@ * limitations under the License. */ -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; -import { render } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; import React from 'react'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; @@ -47,13 +45,11 @@ describe('RadarLegend', () => { GetBBoxPolyfill.remove(); }); - it('should render', () => { - const rendered = render( - - - - - , + it('should render', async () => { + const rendered = await renderInTestApp( + + + , ); expect(rendered.getByTestId('radar-legend')).toBeInTheDocument(); diff --git a/plugins/tech-radar/src/components/RadarPage.test.tsx b/plugins/tech-radar/src/components/RadarPage.test.tsx index f0c2a04590..b65d9b31b2 100644 --- a/plugins/tech-radar/src/components/RadarPage.test.tsx +++ b/plugins/tech-radar/src/components/RadarPage.test.tsx @@ -20,8 +20,6 @@ import { TestApiProvider, wrapInTestApp, } from '@backstage/test-utils'; -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; import { act, render, waitFor } from '@testing-library/react'; import React from 'react'; import GetBBoxPolyfill from '../utils/polyfills/getBBox'; @@ -61,11 +59,9 @@ describe('RadarPage', () => { const { getByTestId, findByTestId } = render( wrapInTestApp( - - - - - , + + + , ), ); @@ -87,11 +83,9 @@ describe('RadarPage', () => { jest.spyOn(mockClient, 'load'); const { getByText, findByTestId } = await renderInTestApp( - - - - - , + + + , ); await expect(findByTestId('tech-radar-svg')).resolves.toBeInTheDocument(); @@ -111,11 +105,9 @@ describe('RadarPage', () => { jest.spyOn(mockClient, 'load'); const { findByTestId } = await renderInTestApp( - - - - - , + + + , ); await expect(findByTestId('tech-radar-svg')).resolves.toBeInTheDocument(); @@ -136,16 +128,14 @@ describe('RadarPage', () => { }; const { queryByTestId } = await renderInTestApp( - - - - - , + + + , ); await waitFor(() => !queryByTestId('progress')); diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx index fd03111b49..7557755ed2 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx @@ -15,9 +15,7 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; +import { renderInTestApp } from '@backstage/test-utils'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; import RadarPlot, { Props } from './RadarPlot'; @@ -49,13 +47,11 @@ describe('RadarPlot', () => { GetBBoxPolyfill.remove(); }); - it('should render', () => { - const rendered = render( - - - - - , + it('should render', async () => { + const rendered = await renderInTestApp( + + + , ); expect(rendered.getByTestId('radar-plot')).toBeInTheDocument(); diff --git a/plugins/techdocs-backend/src/service/standaloneServer.ts b/plugins/techdocs-backend/src/service/standaloneServer.ts index 5751a460bf..7e7e8b24f8 100644 --- a/plugins/techdocs-backend/src/service/standaloneServer.ts +++ b/plugins/techdocs-backend/src/service/standaloneServer.ts @@ -53,7 +53,6 @@ export async function startStandaloneServer( }); const discovery = SingleHostDiscovery.fromConfig(config); const mockUrlReader: jest.Mocked = { - read: jest.fn(), readUrl: jest.fn(), readTree: jest.fn(), search: jest.fn(), diff --git a/plugins/techdocs-node/src/stages/prepare/dir.test.ts b/plugins/techdocs-node/src/stages/prepare/dir.test.ts index ddefb40f03..164cfde687 100644 --- a/plugins/techdocs-node/src/stages/prepare/dir.test.ts +++ b/plugins/techdocs-node/src/stages/prepare/dir.test.ts @@ -45,7 +45,6 @@ const createMockEntity = (annotations: {}) => { const mockConfig = new ConfigReader({}); const mockUrlReader: jest.Mocked = { - read: jest.fn(), readUrl: jest.fn(), readTree: jest.fn(), search: jest.fn(), diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx index 808cb84ccf..4e8f331c7f 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx @@ -15,10 +15,8 @@ */ import React from 'react'; import { act } from '@testing-library/react'; -import { ThemeProvider } from '@material-ui/core'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { lightTheme } from '@backstage/theme'; import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; @@ -84,17 +82,15 @@ jest.mock('@backstage/core-components', () => ({ const Wrapper = ({ children }: { children: React.ReactNode }) => { return ( - - - {children} - - + + {children} + ); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx index 7db00f6590..3e5a4c73c3 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx @@ -16,9 +16,6 @@ import React from 'react'; import { act, waitFor } from '@testing-library/react'; -import { ThemeProvider } from '@material-ui/core'; - -import { lightTheme } from '@backstage/theme'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { techdocsApiRef, @@ -79,13 +76,11 @@ const Wrapper = ({ entityRef?: CompoundEntityRef; children: React.ReactNode; }) => ( - - - - {children} - - - + + + {children} + + ); describe('', () => { diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx index da193430d5..a3e03b7cf0 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx @@ -16,9 +16,6 @@ import React from 'react'; import { act, waitFor } from '@testing-library/react'; -import { ThemeProvider } from '@material-ui/core'; - -import { lightTheme } from '@backstage/theme'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { @@ -71,13 +68,11 @@ const Wrapper = ({ entityRef?: CompoundEntityRef; children: React.ReactNode; }) => ( - - - - {children} - - - + + + {children} + + ); describe('', () => { diff --git a/yarn.lock b/yarn.lock index 76a82ba929..0c11b2f2ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3392,6 +3392,7 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/backend-common@workspace:packages/backend-common" dependencies: + "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/cli-common": "workspace:^" @@ -9234,9 +9235,9 @@ __metadata: languageName: node linkType: hard -"@eslint/eslintrc@npm:^1.4.0": - version: 1.4.0 - resolution: "@eslint/eslintrc@npm:1.4.0" +"@eslint/eslintrc@npm:^1.4.1": + version: 1.4.1 + resolution: "@eslint/eslintrc@npm:1.4.1" dependencies: ajv: ^6.12.4 debug: ^4.3.2 @@ -9247,7 +9248,7 @@ __metadata: js-yaml: ^4.1.0 minimatch: ^3.1.2 strip-json-comments: ^3.1.1 - checksum: 73e39c833deafde8d8706e6fa9b52b6d99927c094ead8e405ea4174e8197ec24aac9ba88ae38cc8ad32eaccf07b9c7fc5dc70761d1fba6da41a928691447305f + checksum: cd3e5a8683db604739938b1c1c8b77927dc04fce3e28e0c88e7f2cd4900b89466baf83dfbad76b2b9e4d2746abdd00dd3f9da544d3e311633d8693f327d04cd7 languageName: node linkType: hard @@ -13957,11 +13958,11 @@ __metadata: linkType: hard "@types/codemirror@npm:^5.0.0": - version: 5.60.5 - resolution: "@types/codemirror@npm:5.60.5" + version: 5.60.6 + resolution: "@types/codemirror@npm:5.60.6" dependencies: "@types/tern": "*" - checksum: 423b6378b6415b626d9f54d37d69477f96940b30a695d4ec36402feb59a96f7ab42d1c17b8472fbb222a0966f6061deee234e37bcd3e34c75836e989dc95929d + checksum: 10b8c2a689f9ed610b0048d9d5ded1a7eb90c45014156b84b41c4f52f4720172414f7172d56f13c8a0b35d429704fb9d240414cf205776c807caa39105b33481 languageName: node linkType: hard @@ -14114,9 +14115,9 @@ __metadata: linkType: hard "@types/d3-force@npm:^3.0.0": - version: 3.0.3 - resolution: "@types/d3-force@npm:3.0.3" - checksum: 1082a07c028b454fccbb250381e1388818606faff0a7b81d80bc4ab53c5f86f2618fcbb12226402880476d96be9bb24c6c38f175c5e697e94ea4cf8af389be6c + version: 3.0.4 + resolution: "@types/d3-force@npm:3.0.4" + checksum: 779fb597fb41e7bc6a5e1b8969d500deb95c4a73428c7c268bf0ca6f3ed668dd2ed6aa652de7af14d2f9c192dad4f6e7badf2c5bc330624bd8405ac88440b278 languageName: node linkType: hard @@ -14169,9 +14170,9 @@ __metadata: linkType: hard "@types/d3-selection@npm:*, @types/d3-selection@npm:^3.0.1": - version: 3.0.3 - resolution: "@types/d3-selection@npm:3.0.3" - checksum: 24d4ac0b8f76bfdf5d3d90e6724ff7703057375ba482d2924f8d959e5481eaab55e0e39b5ca68a6d4e99bce100561333dee4146f497c9fc3605f1a308a7e713c + version: 3.0.4 + resolution: "@types/d3-selection@npm:3.0.4" + checksum: b1e486db7f020610c854996a96b2f59a43b89d48bc7ac2ef9479f6f6e6324d3b21d7dfc8031a0b6d049a84b769b14eca65d73fc42613ab0044ef67973a9fe6ca languageName: node linkType: hard @@ -14194,11 +14195,11 @@ __metadata: linkType: hard "@types/d3-shape@npm:^3.0.1": - version: 3.1.0 - resolution: "@types/d3-shape@npm:3.1.0" + version: 3.1.1 + resolution: "@types/d3-shape@npm:3.1.1" dependencies: "@types/d3-path": "*" - checksum: 19d43b9762cbff503806d014be1b8a5d22dd12c71635f1f0b30696d3b3b31528b978175afe2ba183542fd16e4fc740f56e7a84100ead00f7d95eaeb890ac8b78 + checksum: 8f1762ecdeb4833a3802be1c65363cbc7cca753d0b836a3855fde4ba12f8e6fc142dba3c5f6d669a9e89374cc6dc414464e4f2d04e72fafd4bc64819ce30bb63 languageName: node linkType: hard @@ -14210,12 +14211,12 @@ __metadata: linkType: hard "@types/d3-zoom@npm:^3.0.1": - version: 3.0.1 - resolution: "@types/d3-zoom@npm:3.0.1" + version: 3.0.2 + resolution: "@types/d3-zoom@npm:3.0.2" dependencies: "@types/d3-interpolate": "*" "@types/d3-selection": "*" - checksum: f813b3e6f27efc8508d6a2d575c21d0b1ba338160e498b901bc103d813c47ce609f691912a4ef73e9678cdbbdebbee81fecaf7432f777e9a8a54bf4087c21eba + checksum: c55b18ec5d5108c265d3e19299f51469e48d215f179a665f5797ac18bf8458dcbefd0c682154f8d8eb9d297a7486a5853115f72444af9cd02b3b4b5d1e4f9b22 languageName: node linkType: hard @@ -14326,7 +14327,18 @@ __metadata: languageName: node linkType: hard -"@types/express-serve-static-core@npm:*, @types/express-serve-static-core@npm:4.17.31, @types/express-serve-static-core@npm:^4.17.18, @types/express-serve-static-core@npm:^4.17.31, @types/express-serve-static-core@npm:^4.17.5": +"@types/express-serve-static-core@npm:*, @types/express-serve-static-core@npm:^4.17.18, @types/express-serve-static-core@npm:^4.17.31, @types/express-serve-static-core@npm:^4.17.5": + version: 4.17.32 + resolution: "@types/express-serve-static-core@npm:4.17.32" + dependencies: + "@types/node": "*" + "@types/qs": "*" + "@types/range-parser": "*" + checksum: 70ec1b8f386628850b315a7b9fd4240a5a70297b41ef1c39af65c8b9661d2c775cfff4686b491fd90e5b6eef43088af203700c5541aec0d063db0c6cbeff254c + languageName: node + linkType: hard + +"@types/express-serve-static-core@npm:4.17.31": version: 4.17.31 resolution: "@types/express-serve-static-core@npm:4.17.31" dependencies: @@ -14574,12 +14586,12 @@ __metadata: linkType: hard "@types/jest@npm:*, @types/jest@npm:^29.0.0": - version: 29.2.4 - resolution: "@types/jest@npm:29.2.4" + version: 29.2.5 + resolution: "@types/jest@npm:29.2.5" dependencies: expect: ^29.0.0 pretty-format: ^29.0.0 - checksum: 9deb4756fe1b438d41ff1aae7d6c216c9e49e5fe60f539f8edb6698ffeb530ff7b25d37e223439b03602ca3a7397c9c2e53e1a39c7bd616353472fce0cc04107 + checksum: d668470f00ec4cb8b8457f1fd90f7358fad8f22d74b85006dad6be522d6b9bf10f49f597e88d1d1a518d211c1b65be32a1f27f0e49ce0658d110a9206b8ea310 languageName: node linkType: hard @@ -14594,11 +14606,11 @@ __metadata: linkType: hard "@types/jquery@npm:^3.3.34": - version: 3.5.14 - resolution: "@types/jquery@npm:3.5.14" + version: 3.5.16 + resolution: "@types/jquery@npm:3.5.16" dependencies: "@types/sizzle": "*" - checksum: 159d6f804ed1a204b3f79f2d591a271d82e866bd45bd49fb6ef40561a25dbe0f47ec7815681b44cc2db5598425f72811e7e80ab0e983d980470998ac56feb375 + checksum: 13c995f15d1c2f1d322103dc1cb0a22b95eecc3e7546f00279b8731aea21d7ec04550af40e609ee48e755d4e11bf61c25b4aa9f53df3bcbec4b8fe8e81471732 languageName: node linkType: hard @@ -14746,9 +14758,9 @@ __metadata: linkType: hard "@types/luxon@npm:*, @types/luxon@npm:^3.0.0": - version: 3.1.0 - resolution: "@types/luxon@npm:3.1.0" - checksum: 04768029342ad76fc2a9339436c143ea64797b35cf9b03ddded787c13eae30f0ca1246e51c2c5365ed912f98068e13a967a3931b137eb4585248a0ad7ec3fa86 + version: 3.2.0 + resolution: "@types/luxon@npm:3.2.0" + checksum: 051bfbf841c6ce98728df6538342ce4aaddfcf0ec6e8b473e0195dcfc3ba6b1fe4b3ce17f2ba6d25344f41cb70032c1debe96da9e384e5f6e639665c1e6d368c languageName: node linkType: hard @@ -14830,11 +14842,11 @@ __metadata: linkType: hard "@types/morgan@npm:^1.9.0": - version: 1.9.3 - resolution: "@types/morgan@npm:1.9.3" + version: 1.9.4 + resolution: "@types/morgan@npm:1.9.4" dependencies: "@types/node": "*" - checksum: 0b9bc8641ce03f7176f617523b8da300e5d47225b1667396749950ac7fdfa1f990447d490648fbaff050c2b0ef5fa60c3f2f00c6b76efec06fe5148de5020813 + checksum: d1e99c66a43501dcdf6e94e013dfff4e6c152cbb5f782d954bb722d230a9c1c0fe06fab6df3f3dfa3547735a7598f9471633cc6813d794e9562fd022e217c6ae languageName: node linkType: hard @@ -15657,12 +15669,12 @@ __metadata: linkType: hard "@typescript-eslint/eslint-plugin@npm:^5.9.0": - version: 5.47.1 - resolution: "@typescript-eslint/eslint-plugin@npm:5.47.1" + version: 5.48.0 + resolution: "@typescript-eslint/eslint-plugin@npm:5.48.0" dependencies: - "@typescript-eslint/scope-manager": 5.47.1 - "@typescript-eslint/type-utils": 5.47.1 - "@typescript-eslint/utils": 5.47.1 + "@typescript-eslint/scope-manager": 5.48.0 + "@typescript-eslint/type-utils": 5.48.0 + "@typescript-eslint/utils": 5.48.0 debug: ^4.3.4 ignore: ^5.2.0 natural-compare-lite: ^1.4.0 @@ -15675,7 +15687,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 59fe719a8fbad14c37b8ce0dd292f6b8066bba370090f5e40eeab03033b97a12df1f1d0963c7070ac8cf4f7f319974fa6747e70932660055d222fa993c239b6a + checksum: cb9cd62fd56670414795e30d30c9fa11ec7ad3a8b0abda48dd17625053a1c26ba1767184b096149bdd0ccb457bec6392306f22211b75f802f4b27366398d16eb languageName: node linkType: hard @@ -15696,29 +15708,29 @@ __metadata: linkType: hard "@typescript-eslint/parser@npm:^5.9.0": - version: 5.47.1 - resolution: "@typescript-eslint/parser@npm:5.47.1" + version: 5.48.0 + resolution: "@typescript-eslint/parser@npm:5.48.0" dependencies: - "@typescript-eslint/scope-manager": 5.47.1 - "@typescript-eslint/types": 5.47.1 - "@typescript-eslint/typescript-estree": 5.47.1 + "@typescript-eslint/scope-manager": 5.48.0 + "@typescript-eslint/types": 5.48.0 + "@typescript-eslint/typescript-estree": 5.48.0 debug: ^4.3.4 peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: optional: true - checksum: 36806686a2c5cc60558c09b13e885861aa21ec6250539d8d3d3c8abb90b321662e57dacec44915c87726a5a0d74187b58a65880a0613024eaeeb7ad0197a345d + checksum: 41d5ce5c8742d286fb083523295a4f186e57bbe4e3da63b6b2de1edbafbcbf6d5225ed3405da2c56e2b0fe1d52bb72babc37508d2ee9b86f6fadad3c4a7950d0 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:5.47.1": - version: 5.47.1 - resolution: "@typescript-eslint/scope-manager@npm:5.47.1" +"@typescript-eslint/scope-manager@npm:5.48.0": + version: 5.48.0 + resolution: "@typescript-eslint/scope-manager@npm:5.48.0" dependencies: - "@typescript-eslint/types": 5.47.1 - "@typescript-eslint/visitor-keys": 5.47.1 - checksum: 73e2e2949b6e0122d89cfd44e1d24eda38d774899b834746700a4f1eb096effd1432c953f8be743a3ea3c7fc8fbf6e0882b11ee0f39b7ced6d8abf6a8665f1c8 + "@typescript-eslint/types": 5.48.0 + "@typescript-eslint/visitor-keys": 5.48.0 + checksum: 96c0ce33d613490690ae6f34e4152f05dbddf3196a6dec89afba4a63cd2d828ae23a98262920b521fe461e7655d38f3a01e9e43588c12392a27bf8cb4f8ae201 languageName: node linkType: hard @@ -15732,12 +15744,12 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:5.47.1": - version: 5.47.1 - resolution: "@typescript-eslint/type-utils@npm:5.47.1" +"@typescript-eslint/type-utils@npm:5.48.0": + version: 5.48.0 + resolution: "@typescript-eslint/type-utils@npm:5.48.0" dependencies: - "@typescript-eslint/typescript-estree": 5.47.1 - "@typescript-eslint/utils": 5.47.1 + "@typescript-eslint/typescript-estree": 5.48.0 + "@typescript-eslint/utils": 5.48.0 debug: ^4.3.4 tsutils: ^3.21.0 peerDependencies: @@ -15745,14 +15757,14 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 84a1e6c8fd47d419dc66430e31b818774d4c0329a5f355a5a9e8af94378be4c0c24a89916d5cc1380fdbb640693527b906c2e6adee486a2e6786cb5e08bd9eb3 + checksum: 0d57e3bbcaa46e29b588b86b2271341b264f063e71ff5b6d4d35f50f2fe11bd6cdc3c4c95d78493fd17673ecdbd712992b84da1600947ed3bf6ae09de7b99464 languageName: node linkType: hard -"@typescript-eslint/types@npm:5.47.1": - version: 5.47.1 - resolution: "@typescript-eslint/types@npm:5.47.1" - checksum: 9b3df8661862a8927ec29d21d6b5826cae7dd8b4797b5b54d66289d8abcf46081453a5cbaf9cc0a5b6c8249ca381dda61c2623da2a704e47f9d86175639a8cea +"@typescript-eslint/types@npm:5.48.0": + version: 5.48.0 + resolution: "@typescript-eslint/types@npm:5.48.0" + checksum: fa27bd9ec7ec5f256b79a371bb05cfbc26902b6a395f38b0cff0e281633ebd76775ad18e41be1bb156868859287295f6833a2a671da57c6347ac7c6bc08a553b languageName: node linkType: hard @@ -15763,12 +15775,12 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:5.47.1": - version: 5.47.1 - resolution: "@typescript-eslint/typescript-estree@npm:5.47.1" +"@typescript-eslint/typescript-estree@npm:5.48.0": + version: 5.48.0 + resolution: "@typescript-eslint/typescript-estree@npm:5.48.0" dependencies: - "@typescript-eslint/types": 5.47.1 - "@typescript-eslint/visitor-keys": 5.47.1 + "@typescript-eslint/types": 5.48.0 + "@typescript-eslint/visitor-keys": 5.48.0 debug: ^4.3.4 globby: ^11.1.0 is-glob: ^4.0.3 @@ -15777,7 +15789,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 803214a53fd4faf19b6b325dd4e8ddaa5bb1ebb9b52358d26ebeaeb86b431cea5bc09f3b43ca8abfdd3a72fdea667467a1abfda50cbad866696ec5739afae2ac + checksum: 2444632243111e51bc83b56140514cb5978bef4d7151fede0dfcff8808afc1ad335b0c60ca86c2811bcc82273b87e59e2e0360bf1b8c014825ff818a1731d127 languageName: node linkType: hard @@ -15799,31 +15811,31 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:5.47.1, @typescript-eslint/utils@npm:^5.10.0, @typescript-eslint/utils@npm:^5.13.0": - version: 5.47.1 - resolution: "@typescript-eslint/utils@npm:5.47.1" +"@typescript-eslint/utils@npm:5.48.0, @typescript-eslint/utils@npm:^5.10.0, @typescript-eslint/utils@npm:^5.13.0": + version: 5.48.0 + resolution: "@typescript-eslint/utils@npm:5.48.0" dependencies: "@types/json-schema": ^7.0.9 "@types/semver": ^7.3.12 - "@typescript-eslint/scope-manager": 5.47.1 - "@typescript-eslint/types": 5.47.1 - "@typescript-eslint/typescript-estree": 5.47.1 + "@typescript-eslint/scope-manager": 5.48.0 + "@typescript-eslint/types": 5.48.0 + "@typescript-eslint/typescript-estree": 5.48.0 eslint-scope: ^5.1.1 eslint-utils: ^3.0.0 semver: ^7.3.7 peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - checksum: 5167d26b8d5579de4d9aae36e18f980b33e01006ecc87cff59b761e15f69234092ac555bcf64a9f18d7c3e68a971df2a37b3912fc523c2586c2ba3f4544cc3d3 + checksum: 53f512ae61f72c2b29f2daf8adbc1f37c400cc71156557f69f0745b62c1265d99917a168245e2ee3d88ae458144818d1bf41ced4a764d7d9534b466b29d362fd languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:5.47.1": - version: 5.47.1 - resolution: "@typescript-eslint/visitor-keys@npm:5.47.1" +"@typescript-eslint/visitor-keys@npm:5.48.0": + version: 5.48.0 + resolution: "@typescript-eslint/visitor-keys@npm:5.48.0" dependencies: - "@typescript-eslint/types": 5.47.1 + "@typescript-eslint/types": 5.48.0 eslint-visitor-keys: ^3.3.0 - checksum: b4d1f4daa67e962d22c41325d9dcb6b2efde1caf354a2edb5bf682b92ab8c6205435d0b12f39ce9771955250e26f2a6f03adabb37e62e5aac8225691a59ef153 + checksum: 8d41fb7c93b79df415b43c31da7c9007074d78ab6f16c2d318c23e7974b578ce510f466a9584bd67c526367666974091cb5cfbf6670d29e36fb4ab2e57137515 languageName: node linkType: hard @@ -15837,9 +15849,9 @@ __metadata: languageName: node linkType: hard -"@uiw/codemirror-extensions-basic-setup@npm:4.19.4": - version: 4.19.4 - resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.19.4" +"@uiw/codemirror-extensions-basic-setup@npm:4.19.5": + version: 4.19.5 + resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.19.5" dependencies: "@codemirror/autocomplete": ^6.0.0 "@codemirror/commands": ^6.0.0 @@ -15856,19 +15868,19 @@ __metadata: "@codemirror/search": ">=6.0.0" "@codemirror/state": ">=6.0.0" "@codemirror/view": ">=6.0.0" - checksum: 041f6c67ad43a1991b2b5dea49bc907937b47de26f44d75de81c9937c9ac61193ed08e114b8dfacfa3de45fd05799d76cab0f529577671e61c04d52536287bb1 + checksum: adc49a56f592d34744c67eabdd878512a92387c8d10c6b4fa1cf947a8af1e5075308149d3bcc69778fa78a78f58a40e1b604615167b1374c917408cb50cfcccc languageName: node linkType: hard "@uiw/react-codemirror@npm:^4.9.3": - version: 4.19.4 - resolution: "@uiw/react-codemirror@npm:4.19.4" + version: 4.19.5 + resolution: "@uiw/react-codemirror@npm:4.19.5" dependencies: "@babel/runtime": ^7.18.6 "@codemirror/commands": ^6.1.0 "@codemirror/state": ^6.1.1 "@codemirror/theme-one-dark": ^6.0.0 - "@uiw/codemirror-extensions-basic-setup": 4.19.4 + "@uiw/codemirror-extensions-basic-setup": 4.19.5 codemirror: ^6.0.0 peerDependencies: "@babel/runtime": ">=7.11.0" @@ -15878,7 +15890,7 @@ __metadata: codemirror: ">=6.0.0" react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 88f931ae71f42609db4ca81453d9d68774a57938c06827423588c7e8dabc0c5e0dfce18ca790d5ca078b69b68372ab5a929344333ca7915b27f6a6f66470c5d8 + checksum: 5fb99c3599dabf9b52331016e6b42f790afc3858b3c8f034044f8fca9d52a1b6230a0df2ef21df4f2fae86856a4c1a3134d09a40ae3cd81084288dfab958f4d4 languageName: node linkType: hard @@ -19349,9 +19361,9 @@ __metadata: linkType: hard "core-js@npm:^3.4.1, core-js@npm:^3.6.5": - version: 3.27.0 - resolution: "core-js@npm:3.27.0" - checksum: 14bf6772e1c73a1cb3848ff63cae8d8f28354195e95ff550f2c4a7ae04650987691e37d6c9fe73789ffd97055b024fc7df825c203965f9a9b9aa6fb9f26f8571 + version: 3.27.1 + resolution: "core-js@npm:3.27.1" + checksum: d50b5f88aea4302512ad9446c18e90f4d35dea1e6d8d3f87337690677061565ff11a670f1e0c87de57aa6074375fbb25ed5784076c040d3c4de8b4bce7d2ebeb languageName: node linkType: hard @@ -21738,13 +21750,13 @@ __metadata: linkType: hard "eslint-config-prettier@npm:^8.3.0": - version: 8.5.0 - resolution: "eslint-config-prettier@npm:8.5.0" + version: 8.6.0 + resolution: "eslint-config-prettier@npm:8.6.0" peerDependencies: eslint: ">=7.0.0" bin: eslint-config-prettier: bin/cli.js - checksum: 0d0f5c32e7a0ad91249467ce71ca92394ccd343178277d318baf32063b79ea90216f4c81d1065d60f96366fdc60f151d4d68ae7811a58bd37228b84c2083f893 + checksum: ff0d0dfc839a556355422293428637e8d35693de58dabf8638bf0b6529131a109d0b2ade77521aa6e54573bb842d7d9d322e465dd73dd61c7590fa3834c3fa81 languageName: node linkType: hard @@ -21830,8 +21842,8 @@ __metadata: linkType: hard "eslint-plugin-jest@npm:^27.0.0": - version: 27.1.7 - resolution: "eslint-plugin-jest@npm:27.1.7" + version: 27.2.0 + resolution: "eslint-plugin-jest@npm:27.2.0" dependencies: "@typescript-eslint/utils": ^5.10.0 peerDependencies: @@ -21842,7 +21854,7 @@ __metadata: optional: true jest: optional: true - checksum: 1173e60450d8fa7a913d654e80e26176cc64c35f287d680d6fe4187a53974fd8c6883749924c8ea2a9328e295cba4d1be0b3047492653270e9341da1a3fec580 + checksum: 83554bb8eed289f903e2e03871a42a9b7b37b5e443d4a1501a42e004db49cddad894592ccec9840221b412ea3d2388428a1b11938031e5628d21c5a3210f1240 languageName: node linkType: hard @@ -22004,10 +22016,10 @@ __metadata: linkType: hard "eslint@npm:^8.6.0": - version: 8.30.0 - resolution: "eslint@npm:8.30.0" + version: 8.31.0 + resolution: "eslint@npm:8.31.0" dependencies: - "@eslint/eslintrc": ^1.4.0 + "@eslint/eslintrc": ^1.4.1 "@humanwhocodes/config-array": ^0.11.8 "@humanwhocodes/module-importer": ^1.0.1 "@nodelib/fs.walk": ^1.2.8 @@ -22048,7 +22060,7 @@ __metadata: text-table: ^0.2.0 bin: eslint: bin/eslint.js - checksum: b7525bb465b342665c3b8bab7e114d514ef1bc4e79f211c919863f9c71767e7412ec82383a22614a92d159783f91101018817000f7c61ce69a5e7015280cafaf + checksum: 5e5688bb864edc6b12d165849994812eefa67fb3fc44bb26f53659b63edcd8bcc68389d27cc6cc9e5b79ee22f24b6f311fa3ed047bddcafdec7d84c1b5561e4f languageName: node linkType: hard @@ -22744,9 +22756,9 @@ __metadata: linkType: hard "fast-json-patch@npm:^3.0.0-1": - version: 3.0.0-1 - resolution: "fast-json-patch@npm:3.0.0-1" - checksum: d4a8dff174357a90f3e2b79812df4bcc179143524e7eb1ab84a3bd5a80bd283f6be53996abe5c38527aeb28e994f0d03084c9ce5fcd8eb3e2881986e991105aa + version: 3.1.1 + resolution: "fast-json-patch@npm:3.1.1" + checksum: c4525b61b2471df60d4b025b4118b036d99778a93431aa44d1084218182841d82ce93056f0f3bbd731a24e6a8e69820128adf1873eb2199a26c62ef58d137833 languageName: node linkType: hard @@ -26658,9 +26670,9 @@ __metadata: linkType: hard "jose@npm:^4.10.0, jose@npm:^4.6.0": - version: 4.11.1 - resolution: "jose@npm:4.11.1" - checksum: cd15cba258d0fd20f6168631ce2e94fda8442df80e43c1033c523915cecdf390a1cc8efe0eab0c2d65935ca973d791c668aea80724d2aa9c2879d4e70f3081d7 + version: 4.11.2 + resolution: "jose@npm:4.11.2" + checksum: bc9b2271c31683603857b2fd2903afa29f826fffd4bcefc0845355ed86995def87eaadf502033207996109d76698c912e5a986e279326d9629321a50504fe466 languageName: node linkType: hard @@ -27139,11 +27151,11 @@ __metadata: linkType: hard "json5@npm:^2.1.2, json5@npm:^2.1.3, json5@npm:^2.2.0, json5@npm:^2.2.1": - version: 2.2.2 - resolution: "json5@npm:2.2.2" + version: 2.2.3 + resolution: "json5@npm:2.2.3" bin: json5: lib/cli.js - checksum: 9a878d66b72157b073cf0017f3e5d93ec209fa5943abcb38d37a54b208917c166bd473c26a24695e67a016ce65759aeb89946592991f8f9174fb96c8e2492683 + checksum: 2a7436a93393830bce797d4626275152e37e877b265e94ca69c99e3d20c2b9dab021279146a39cdb700e71b2dd32a4cebd1514cd57cee102b1af906ce5040349 languageName: node linkType: hard @@ -28324,9 +28336,9 @@ __metadata: linkType: hard "luxon@npm:^3.0.0": - version: 3.1.1 - resolution: "luxon@npm:3.1.1" - checksum: 388fb35d3c51a19d8b305a3338e7e74634b08562e7d2f9ed5c05a7f4bc9ee1c1ab6a2546b6d9c4c104516b24043757d65f5f3fe3d78b206fbf55a9586ab62230 + version: 3.2.0 + resolution: "luxon@npm:3.2.0" + checksum: 9a54fc68f1735259095299616c7d01967fb52a9c3500c6b31f97c477574122c62dc0f119d13abc04daaf545e1b8f37a63642b45cd04a240ff59987ceb6ec02c0 languageName: node linkType: hard @@ -32411,11 +32423,11 @@ __metadata: linkType: hard "prom-client@npm:^14.0.1": - version: 14.1.0 - resolution: "prom-client@npm:14.1.0" + version: 14.1.1 + resolution: "prom-client@npm:14.1.1" dependencies: tdigest: ^0.1.1 - checksum: 88f8e67020c0af07d09ab8f15caaa2a3d0810b01d903d0977d82947d3b0100a539db77bcccfde0bf2449a85060475f7fb05b64e7c34951c8a54b84240ae7ae81 + checksum: 7ff3e85636b1f0ca590b38ad7c7c5f9998b985c06a1c572ffffc69f052d5b4baf84c9124923e3824c63f78b5e8400081c6472173d04382ca296ebdf36d774a88 languageName: node linkType: hard