Merge master into adr-plugin-adjustments
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-plugin-api': patch
|
||||
---
|
||||
|
||||
Added `RootLifecycleService` and `rootLifecycleServiceRef`, as well as added a `labels` option to the existing `LifecycleServiceShutdownHook`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Removed unnecessary usage of `ThemeProvider` from the `ExampleComponent` test in the plugin template.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': patch
|
||||
'@backstage/backend-defaults': patch
|
||||
---
|
||||
|
||||
Include implementations for the new `rootLifecycleServiceRef`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-bazaar': patch
|
||||
---
|
||||
|
||||
Added `isBazaarAvailable` helper to be used with the `EntitySwitch`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-plugin-api': patch
|
||||
---
|
||||
|
||||
Migrate `UrlReader` into this package to gradually remove the dependency on backend-common.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-explore-backend': patch
|
||||
'@backstage/plugin-explore': patch
|
||||
---
|
||||
|
||||
Updated `README.md` examples
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-openapi': patch
|
||||
---
|
||||
|
||||
Updated internal usage of UrlReader interface.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Updated implementations for the new `RootLifecycleService`.
|
||||
+1
-1
@@ -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!
|
||||
|
||||
@@ -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, <entity>))
|
||||
} 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) {
|
||||
|
||||
@@ -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<Array<EntityProvider & EventSubscriber>> {
|
||||
const providers: Array<
|
||||
(EntityProvider & EventSubscriber) | Array<EntityProvider & EventSubscriber>
|
||||
> = [];
|
||||
- // 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<Buffer>;
|
||||
/**
|
||||
* 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<ReadUrlResponse>;
|
||||
|
||||
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
|
||||
/* Used to read a file tree and download as a directory. */
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
/* 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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -83,6 +83,11 @@ export const permissionsFactory: (
|
||||
options?: undefined,
|
||||
) => ServiceFactory<PermissionsService>;
|
||||
|
||||
// @public
|
||||
export const rootLifecycleFactory: (
|
||||
options?: undefined,
|
||||
) => ServiceFactory<LifecycleService>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const rootLoggerFactory: (
|
||||
options?: undefined,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<LifecycleServiceShutdownHook & { pluginId: string }> =
|
||||
[];
|
||||
|
||||
addShutdownHook(
|
||||
options: LifecycleServiceShutdownHook & { pluginId: string },
|
||||
): void {
|
||||
this.#shutdownTasks.push(options);
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
+3
-3
@@ -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');
|
||||
},
|
||||
@@ -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<LifecycleServiceShutdownHook> = [];
|
||||
|
||||
addShutdownHook(options: LifecycleServiceShutdownHook): void {
|
||||
this.#shutdownTasks.push(options);
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
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));
|
||||
},
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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<ReadTreeResponseFile[]>;
|
||||
archive(): Promise<NodeJS.ReadableStream>;
|
||||
dir(options?: ReadTreeResponseDirOptions): Promise<string>;
|
||||
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<Buffer>;
|
||||
};
|
||||
export { ReadTreeResponseFile };
|
||||
|
||||
// @public
|
||||
export type ReadUrlOptions = {
|
||||
etag?: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
export { ReadUrlOptions };
|
||||
|
||||
// @public
|
||||
export type ReadUrlResponse = {
|
||||
buffer(): Promise<Buffer>;
|
||||
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<Buffer>;
|
||||
};
|
||||
export { SearchResponseFile };
|
||||
|
||||
// @public
|
||||
export class ServerTokenManager implements TokenManager {
|
||||
@@ -755,13 +721,7 @@ export interface TokenManager {
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type UrlReader = {
|
||||
read(url: string): Promise<Buffer>;
|
||||
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
|
||||
};
|
||||
export { UrlReader };
|
||||
|
||||
// @public
|
||||
export type UrlReaderPredicateTuple = {
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -52,7 +52,7 @@ const createTemporaryDirectory = async (workDir: string): Promise<string> =>
|
||||
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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -45,18 +45,6 @@ export class UrlReaderPredicateMux implements UrlReader {
|
||||
this.readers.push(tuple);
|
||||
}
|
||||
|
||||
async read(url: string): Promise<Buffer> {
|
||||
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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Buffer>;
|
||||
|
||||
/**
|
||||
* 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<ReadUrlResponse>;
|
||||
|
||||
/**
|
||||
* Reads a full or partial file tree.
|
||||
*/
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
|
||||
/**
|
||||
* Searches for a file in a tree using a glob pattern.
|
||||
*/
|
||||
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<Buffer>;
|
||||
|
||||
/**
|
||||
* 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<ReadTreeResponseFile[]>;
|
||||
|
||||
/**
|
||||
* Returns the tree contents as a binary archive, using a stream.
|
||||
*/
|
||||
archive(): Promise<NodeJS.ReadableStream>;
|
||||
|
||||
/**
|
||||
* 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<string>;
|
||||
|
||||
/**
|
||||
* 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<Buffer>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options that control execution of {@link ReadTreeResponseFactory} methods.
|
||||
*
|
||||
@@ -315,66 +118,3 @@ export interface ReadTreeResponseFactory {
|
||||
options: FromReadableArrayOptions,
|
||||
): Promise<ReadTreeResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Buffer>;
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
discoveryFactory,
|
||||
httpRouterFactory,
|
||||
lifecycleFactory,
|
||||
rootLifecycleFactory,
|
||||
loggerFactory,
|
||||
permissionsFactory,
|
||||
rootLoggerFactory,
|
||||
@@ -45,6 +46,7 @@ export const defaultServiceFactories = [
|
||||
urlReaderFactory,
|
||||
httpRouterFactory,
|
||||
lifecycleFactory,
|
||||
rootLifecycleFactory,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
/// <reference types="node" />
|
||||
|
||||
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<LifecycleService, 'plugin'>;
|
||||
// @public (undocumented)
|
||||
export type LifecycleServiceShutdownHook = {
|
||||
fn: () => void | Promise<void>;
|
||||
labels?: Record<string, string>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -245,6 +249,56 @@ export interface PluginMetadataService {
|
||||
// @public (undocumented)
|
||||
const pluginMetadataServiceRef: ServiceRef<PluginMetadataService, 'plugin'>;
|
||||
|
||||
// @public
|
||||
export type ReadTreeOptions = {
|
||||
filter?(
|
||||
path: string,
|
||||
info?: {
|
||||
size: number;
|
||||
},
|
||||
): boolean;
|
||||
etag?: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ReadTreeResponse = {
|
||||
files(): Promise<ReadTreeResponseFile[]>;
|
||||
archive(): Promise<NodeJS.ReadableStream>;
|
||||
dir(options?: ReadTreeResponseDirOptions): Promise<string>;
|
||||
etag: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ReadTreeResponseDirOptions = {
|
||||
targetDir?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ReadTreeResponseFile = {
|
||||
path: string;
|
||||
content(): Promise<Buffer>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ReadUrlOptions = {
|
||||
etag?: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ReadUrlResponse = {
|
||||
buffer(): Promise<Buffer>;
|
||||
stream?(): Readable;
|
||||
etag?: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type RootLifecycleService = LifecycleService;
|
||||
|
||||
// @public (undocumented)
|
||||
const rootLifecycleServiceRef: ServiceRef<LifecycleService, 'root'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type RootLoggerService = LoggerService;
|
||||
|
||||
@@ -257,6 +311,24 @@ export type SchedulerService = PluginTaskScheduler;
|
||||
// @public (undocumented)
|
||||
const schedulerServiceRef: ServiceRef<PluginTaskScheduler, 'plugin'>;
|
||||
|
||||
// @public
|
||||
export type SearchOptions = {
|
||||
etag?: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type SearchResponse = {
|
||||
files: SearchResponseFile[];
|
||||
etag: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type SearchResponseFile = {
|
||||
url: string;
|
||||
content(): Promise<Buffer>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type ServiceFactory<TService = unknown> =
|
||||
| {
|
||||
@@ -307,9 +379,13 @@ export type TypesToServiceRef<T> = {
|
||||
[key in keyof T]: ServiceRef<T[key]>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type UrlReaderService = UrlReader;
|
||||
// @public
|
||||
export type UrlReaderService = {
|
||||
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
const urlReaderServiceRef: ServiceRef<UrlReader, 'plugin'>;
|
||||
const urlReaderServiceRef: ServiceRef<UrlReaderService, 'plugin'>;
|
||||
```
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -21,6 +21,9 @@ import { createServiceRef } from '../system/types';
|
||||
**/
|
||||
export type LifecycleServiceShutdownHook = {
|
||||
fn: () => void | Promise<void>;
|
||||
|
||||
/** Labels to help identify the shutdown hook */
|
||||
labels?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<RootLifecycleService>({
|
||||
id: 'core.rootLifecycle',
|
||||
scope: 'root',
|
||||
});
|
||||
@@ -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<ReadUrlResponse>;
|
||||
|
||||
/**
|
||||
* Reads a full or partial file tree.
|
||||
*/
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
|
||||
/**
|
||||
* Searches for a file in a tree using a glob pattern.
|
||||
*/
|
||||
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<Buffer>;
|
||||
|
||||
/**
|
||||
* 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<ReadTreeResponseFile[]>;
|
||||
|
||||
/**
|
||||
* Returns the tree contents as a binary archive, using a stream.
|
||||
*/
|
||||
archive(): Promise<NodeJS.ReadableStream>;
|
||||
|
||||
/**
|
||||
* 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<string>;
|
||||
|
||||
/**
|
||||
* 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<Buffer>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<Buffer>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
|
||||
@@ -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<Backend>();
|
||||
|
||||
+1
-7
@@ -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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ExampleComponent />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
await renderInTestApp(<ExampleComponent />);
|
||||
expect(screen.getByText('Welcome to {{ id }}!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -280,10 +280,25 @@ export class ConfigReader implements Config {
|
||||
|
||||
/** {@inheritdoc Config.getOptionalBoolean} */
|
||||
getOptionalBoolean(key: string): boolean | undefined {
|
||||
return this.readConfigValue(
|
||||
const value = this.readConfigValue<string | number | boolean>(
|
||||
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} */
|
||||
|
||||
@@ -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 = () => {
|
||||
<Context.Consumer>
|
||||
{value => (
|
||||
<div className={classes.root}>
|
||||
<MuiThemeProvider theme={textFieldTheme}>
|
||||
<TextField
|
||||
label="Project ID"
|
||||
variant="outlined"
|
||||
defaultValue={value.projectId}
|
||||
onChange={e =>
|
||||
value.setProjectId?.(parseInt(e.target.value, 10) || undefined)
|
||||
}
|
||||
/>
|
||||
</MuiThemeProvider>
|
||||
<TextField
|
||||
label="Project ID"
|
||||
variant="outlined"
|
||||
defaultValue={value.projectId}
|
||||
InputLabelProps={{ classes: { root: classes.label } }}
|
||||
InputProps={{ classes: { notchedOutline: classes.outline } }}
|
||||
onChange={e =>
|
||||
value.setProjectId?.(parseInt(e.target.value, 10) || undefined)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Context.Consumer>
|
||||
|
||||
@@ -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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<EntityProvider
|
||||
entity={{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'test' },
|
||||
}}
|
||||
>
|
||||
<AllureReportComponent />
|
||||
</EntityProvider>
|
||||
</ThemeProvider>,
|
||||
<EntityProvider
|
||||
entity={{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'test' },
|
||||
}}
|
||||
>
|
||||
<AllureReportComponent />
|
||||
</EntityProvider>,
|
||||
);
|
||||
expect(rendered.getByText('Missing Annotation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -255,7 +255,6 @@ describe('replaceReadme', () => {
|
||||
}),
|
||||
readTree: jest.fn(),
|
||||
search: jest.fn(),
|
||||
read: jest.fn(),
|
||||
};
|
||||
|
||||
const result = await replaceReadme(
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
<Grid item md={8} xs={12}>
|
||||
<EntityAboutCard variant="gridItem" />
|
||||
</Grid>
|
||||
|
||||
+ <Grid item sm={6}>
|
||||
+ <EntityBazaarInfoCard />
|
||||
+ </Grid>
|
||||
+ <EntitySwitch>
|
||||
+ <EntitySwitch.Case if={isBazaarAvailable}>
|
||||
+ <Grid item sm={6}>
|
||||
+ <EntityBazaarInfoCard />
|
||||
+ </Grid>
|
||||
+ </EntitySwitch.Case>
|
||||
+ </EntitySwitch>
|
||||
|
||||
{/* ...other entity-cards */}
|
||||
```
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
```ts
|
||||
/// <reference types="react" />
|
||||
|
||||
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<boolean>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const SortView: () => JSX.Element;
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export const isBazaarAvailable = async (
|
||||
entity: Entity,
|
||||
context: { apis: ApiHolder },
|
||||
): Promise<boolean> => {
|
||||
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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<void>;
|
||||
read(options?: { logger?: Logger }): Promise<void>;
|
||||
// (undocumented)
|
||||
supportsEventTopics(): string[];
|
||||
}
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<GroupEntity | undefined> => {
|
||||
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<GroupEntity | undefined> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<void>;
|
||||
@@ -224,6 +246,306 @@ export class GithubOrgEntityProvider implements EntityProvider {
|
||||
markCommitComplete();
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/plugin-events-node#EventSubscriber.onEvent} */
|
||||
async onEvent(params: EventParams): Promise<void> {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ const integrations = ScmIntegrations.fromConfig(new ConfigReader({}));
|
||||
|
||||
describe('PlaceholderProcessor', () => {
|
||||
const reader: jest.Mocked<UrlReader> = {
|
||||
read: jest.fn(),
|
||||
readTree: jest.fn(),
|
||||
search: jest.fn(),
|
||||
readUrl: jest.fn(),
|
||||
|
||||
@@ -194,7 +194,6 @@ describe('UrlReaderProcessor', () => {
|
||||
const logger = getVoidLogger();
|
||||
|
||||
const reader: jest.Mocked<UrlReader> = {
|
||||
read: jest.fn(),
|
||||
readUrl: jest.fn(),
|
||||
readTree: jest.fn(),
|
||||
search: jest.fn().mockImplementation(async () => []),
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('<CatalogGraphCard/>', () => {
|
||||
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('<CatalogGraphCard/>', () => {
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -111,7 +111,6 @@ export const CatalogGraphCard = (props: {
|
||||
const catalogGraphParams = qs.stringify(
|
||||
{
|
||||
rootEntityRefs: [stringifyEntityRef(entity)],
|
||||
maxDepth: maxDepth + 1,
|
||||
unidirectional,
|
||||
mergeRelations,
|
||||
kinds,
|
||||
|
||||
@@ -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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<IconLink
|
||||
href="https://example.com"
|
||||
text="I am Link"
|
||||
Icon={CloudIcon}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
it('should render an icon link', async () => {
|
||||
await renderInTestApp(
|
||||
<IconLink href="https://example.com" text="I am Link" Icon={CloudIcon} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('I am Link')).toBeInTheDocument();
|
||||
|
||||
@@ -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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CodeClimateTable codeClimateData={mockData} />
|
||||
</ThemeProvider>,
|
||||
const table = await renderInTestApp(
|
||||
<CodeClimateTable codeClimateData={mockData} />,
|
||||
);
|
||||
expect(await table.findByText('3 months')).toBeInTheDocument();
|
||||
expect(await table.findByText('88%')).toBeInTheDocument();
|
||||
|
||||
+1
-5
@@ -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(
|
||||
<ApiProvider apis={apis}>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CodeScenePageComponent />
|
||||
</ThemeProvider>
|
||||
<CodeScenePageComponent />
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
|
||||
+1
-5
@@ -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(
|
||||
<ApiProvider apis={apis}>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CodeSceneProjectDetailsPage />
|
||||
</ThemeProvider>
|
||||
<CodeSceneProjectDetailsPage />
|
||||
</ApiProvider>,
|
||||
);
|
||||
expect(rendered.getByText('CodeScene: test-project')).toBeInTheDocument();
|
||||
|
||||
@@ -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(
|
||||
<ApiProvider apis={apis}>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ProjectsComponent />
|
||||
</ThemeProvider>
|
||||
<ProjectsComponent />
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = () => {
|
||||
...
|
||||
|
||||
@@ -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('<ToolExplorerContent />', () => {
|
||||
};
|
||||
|
||||
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<TestApiProvider apis={[[exploreApiRef, exploreApi]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
</ThemeProvider>
|
||||
<TestApiProvider apis={[[exploreApiRef, exploreApi]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ApiProvider apis={apis}>
|
||||
<ProfileCatalog />
|
||||
</ApiProvider>
|
||||
</ThemeProvider>,
|
||||
<ApiProvider apis={apis}>
|
||||
<ProfileCatalog />
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(getByText('Create GitOps-managed Cluster')).toBeInTheDocument();
|
||||
|
||||
@@ -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', () => () => '<GraphiQL />');
|
||||
jest.mock('graphiql', () => ({ GraphiQL: () => '<GraphiQL />' }));
|
||||
|
||||
describe('GraphiQLBrowser', () => {
|
||||
it('should render error text if there are no endpoints', () => {
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<GraphiQLBrowser endpoints={[]} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
it('should render error text if there are no endpoints', async () => {
|
||||
const rendered = await renderInTestApp(<GraphiQLBrowser endpoints={[]} />);
|
||||
rendered.getByText('No endpoints available');
|
||||
});
|
||||
|
||||
it('should render endpoint tabs', () => {
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<GraphiQLBrowser
|
||||
endpoints={[
|
||||
{
|
||||
id: 'a',
|
||||
title: 'Endpoint A',
|
||||
async fetcher() {},
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
title: 'Endpoint B',
|
||||
async fetcher() {},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
it('should render endpoint tabs', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<GraphiQLBrowser
|
||||
endpoints={[
|
||||
{
|
||||
id: 'a',
|
||||
title: 'Endpoint A',
|
||||
async fetcher() {},
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
title: 'Endpoint B',
|
||||
async fetcher() {},
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
rendered.getByText('Endpoint A');
|
||||
rendered.getByText('Endpoint B');
|
||||
|
||||
@@ -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: () => '<GraphiQLBrowser />',
|
||||
@@ -38,17 +34,9 @@ describe('GraphiQLPage', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const rendered = await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[graphQlBrowseApiRef, loadingApi],
|
||||
[configApiRef, new ConfigReader({})],
|
||||
]}
|
||||
>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<GraphiQLPage />
|
||||
</ThemeProvider>
|
||||
,
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider apis={[[graphQlBrowseApiRef, loadingApi]]}>
|
||||
<GraphiQLPage />,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
act(() => {
|
||||
@@ -66,16 +54,9 @@ describe('GraphiQLPage', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const rendered = await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[graphQlBrowseApiRef, loadingApi],
|
||||
[configApiRef, new ConfigReader({})],
|
||||
]}
|
||||
>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<GraphiQLPage />
|
||||
</ThemeProvider>
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider apis={[[graphQlBrowseApiRef, loadingApi]]}>
|
||||
<GraphiQLPage />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
@@ -90,16 +71,9 @@ describe('GraphiQLPage', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const rendered = await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[graphQlBrowseApiRef, loadingApi],
|
||||
[configApiRef, new ConfigReader({})],
|
||||
]}
|
||||
>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<GraphiQLPage />
|
||||
</ThemeProvider>
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider apis={[[graphQlBrowseApiRef, loadingApi]]}>
|
||||
<GraphiQLPage />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<HeaderWorldClock clockConfigs={clockConfigs} />
|
||||
</ThemeProvider>,
|
||||
<HeaderWorldClock clockConfigs={clockConfigs} />,
|
||||
);
|
||||
|
||||
expect(rendered.getByText('NYC')).toBeInTheDocument();
|
||||
@@ -59,9 +55,7 @@ describe('HeaderWorldClock with no Time Zones provided', () => {
|
||||
const clockConfigs: ClockConfig[] = [];
|
||||
|
||||
const rendered = await renderInTestApp(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<HeaderWorldClock clockConfigs={clockConfigs} />
|
||||
</ThemeProvider>,
|
||||
<HeaderWorldClock clockConfigs={clockConfigs} />,
|
||||
);
|
||||
|
||||
expect(rendered.container).toBeEmptyDOMElement();
|
||||
@@ -78,9 +72,7 @@ describe('HeaderWorldClock with invalid Time Zone', () => {
|
||||
];
|
||||
|
||||
const rendered = await renderInTestApp(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<HeaderWorldClock clockConfigs={clockConfigs} />
|
||||
</ThemeProvider>,
|
||||
<HeaderWorldClock clockConfigs={clockConfigs} />,
|
||||
);
|
||||
|
||||
expect(rendered.getByText('GMT')).toBeInTheDocument();
|
||||
@@ -105,12 +97,10 @@ describe('HeaderWorldClock with custom Time Format', () => {
|
||||
};
|
||||
|
||||
const rendered = await renderInTestApp(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<HeaderWorldClock
|
||||
clockConfigs={clockConfigs}
|
||||
customTimeFormat={timeFormat}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
<HeaderWorldClock
|
||||
clockConfigs={clockConfigs}
|
||||
customTimeFormat={timeFormat}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(rendered.getByText('09:10')).toBeInTheDocument();
|
||||
|
||||
@@ -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('<AuditListTableForEntity />', () => {
|
||||
let apis: TestApiRegistry;
|
||||
|
||||
const mockErrorApi: jest.Mocked<typeof errorApiRef.T> = {
|
||||
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('<AuditListTableForEntity />', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const subject = () =>
|
||||
render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<MemoryRouter>
|
||||
<ApiProvider apis={apis}>
|
||||
<EntityProvider entity={entity}>
|
||||
<AuditListForEntity />
|
||||
</EntityProvider>
|
||||
</ApiProvider>
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const subject = () => (
|
||||
<TestApiProvider
|
||||
apis={[[lighthouseApiRef, new LighthouseRestApi('http://lighthouse')]]}
|
||||
>
|
||||
<EntityProvider entity={entity}>
|
||||
<AuditListForEntity />
|
||||
</EntityProvider>
|
||||
</TestApiProvider>
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('<LastLighthouseAuditCard />', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const subject = () =>
|
||||
render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<MemoryRouter>
|
||||
<EntityProvider entity={entity}>
|
||||
<LastLighthouseAuditCard />
|
||||
</EntityProvider>
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<EntityProvider entity={entity}>
|
||||
<LastLighthouseAuditCard />
|
||||
</EntityProvider>,
|
||||
);
|
||||
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('<LastLighthouseAuditCard />', () => {
|
||||
});
|
||||
|
||||
it('renders the performance data for the audit', async () => {
|
||||
const { findByText } = subject();
|
||||
const { findByText } = await renderInTestApp(
|
||||
<EntityProvider entity={entity}>
|
||||
<LastLighthouseAuditCard />
|
||||
</EntityProvider>,
|
||||
);
|
||||
expect(await findByText('N/A')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -119,7 +113,11 @@ describe('<LastLighthouseAuditCard />', () => {
|
||||
});
|
||||
|
||||
it('renders the url and status of the audit', async () => {
|
||||
const { findByText } = subject();
|
||||
const { findByText } = await renderInTestApp(
|
||||
<EntityProvider entity={entity}>
|
||||
<LastLighthouseAuditCard />
|
||||
</EntityProvider>,
|
||||
);
|
||||
expect(await findByText(audit.url)).toBeInTheDocument();
|
||||
expect(await findByText(audit.status)).toBeInTheDocument();
|
||||
});
|
||||
@@ -135,7 +133,11 @@ describe('<LastLighthouseAuditCard />', () => {
|
||||
});
|
||||
|
||||
it('renders a Progress element', async () => {
|
||||
const { findByTestId } = subject();
|
||||
const { findByTestId } = await renderInTestApp(
|
||||
<EntityProvider entity={entity}>
|
||||
<LastLighthouseAuditCard />
|
||||
</EntityProvider>,
|
||||
);
|
||||
expect(await findByTestId('progress')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -150,7 +152,11 @@ describe('<LastLighthouseAuditCard />', () => {
|
||||
});
|
||||
|
||||
it('renders nothing', async () => {
|
||||
const { queryByTestId } = subject();
|
||||
const { queryByTestId } = await renderInTestApp(
|
||||
<EntityProvider entity={entity}>
|
||||
<LastLighthouseAuditCard />
|
||||
</EntityProvider>,
|
||||
);
|
||||
expect(queryByTestId('AuditListTable')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -165,7 +171,11 @@ describe('<LastLighthouseAuditCard />', () => {
|
||||
});
|
||||
|
||||
it('renders nothing', async () => {
|
||||
const { queryByTestId } = subject();
|
||||
const { queryByTestId } = await renderInTestApp(
|
||||
<EntityProvider entity={entity}>
|
||||
<LastLighthouseAuditCard />
|
||||
</EntityProvider>,
|
||||
);
|
||||
expect(queryByTestId('AuditListTable')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('<PlaylistCard/>', () => {
|
||||
it('renders playlist info', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<PlaylistCard
|
||||
playlist={{
|
||||
id: 'id1',
|
||||
name: 'playlist-1',
|
||||
description: 'test description',
|
||||
owner: 'group:default/some-owner',
|
||||
public: true,
|
||||
entities: 3,
|
||||
followers: 2,
|
||||
isFollowing: false,
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
<PlaylistCard
|
||||
playlist={{
|
||||
id: 'id1',
|
||||
name: 'playlist-1',
|
||||
description: 'test description',
|
||||
owner: 'group:default/some-owner',
|
||||
public: true,
|
||||
entities: 3,
|
||||
followers: 2,
|
||||
isFollowing: false,
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/playlists': rootRouteRef,
|
||||
|
||||
@@ -75,7 +75,6 @@ describe('fetch:cookiecutter', () => {
|
||||
|
||||
const mockReader: UrlReader = {
|
||||
readUrl: jest.fn(),
|
||||
read: jest.fn(),
|
||||
readTree: jest.fn(),
|
||||
search: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -74,7 +74,6 @@ describe('fetch:rails', () => {
|
||||
};
|
||||
|
||||
const mockReader: UrlReader = {
|
||||
read: jest.fn(),
|
||||
readUrl: jest.fn(),
|
||||
readTree: jest.fn(),
|
||||
search: jest.fn(),
|
||||
|
||||
@@ -39,7 +39,6 @@ describe('fetchContent helper', () => {
|
||||
|
||||
const readTree = jest.fn();
|
||||
const reader: UrlReader = {
|
||||
read: jest.fn(),
|
||||
readUrl: jest.fn(),
|
||||
readTree,
|
||||
search: jest.fn(),
|
||||
|
||||
@@ -34,7 +34,6 @@ describe('fetch:plain', () => {
|
||||
);
|
||||
const reader: UrlReader = {
|
||||
readUrl: jest.fn(),
|
||||
read: jest.fn(),
|
||||
readTree: jest.fn(),
|
||||
search: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<IconLink
|
||||
href="https://example.com"
|
||||
text="I am Link"
|
||||
Icon={CloudIcon}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
it('should render an icon link', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<IconLink href="https://example.com" text="I am Link" Icon={CloudIcon} />,
|
||||
);
|
||||
|
||||
expect(rendered.getByText('I am Link')).toBeInTheDocument();
|
||||
|
||||
@@ -53,7 +53,6 @@ describe('DefaultCatalogCollatorFactory', () => {
|
||||
readable._read = () => {};
|
||||
reader = {
|
||||
search: jest.fn(),
|
||||
read: jest.fn(),
|
||||
readTree: jest.fn(),
|
||||
readUrl: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ErrorCell sentryIssue={testIssue} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const cell = await renderInTestApp(<ErrorCell sentryIssue={testIssue} />);
|
||||
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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ErrorCell sentryIssue={testIssue} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const cell = await renderInTestApp(<ErrorCell sentryIssue={testIssue} />);
|
||||
const errorType = await cell.findByText('Exception: Could not load cr...');
|
||||
expect(errorType.closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
|
||||
@@ -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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<SentryIssuesTable
|
||||
sentryIssues={issues}
|
||||
statsFor="24h"
|
||||
tableOptions={{
|
||||
padding: 'dense',
|
||||
paging: true,
|
||||
search: false,
|
||||
pageSize: 5,
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
const table = await renderInTestApp(
|
||||
<SentryIssuesTable
|
||||
sentryIssues={issues}
|
||||
statsFor="24h"
|
||||
tableOptions={{
|
||||
padding: 'dense',
|
||||
paging: true,
|
||||
search: false,
|
||||
pageSize: 5,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(await table.findByText('Error')).toBeInTheDocument();
|
||||
expect(await table.findByText('Graph')).toBeInTheDocument();
|
||||
@@ -68,19 +64,17 @@ describe('SentryIssuesTable', () => {
|
||||
userCount: 202,
|
||||
},
|
||||
];
|
||||
const table = await render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<SentryIssuesTable
|
||||
sentryIssues={issues}
|
||||
statsFor="24h"
|
||||
tableOptions={{
|
||||
padding: 'dense',
|
||||
paging: true,
|
||||
search: false,
|
||||
pageSize: 5,
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
const table = await renderInTestApp(
|
||||
<SentryIssuesTable
|
||||
sentryIssues={issues}
|
||||
statsFor="24h"
|
||||
tableOptions={{
|
||||
padding: 'dense',
|
||||
paging: true,
|
||||
search: false,
|
||||
pageSize: 5,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<SentryIssuesTable
|
||||
sentryIssues={issues}
|
||||
statsFor="24h"
|
||||
tableOptions={{
|
||||
padding: 'dense',
|
||||
paging: true,
|
||||
search: false,
|
||||
pageSize: 5,
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
const table = await renderInTestApp(
|
||||
<SentryIssuesTable
|
||||
sentryIssues={issues}
|
||||
statsFor="24h"
|
||||
tableOptions={{
|
||||
padding: 'dense',
|
||||
paging: true,
|
||||
search: false,
|
||||
pageSize: 5,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(await table.findByText('Last 24h')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={lightTheme}>{children}</ThemeProvider>
|
||||
{children}
|
||||
</EntityProvider>
|
||||
</TestApiProvider>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<Radar {...minProps} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
it('should render', async () => {
|
||||
const rendered = await renderInTestApp(<Radar {...minProps} />);
|
||||
|
||||
const svg = rendered.container.querySelector('svg');
|
||||
expect(svg).not.toBeNull();
|
||||
|
||||
@@ -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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<svg>
|
||||
<RadarBubble {...minProps} />
|
||||
</svg>
|
||||
</ThemeProvider>,
|
||||
it('should render', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<svg>
|
||||
<RadarBubble {...minProps} />
|
||||
</svg>,
|
||||
);
|
||||
|
||||
expect(rendered.getByText(minProps.text)).toBeInTheDocument();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user