Merge branch 'backstage:master' into feat/retry-in-useentity

This commit is contained in:
Yousif Al-Raheem
2021-07-22 11:05:44 +02:00
committed by GitHub
78 changed files with 2575 additions and 503 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kubernetes-backend': patch
---
Support assume role on kubernetes api configuration for AWS.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Increase the vertical padding of the sidebar search input field to match the height of the parent anchor tag. This prevents users from accidentally navigating to the search page when they actually wanted to use the search input directly.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Implement the etag functionality in the `readUrl` method of `FetchUrlReader`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Add search list item to display tech docs search results
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': patch
---
Implements tech docs collator to retrieve and expose search indexes for entities that have tech docs configured
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---
Adding a type filter to new search
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Add new built-in action ci:github-actions-dispatch
@@ -0,0 +1,7 @@
---
'@backstage/plugin-techdocs': patch
---
Only update the `path` when the content is updated.
If content and path are updated independently, the frontend rendering is triggered twice on each navigation: Once for the `path` change (with the old content) and once for the new content.
This might result in a flickering rendering that is caused by the async frontend preprocessing, and the fact that replacing the shadow dom content is expensive.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Refactor the techdocs transformers to return `Promise`s and await all transformations.
+88
View File
@@ -0,0 +1,88 @@
---
'@backstage/techdocs-common': minor
'@backstage/plugin-techdocs-backend': minor
---
Improve the annotation `backstage.io/techdocs-ref: dir:<relative-target>` that links to a path that is relative to the source of the annotated entity.
This annotation works with the basic and the recommended flow, however, it will be most useful with the basic approach.
This change remove the deprecation of the `dir` reference and provides first-class support for it.
In addition, this change removes the support of the deprecated `github`, `gitlab`, and `azure/api` locations from the `dir` reference preparer.
#### Example Usage
The annotation is convenient if the documentation is stored in the same location, i.e. the same git repository, as the `catalog-info.yaml`.
While it is still supported to add full URLs such as `backstage.io/techdocs-ref: url:https://...` for custom setups, documentation is mostly stored in the same repository as the entity definition.
By automatically resolving the target relative to the registration location of the entity, the configuration overhead for this default setup is minimized.
Since it leverages the `@backstage/integrations` package for the URL resolution, this is compatible with every supported source.
Consider the following examples:
1. "I have a repository with a single `catalog-info.yaml` and a TechDocs page in the root folder!"
```
https://github.com/backstage/example/tree/main/
|- catalog-info.yaml
| > apiVersion: backstage.io/v1alpha1
| > kind: Component
| > metadata:
| > name: example
| > annotations:
| > backstage.io/techdocs-ref: dir:. # -> same folder
| > spec: {}
|- docs/
|- mkdocs.yml
```
2. "I have a repository with a single `catalog-info.yaml` and my TechDocs page in located in a folder!"
```
https://bitbucket.org/my-owner/my-project/src/master/
|- catalog-info.yaml
| > apiVersion: backstage.io/v1alpha1
| > kind: Component
| > metadata:
| > name: example
| > annotations:
| > backstage.io/techdocs-ref: dir:./some-folder # -> subfolder
| > spec: {}
|- some-folder/
|- docs/
|- mkdocs.yml
```
3. "I have a mono repository that hosts multiple components!"
```
https://dev.azure.com/organization/project/_git/repository
|- my-1st-module/
|- catalog-info.yaml
| > apiVersion: backstage.io/v1alpha1
| > kind: Component
| > metadata:
| > name: my-1st-module
| > annotations:
| > backstage.io/techdocs-ref: dir:. # -> same folder
| > spec: {}
|- docs/
|- mkdocs.yml
|- my-2nd-module/
|- catalog-info.yaml
| > apiVersion: backstage.io/v1alpha1
| > kind: Component
| > metadata:
| > name: my-2nd-module
| > annotations:
| > backstage.io/techdocs-ref: dir:. # -> same folder
| > spec: {}
|- docs/
|- mkdocs.yml
|- catalog-info.yaml
| > apiVersion: backstage.io/v1alpha1
| > kind: Location
| > metadata:
| > name: example
| > spec:
| > targets:
| > - ./*/catalog-info.yaml
```
+2
View File
@@ -39,9 +39,11 @@
"@backstage/plugin-techdocs": "^0.10.0",
"@backstage/plugin-todo": "^0.1.5",
"@backstage/plugin-user-settings": "^0.3.0",
"@backstage/search-common": "^0.1.2",
"@backstage/theme": "^0.2.8",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@octokit/rest": "^18.5.3",
"@roadiehq/backstage-plugin-buildkite": "^1.0.6",
"@roadiehq/backstage-plugin-github-insights": "^1.1.20",
@@ -14,17 +14,20 @@
* limitations under the License.
*/
import React from 'react';
import React, { useState } from 'react';
import { makeStyles, Theme, Grid, List, Paper } from '@material-ui/core';
import Pagination from '@material-ui/lab/Pagination';
import { CatalogResultListItem } from '@backstage/plugin-catalog';
import {
SearchBar,
SearchFilter,
SearchResult,
SearchType,
DefaultResultListItem,
} from '@backstage/plugin-search';
import { Content, Header, Lifecycle, Page } from '@backstage/core-components';
import { DocsResultListItem } from '@backstage/plugin-techdocs';
import { SearchResultSet } from '@backstage/search-common';
const useStyles = makeStyles((theme: Theme) => ({
bar: {
@@ -34,15 +37,63 @@ const useStyles = makeStyles((theme: Theme) => ({
padding: theme.spacing(2),
},
filter: {
'& + &': {
marginTop: theme.spacing(2.5),
},
marginTop: theme.spacing(2.5),
},
}));
// TODO: Move this into the search plugin once pagination is natively supported.
// See: https://github.com/backstage/backstage/issues/6062
const SearchResultList = ({ results }: SearchResultSet) => {
const pageSize = 10;
const [page, setPage] = useState(1);
const changePage = (_: any, pageIndex: number) => {
setPage(pageIndex);
};
const pageAmount = Math.ceil((results.length || 0) / pageSize);
return (
<>
<List>
{results
.slice(pageSize * (page - 1), pageSize * page)
.map(({ type, document }) => {
switch (type) {
case 'software-catalog':
return (
<CatalogResultListItem
key={document.location}
result={document}
/>
);
case 'techdocs':
return (
<DocsResultListItem
key={document.location}
result={document}
/>
);
default:
return (
<DefaultResultListItem
key={document.location}
result={document}
/>
);
}
})}
</List>
<Pagination
count={pageAmount}
page={page}
onChange={changePage}
showFirstButton
showLastButton
/>
</>
);
};
const SearchPage = () => {
const classes = useStyles();
return (
<Page themeId="home">
<Header title="Search" subtitle={<Lifecycle alpha />} />
@@ -55,6 +106,11 @@ const SearchPage = () => {
</Grid>
<Grid item xs={3}>
<Paper className={classes.filters}>
<SearchType
values={['techdocs', 'software-catalog']}
name="type"
defaultValue="software-catalog"
/>
<SearchFilter.Select
className={classes.filter}
name="kind"
@@ -69,28 +125,7 @@ const SearchPage = () => {
</Grid>
<Grid item xs={9}>
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document }) => {
switch (type) {
case 'software-catalog':
return (
<CatalogResultListItem
key={document.location}
result={document}
/>
);
default:
return (
<DefaultResultListItem
key={document.location}
result={document}
/>
);
}
})}
</List>
)}
{({ results }) => <SearchResultList results={results} />}
</SearchResult>
</Grid>
</Grid>
@@ -15,12 +15,16 @@
*/
import { ConfigReader } from '@backstage/config';
import { NotFoundError, NotModifiedError } from '@backstage/errors';
import { msw } from '@backstage/test-utils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { getVoidLogger } from '../logging';
import { FetchUrlReader } from './FetchUrlReader';
import { DefaultReadTreeResponseFactory } from './tree';
const fetchUrlReader = new FetchUrlReader();
describe('FetchUrlReader', () => {
const worker = setupServer();
@@ -30,6 +34,39 @@ describe('FetchUrlReader', () => {
jest.clearAllMocks();
});
beforeEach(() => {
worker.use(
rest.get('https://backstage.io/some-resource', (req, res, ctx) => {
if (req.headers.get('if-none-match') === 'foo') {
return res(
ctx.status(304),
ctx.set('Content-Type', 'text/plain'),
ctx.set('etag', 'foo'),
);
}
return res(
ctx.status(200),
ctx.set('Content-Type', 'text/plain'),
ctx.set('etag', 'foo'),
ctx.body('content foo'),
);
}),
);
worker.use(
rest.get('https://backstage.io/not-exists', (_req, res, ctx) => {
return res(ctx.status(404));
}),
);
worker.use(
rest.get('https://backstage.io/error', (_req, res, ctx) => {
return res(ctx.status(500), ctx.body('An internal error occured'));
}),
);
});
it('factory should create a single entry with a predicate that matches config', async () => {
const entries = FetchUrlReader.factory({
config: new ConfigReader({
@@ -70,4 +107,55 @@ describe('FetchUrlReader', () => {
expect(predicate(new URL('https://a.examples.org:700/test'))).toBe(true);
expect(predicate(new URL('https://a.b.examples.org:700/test'))).toBe(true);
});
describe('read', () => {
it('should return etag from the response', async () => {
const buffer = await fetchUrlReader.read(
'https://backstage.io/some-resource',
);
expect(buffer.toString()).toBe('content foo');
});
it('should throw NotFound if server responds with 404', async () => {
await expect(
fetchUrlReader.read('https://backstage.io/not-exists'),
).rejects.toThrow(NotFoundError);
});
it('should throw Error if server responds with 500', async () => {
await expect(
fetchUrlReader.read('https://backstage.io/error'),
).rejects.toThrow(Error);
});
});
describe('readUrl', () => {
it('should throw NotModified if server responds with 304', async () => {
await expect(
fetchUrlReader.readUrl('https://backstage.io/some-resource', {
etag: 'foo',
}),
).rejects.toThrow(NotModifiedError);
});
it('should return etag from the response', async () => {
const response = await fetchUrlReader.readUrl(
'https://backstage.io/some-resource',
);
expect(response.etag).toBe('foo');
expect((await response.buffer()).toString()).toEqual('content foo');
});
it('should throw NotFound if server responds with 404', async () => {
await expect(
fetchUrlReader.readUrl('https://backstage.io/not-exists'),
).rejects.toThrow(NotFoundError);
});
it('should throw Error if server responds with 500', async () => {
await expect(
fetchUrlReader.readUrl('https://backstage.io/error'),
).rejects.toThrow(Error);
});
});
});
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { NotFoundError, NotModifiedError } from '@backstage/errors';
import fetch from 'cross-fetch';
import { NotFoundError } from '@backstage/errors';
import {
ReaderFactory,
ReadTreeResponse,
@@ -57,15 +57,34 @@ export class FetchUrlReader implements UrlReader {
};
async read(url: string): Promise<Buffer> {
const response = await this.readUrl(url);
return response.buffer();
}
async readUrl(
url: string,
options?: ReadUrlOptions,
): Promise<ReadUrlResponse> {
let response: Response;
try {
response = await fetch(url);
response = await fetch(url, {
headers: {
...(options?.etag && { 'If-None-Match': options.etag }),
},
});
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
if (response.status === 304) {
throw new NotModifiedError();
}
if (response.ok) {
return Buffer.from(await response.text());
return {
buffer: async () => Buffer.from(await response.text()),
etag: response.headers.get('ETag') ?? undefined,
};
}
const message = `could not read ${url}, ${response.status} ${response.statusText}`;
@@ -75,15 +94,6 @@ export class FetchUrlReader implements UrlReader {
throw new Error(message);
}
async readUrl(
url: string,
_options?: ReadUrlOptions,
): Promise<ReadUrlResponse> {
// TODO etag is not implemented yet.
const buffer = await this.read(url);
return { buffer: async () => buffer };
}
async readTree(): Promise<ReadTreeResponse> {
throw new Error('FetchUrlReader does not implement readTree');
}
+6
View File
@@ -21,6 +21,7 @@ import {
} from '@backstage/plugin-search-backend-node';
import { PluginEnvironment } from '../types';
import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend';
import { DefaultTechDocsCollator } from '@backstage/plugin-techdocs-backend';
export default async function createPlugin({
logger,
@@ -37,6 +38,11 @@ export default async function createPlugin({
collator: new DefaultCatalogCollator({ discovery }),
});
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: new DefaultTechDocsCollator({ discovery, logger }),
});
// The scheduler controls when documents are gathered from collators and sent
// to the search engine for indexing.
const { scheduler } = await indexBuilder.build();
@@ -44,7 +44,6 @@ const useStyles = makeStyles<BackstageTheme>(theme => {
drawerWidthOpen,
iconContainerWidth,
} = sidebarConfig;
return {
root: {
color: theme.palette.navigation.color,
@@ -97,6 +96,9 @@ const useStyles = makeStyles<BackstageTheme>(theme => {
fontWeight: 'bold',
fontSize: theme.typography.fontSize,
},
searchFieldHTMLInput: {
padding: `${theme.spacing(2)} 0 ${theme.spacing(2)}`,
},
searchContainer: {
width: drawerWidthOpen - iconContainerWidth,
},
@@ -271,6 +273,9 @@ export const SidebarSearchField = (props: SidebarSearchFieldProps) => {
disableUnderline: true,
className: classes.searchField,
}}
inputProps={{
className: classes.searchFieldHTMLInput,
}}
/>
</SidebarItem>
</div>
+25 -3
View File
@@ -15,6 +15,7 @@ import { GitHubIntegrationConfig } from '@backstage/integration';
import { GitLabIntegrationConfig } from '@backstage/integration';
import { Logger as Logger_2 } from 'winston';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { UrlReader } from '@backstage/backend-common';
import { Writable } from 'stream';
@@ -47,9 +48,15 @@ export class CommonGitPreparer implements PreparerBase {
//
// @public (undocumented)
export class DirectoryPreparer implements PreparerBase {
constructor(config: Config, logger: Logger_2, reader: UrlReader);
constructor(config: Config, _logger: Logger_2, reader: UrlReader);
// (undocumented)
prepare(entity: Entity): Promise<PreparerResponse>;
prepare(
entity: Entity,
options?: {
logger?: Logger_2;
etag?: string;
},
): Promise<PreparerResponse>;
}
// Warning: (ae-missing-release-tag) "GeneratorBase" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -189,7 +196,10 @@ export const getLastCommitTimestamp: (
// Warning: (ae-missing-release-tag) "getLocationForEntity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const getLocationForEntity: (entity: Entity) => ParsedLocationAnnotation;
export const getLocationForEntity: (
entity: Entity,
scmIntegration: ScmIntegrationRegistry,
) => ParsedLocationAnnotation;
// Warning: (ae-missing-release-tag) "getTokenForGitRepo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -355,6 +365,18 @@ export type TechDocsMetadata = {
etag: string;
};
// Warning: (ae-missing-release-tag) "transformDirLocation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export const transformDirLocation: (
entity: Entity,
dirAnnotation: ParsedLocationAnnotation,
scmIntegrations: ScmIntegrationRegistry,
) => {
type: 'dir' | 'url';
target: string;
};
// Warning: (ae-missing-release-tag) "UrlPreparer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
+165 -1
View File
@@ -19,14 +19,26 @@ import {
SearchResponse,
UrlReader,
} from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { Entity, getEntitySourceLocation } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import os from 'os';
import path from 'path';
import { Readable } from 'stream';
import {
getDocFilesFromRepository,
getLocationForEntity,
parseReferenceAnnotation,
transformDirLocation,
} from './helpers';
jest.mock('@backstage/catalog-model', () => ({
...jest.requireActual('@backstage/catalog-model'),
getEntitySourceLocation: jest.fn(),
}));
const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
const entityBase: Entity = {
metadata: {
namespace: 'default',
@@ -81,6 +93,10 @@ const mockEntityWithBadAnnotation: Entity = {
},
};
const scmIntegrations = ScmIntegrations.fromConfig(new ConfigReader({}));
afterEach(() => jest.resetAllMocks());
describe('parseReferenceAnnotation', () => {
it('should parse annotation', () => {
const parsedLocationAnnotation = parseReferenceAnnotation(
@@ -109,10 +125,158 @@ describe('parseReferenceAnnotation', () => {
});
});
describe('transformDirLocation', () => {
it.each`
techdocsRef | target
${'dir:.'} | ${'https://my-url/folder/'}
${'dir:./sub-folder'} | ${'https://my-url/folder/sub-folder'}
`(
'should transform "$techdocsRef" for url type locations',
({ techdocsRef, target }) => {
(getEntitySourceLocation as jest.Mock).mockReturnValue({
type: 'url',
target: 'https://my-url/folder/',
});
const entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'backstage.io/techdocs-ref': techdocsRef,
},
},
};
const result = transformDirLocation(
entity,
parseReferenceAnnotation('backstage.io/techdocs-ref', entity),
scmIntegrations,
);
expect(result).toEqual({ type: 'url', target });
},
);
it.each`
techdocsRef | target
${'dir:.'} | ${path.join(rootDir, 'working-copy')}
${'dir:./sub-folder'} | ${path.join(rootDir, 'working-copy', 'sub-folder')}
`(
'should transform "$techdocsRef" for file type locations',
({ techdocsRef, target }) => {
(getEntitySourceLocation as jest.Mock).mockReturnValue({
type: 'file',
target: path.join(rootDir, 'working-copy', 'catalog-info.yaml'),
});
const entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'backstage.io/techdocs-ref': techdocsRef,
},
},
};
const result = transformDirLocation(
entity,
parseReferenceAnnotation('backstage.io/techdocs-ref', entity),
scmIntegrations,
);
expect(result).toEqual({ type: 'dir', target });
},
);
it('should reject unsafe file location', () => {
(getEntitySourceLocation as jest.Mock).mockReturnValue({
type: 'file',
target: '/tmp/catalog-info.yaml',
});
const entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'backstage.io/techdocs-ref': 'dir:..',
},
},
};
expect(() =>
transformDirLocation(
entity,
parseReferenceAnnotation('backstage.io/techdocs-ref', entity),
scmIntegrations,
),
).toThrow(
/Relative path is not allowed to refer to a directory outside its parent/,
);
});
it('should reject other location types', () => {
(getEntitySourceLocation as jest.Mock).mockReturnValue({
type: 'other',
target: '/',
});
const entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'backstage.io/techdocs-ref': 'dir:.',
},
},
};
expect(() =>
transformDirLocation(
entity,
parseReferenceAnnotation('backstage.io/techdocs-ref', entity),
scmIntegrations,
),
).toThrow(/Unable to resolve location type other/);
});
});
describe('getLocationForEntity', () => {
it('should handle dir locations', () => {
(getEntitySourceLocation as jest.Mock).mockReturnValue({
type: 'url',
target: 'https://my-url/folder/',
});
const entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'backstage.io/techdocs-ref': 'dir:.',
},
},
};
const parsedLocationAnnotation = getLocationForEntity(
entity,
scmIntegrations,
);
expect(parsedLocationAnnotation.type).toBe('url');
expect(parsedLocationAnnotation.target).toBe('https://my-url/folder/');
});
it('should get location for entity', () => {
const parsedLocationAnnotation = getLocationForEntity(
mockEntityWithAnnotation,
scmIntegrations,
);
expect(parsedLocationAnnotation.type).toBe('url');
expect(parsedLocationAnnotation.target).toBe(
+70 -14
View File
@@ -14,10 +14,19 @@
* limitations under the License.
*/
import { Git, UrlReader } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { Entity, parseLocationReference } from '@backstage/catalog-model';
import {
Git,
resolveSafeChildPath,
UrlReader,
} from '@backstage/backend-common';
import {
Entity,
getEntitySourceLocation,
parseLocationReference,
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import fs from 'fs-extra';
import parseGitUrl from 'git-url-parse';
import os from 'os';
@@ -50,30 +59,77 @@ export const parseReferenceAnnotation = (
};
};
/**
* TechDocs references of type `dir` are relative the source location of the entity.
* This function transforms relative references to absolute ones, based on the
* location the entity was ingested from. If the entity was registered by a `url`
* location, it returns a `url` location with a resolved target that points to the
* targeted subfolder. If the entity was registered by a `file` location, it returns
* an absolute `dir` location.
*
* @param entity - the entity with annotations
* @param dirAnnotation - the parsed techdocs-ref annotation of type 'dir'
* @param scmIntegrations - access to the scmIntegration to do url transformations
* @throws if the entity doesn't specify a `dir` location or is ingested from an unsupported location.
* @returns the transformed location with an absolute target.
*/
export const transformDirLocation = (
entity: Entity,
dirAnnotation: ParsedLocationAnnotation,
scmIntegrations: ScmIntegrationRegistry,
): { type: 'dir' | 'url'; target: string } => {
const location = getEntitySourceLocation(entity);
switch (location.type) {
case 'url': {
const target = scmIntegrations.resolveUrl({
url: dirAnnotation.target,
base: location.target,
});
return {
type: 'url',
target,
};
}
case 'file': {
// only permit targets in the same folder as the target of the `file` location!
const target = resolveSafeChildPath(
path.dirname(location.target),
dirAnnotation.target,
);
return {
type: 'dir',
target,
};
}
default:
throw new InputError(`Unable to resolve location type ${location.type}`);
}
};
export const getLocationForEntity = (
entity: Entity,
scmIntegration: ScmIntegrationRegistry,
): ParsedLocationAnnotation => {
const { type, target } = parseReferenceAnnotation(
const annotation = parseReferenceAnnotation(
'backstage.io/techdocs-ref',
entity,
);
switch (type) {
switch (annotation.type) {
case 'github':
case 'gitlab':
case 'azure/api':
case 'url':
return { type, target };
return annotation;
case 'dir':
if (path.isAbsolute(target)) {
return { type, target };
}
return parseReferenceAnnotation(
'backstage.io/managed-by-location',
entity,
);
return transformDirLocation(entity, annotation, scmIntegration);
default:
throw new Error(`Invalid reference annotation ${type}`);
throw new Error(`Invalid reference annotation ${annotation.type}`);
}
};
@@ -15,7 +15,6 @@
*/
import { getVoidLogger, UrlReader } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { checkoutGitRepository } from '../../helpers';
import { DirectoryPreparer } from './dir';
function normalizePath(path: string) {
@@ -27,8 +26,6 @@ function normalizePath(path: string) {
jest.mock('../../helpers', () => ({
...jest.requireActual<{}>('../../helpers'),
checkoutGitRepository: jest.fn(() => '/tmp/backstage-repo/org/name/branch/'),
getLastCommitTimestamp: jest.fn(() => 12345678),
}));
const logger = getVoidLogger();
@@ -71,7 +68,7 @@ describe('directory preparer', () => {
expect(normalizePath(preparedDir)).toEqual('/directory/our-documentation');
});
it('should merge managed-by-location and techdocs-ref when techdocs-ref is absolute', async () => {
it('should reject when techdocs-ref is absolute', async () => {
const directoryPreparer = new DirectoryPreparer(
mockConfig,
logger,
@@ -84,11 +81,12 @@ describe('directory preparer', () => {
'backstage.io/techdocs-ref': 'dir:/our-documentation/techdocs',
});
const { preparedDir } = await directoryPreparer.prepare(mockEntity);
expect(normalizePath(preparedDir)).toEqual('/our-documentation/techdocs');
await expect(directoryPreparer.prepare(mockEntity)).rejects.toThrow(
/Relative path is not allowed to refer to a directory outside its parent/,
);
});
it('should merge managed-by-location and techdocs-ref when managed-by-location is a git repository', async () => {
it('should reject when managed-by-location is a git repository', async () => {
const directoryPreparer = new DirectoryPreparer(
mockConfig,
logger,
@@ -101,10 +99,8 @@ describe('directory preparer', () => {
'backstage.io/techdocs-ref': 'dir:./docs',
});
const { preparedDir } = await directoryPreparer.prepare(mockEntity);
expect(normalizePath(preparedDir)).toEqual(
'/tmp/backstage-repo/org/name/branch/docs',
await expect(directoryPreparer.prepare(mockEntity)).rejects.toThrow(
/Unable to resolve location type github/,
);
expect(checkoutGitRepository).toHaveBeenCalledTimes(1);
});
});
@@ -15,104 +15,68 @@
*/
import { UrlReader } from '@backstage/backend-common';
import { InputError, NotModifiedError } from '@backstage/errors';
import { Entity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import parseGitUrl from 'git-url-parse';
import path from 'path';
import { Logger } from 'winston';
import { InputError } from '@backstage/errors';
import {
checkoutGitRepository,
getLastCommitTimestamp,
parseReferenceAnnotation,
} from '../../helpers';
ScmIntegrationRegistry,
ScmIntegrations,
} from '@backstage/integration';
import { Logger } from 'winston';
import { parseReferenceAnnotation, transformDirLocation } from '../../helpers';
import { PreparerBase, PreparerResponse } from './types';
export class DirectoryPreparer implements PreparerBase {
constructor(
private readonly config: Config,
private readonly logger: Logger,
private readonly reader: UrlReader,
) {
this.config = config;
this.logger = logger;
private readonly scmIntegrations: ScmIntegrationRegistry;
private readonly reader: UrlReader;
constructor(config: Config, _logger: Logger, reader: UrlReader) {
this.reader = reader;
this.scmIntegrations = ScmIntegrations.fromConfig(config);
}
private async resolveManagedByLocationToDir(
async prepare(
entity: Entity,
options?: { etag?: string },
options?: { logger?: Logger; etag?: string },
): Promise<PreparerResponse> {
const { type, target } = parseReferenceAnnotation(
'backstage.io/managed-by-location',
const annotation = parseReferenceAnnotation(
'backstage.io/techdocs-ref',
entity,
);
this.logger.debug(
`Building docs for entity with type 'dir' and managed-by-location '${type}'`,
const { type, target } = transformDirLocation(
entity,
annotation,
this.scmIntegrations,
);
switch (type) {
case 'url': {
options?.logger?.debug(`Reading files from ${target}`);
// the target is an absolute url since it has already been transformed
const response = await this.reader.readTree(target, {
etag: options?.etag,
});
const preparedDir = await response.dir();
options?.logger?.debug(`Tree downloaded and stored at ${preparedDir}`);
return {
preparedDir,
etag: response.etag,
};
}
case 'github':
case 'gitlab':
case 'azure/api': {
const parsedGitLocation = parseGitUrl(target);
const repoLocation = await checkoutGitRepository(
target,
this.config,
this.logger,
);
// Check if etag has changed for cache invalidation.
const etag = await getLastCommitTimestamp(repoLocation, this.logger);
if (options?.etag === etag.toString()) {
throw new NotModifiedError();
}
case 'dir': {
return {
preparedDir: path.dirname(
path.join(repoLocation, parsedGitLocation.filepath),
),
etag: etag.toString(),
};
}
case 'file':
return {
preparedDir: path.dirname(target),
// the transformation already validated that the target is in a safe location
preparedDir: target,
// Instead of supporting caching on local sources, use techdocs-cli for local development and debugging.
etag: '',
};
}
default:
throw new InputError(`Unable to resolve location type ${type}`);
}
}
async prepare(entity: Entity): Promise<PreparerResponse> {
this.logger.warn(
'You are using the legacy dir preparer in TechDocs which will be removed in near future (March 2021). ' +
'Migrate to URL reader by updating `backstage.io/techdocs-ref` annotation in `catalog-info.yaml` ' +
'to be prefixed with `url:`. Read the migration guide and benefits at https://github.com/backstage/backstage/issues/4409 ',
);
const { target } = parseReferenceAnnotation(
'backstage.io/techdocs-ref',
entity,
);
// This will throw NotModified error if etag has not changed.
const response = await this.resolveManagedByLocationToDir(entity);
return {
preparedDir: path.resolve(response.preparedDir, target),
etag: response.etag,
};
}
}
+23 -1
View File
@@ -10,6 +10,14 @@ import { KubernetesFetchError } from '@backstage/plugin-kubernetes-common';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
import { Logger as Logger_2 } from 'winston';
// Warning: (ae-missing-release-tag) "AWSClusterDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface AWSClusterDetails extends ClusterDetails {
// (undocumented)
assumeRole?: string;
}
// Warning: (ae-missing-release-tag) "ClusterDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -53,6 +61,11 @@ export interface FetchResponseWrapper {
responses: FetchResponse[];
}
// Warning: (ae-missing-release-tag) "GKEClusterDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface GKEClusterDetails extends ClusterDetails {}
// Warning: (ae-missing-release-tag) "KubernetesClustersSupplier" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -107,7 +120,11 @@ export const makeRouter: (
// @public (undocumented)
export interface ObjectFetchParams {
// (undocumented)
clusterDetails: ClusterDetails;
clusterDetails:
| AWSClusterDetails
| GKEClusterDetails
| ServiceAccountClusterDetails
| ClusterDetails;
// (undocumented)
customResources: CustomResource[];
// (undocumented)
@@ -130,6 +147,11 @@ export interface RouterOptions {
logger: Logger_2;
}
// Warning: (ae-missing-release-tag) "ServiceAccountClusterDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface ServiceAccountClusterDetails extends ClusterDetails {}
// Warning: (ae-missing-release-tag) "ServiceLocatorMethod" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
+3 -1
View File
@@ -55,7 +55,9 @@
"devDependencies": {
"@backstage/cli": "^0.7.4",
"@types/aws4": "^1.5.1",
"supertest": "^6.1.3"
"supertest": "^6.1.3",
"aws-sdk-mock": "^5.2.1",
"bdd-lazy-var": "^2.6.0"
},
"files": [
"dist",
@@ -97,4 +97,48 @@ describe('ConfigClusterLocator', () => {
},
]);
});
it('one aws cluster with assumeRole and one without', async () => {
const config: Config = new ConfigReader({
clusters: [
{
name: 'cluster1',
serviceAccountToken: 'token',
url: 'http://localhost:8080',
authProvider: 'aws',
skipTLSVerify: false,
},
{
assumeRole: 'SomeRole',
name: 'cluster2',
url: 'http://localhost:8081',
authProvider: 'aws',
skipTLSVerify: true,
},
],
});
const sut = ConfigClusterLocator.fromConfig(config);
const result = await sut.getClusters();
expect(result).toStrictEqual([
{
assumeRole: undefined,
name: 'cluster1',
serviceAccountToken: 'token',
url: 'http://localhost:8080',
authProvider: 'aws',
skipTLSVerify: false,
},
{
assumeRole: 'SomeRole',
name: 'cluster2',
serviceAccountToken: undefined,
url: 'http://localhost:8081',
authProvider: 'aws',
skipTLSVerify: true,
},
]);
});
});
@@ -29,13 +29,32 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier {
// is required if authProvider is serviceAccount
return new ConfigClusterLocator(
config.getConfigArray('clusters').map(c => {
return {
const authProvider = c.getString('authProvider');
const clusterDetails = {
name: c.getString('name'),
url: c.getString('url'),
serviceAccountToken: c.getOptionalString('serviceAccountToken'),
skipTLSVerify: c.getOptionalBoolean('skipTLSVerify') ?? false,
authProvider: c.getString('authProvider'),
authProvider: authProvider,
};
switch (authProvider) {
case 'google': {
return clusterDetails;
}
case 'aws': {
const assumeRole = c.getOptionalString('assumeRole');
return { assumeRole, ...clusterDetails };
}
case 'serviceAccount': {
return clusterDetails;
}
default: {
throw new Error(
`authProvider "${authProvider}" has no config associated with it`,
);
}
}
}),
);
}
@@ -16,7 +16,7 @@
import { Config } from '@backstage/config';
import * as container from '@google-cloud/container';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
import { GKEClusterDetails, KubernetesClustersSupplier } from '../types/types';
type GkeClusterLocatorOptions = {
projectId: string;
@@ -49,7 +49,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier {
);
}
async getClusters(): Promise<ClusterDetails[]> {
async getClusters(): Promise<GKEClusterDetails[]> {
const { projectId, region, skipTLSVerify } = this.options;
const request = {
parent: `projects/${projectId}/locations/${region}`,
@@ -14,15 +14,58 @@
* limitations under the License.
*/
import AWS from 'aws-sdk';
import AWSMock from 'aws-sdk-mock';
import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator';
import { get, def } from 'bdd-lazy-var';
describe('AwsIamKubernetesAuthTranslator tests', () => {
let role: any = undefined;
const credentials: any = {
accessKeyId: 'bloop',
secretAccessKey: 'omg-so-secret',
sessionToken: 'token',
};
let assumeResponse: any = {
Credentials: {
AccessKeyId: credentials.accessKeyId,
SecretAccessKey: credentials.secretAccessKey,
SessionToken: credentials.sessionToken,
},
};
let credentialsResponse: any = new AWS.Credentials(credentials);
AWSMock.setSDKInstance(AWS);
beforeEach(() => {
jest.resetAllMocks();
});
it('returns a signed url for aws credentials', async () => {
afterAll(() => {
jest.resetAllMocks();
});
def('subject', () => {
AWSMock.mock('STS', 'assumeRole', (_params: any, callback: Function) => {
callback(null, assumeResponse);
});
const authTranslator = new AwsIamKubernetesAuthTranslator();
jest
.spyOn(authTranslator, 'awsGetCredentials')
.mockImplementation(async () => credentialsResponse);
return authTranslator.decorateClusterDetailsWithAuth({
assumeRole: role,
name: 'test-cluster',
url: '',
authProvider: 'aws',
});
});
it('returns a signed url for aws credentials', async () => {
// These credentials are not real.
// Pulled from example in docs: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html
AWS.config.credentials = new AWS.Credentials(
@@ -30,24 +73,47 @@ describe('AwsIamKubernetesAuthTranslator tests', () => {
'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
);
const clusterDetails = await authTranslator.decorateClusterDetailsWithAuth({
name: 'test-cluster',
url: '',
authProvider: 'aws',
});
expect(clusterDetails.serviceAccountToken).toBeDefined();
const subject = await get('subject');
expect(subject.serviceAccountToken).toBeDefined();
});
it('throws when unable to get aws credentials', async () => {
AWS.config.credentials = undefined;
const authTranslator = new AwsIamKubernetesAuthTranslator();
const promise = authTranslator.decorateClusterDetailsWithAuth({
name: 'test-cluster',
url: '',
authProvider: 'aws',
});
await expect(promise).rejects.toThrow(
'Could not load credentials from any providers',
describe('When the role is assumed', () => {
// These credentials are not real.
// Pulled from example in docs: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html
AWS.config.credentials = new AWS.Credentials(
'AKIAIOSFODNN7EXAMPLE',
'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
);
role = 'SomeRole';
describe('When the role is valid', () => {
it('returns a signed url for aws credentials', async () => {
const subject = await get('subject');
expect(subject.serviceAccountToken).toBeDefined();
});
});
describe('When the role is invalid', () => {
it('returns the original AWS credentials', async () => {
assumeResponse = undefined;
await expect(get('subject')).rejects.toThrow(/Unable to assume role:/);
});
});
});
describe('When no creds are returned from AWS', () => {
it('throws unable to get aws credentials', async () => {
credentialsResponse = new Error();
await expect(get('subject')).rejects.toThrow('No AWS credentials found.');
});
});
describe('When invalid creds are returned from AWS', () => {
it('throws credentials are invalid to get aws credentials', async () => {
credentialsResponse = new AWS.Credentials(credentialsResponse);
await expect(get('subject')).rejects.toThrow(
'Invalid AWS credentials found.',
);
});
});
});
@@ -15,7 +15,7 @@
*/
import AWS, { Credentials } from 'aws-sdk';
import { sign } from 'aws4';
import { ClusterDetails } from '../types/types';
import { AWSClusterDetails } from '../types/types';
import { KubernetesAuthTranslator } from './types';
const base64 = (str: string) =>
@@ -29,23 +29,78 @@ const pipe = (fns: ReadonlyArray<any>) => (thing: string): string =>
const removePadding = replace(/=+$/, '');
const makeUrlSafe = pipe([replace('+', '-'), replace('/', '_')]);
type SigningCreds = {
accessKeyId: string | undefined;
secretAccessKey: string | undefined;
sessionToken: string | undefined;
};
export class AwsIamKubernetesAuthTranslator
implements KubernetesAuthTranslator {
async getBearerToken(clusterName: string): Promise<string> {
const credentials = await new Promise((resolve, reject) => {
validCredentials(creds: SigningCreds): boolean {
return ((creds?.accessKeyId &&
creds?.secretAccessKey &&
creds?.sessionToken) as unknown) as boolean;
}
awsGetCredentials = async (): Promise<Credentials> => {
return new Promise((resolve, reject) => {
AWS.config.getCredentials(err => {
if (err) {
reject(err);
} else {
resolve(AWS.config.credentials);
return reject(err);
}
return resolve(AWS.config.credentials as Credentials);
});
});
};
async getCredentials(assumeRole: string | undefined): Promise<SigningCreds> {
return new Promise<SigningCreds>(async (resolve, reject) => {
const awsCreds = await this.awsGetCredentials();
if (!(awsCreds instanceof Credentials))
return reject(Error('No AWS credentials found.'));
let creds: SigningCreds = {
accessKeyId: awsCreds.accessKeyId,
secretAccessKey: awsCreds.secretAccessKey,
sessionToken: awsCreds.sessionToken,
};
if (!this.validCredentials(creds))
return reject(Error('Invalid AWS credentials found.'));
if (!assumeRole) return resolve(creds);
try {
const params = {
RoleArn: assumeRole,
RoleSessionName: 'backstage-login',
};
const assumedRole = await new AWS.STS().assumeRole(params).promise();
if (!assumedRole.Credentials) {
throw new Error(`No credentials returned for role ${assumeRole}`);
}
creds = {
accessKeyId: assumedRole.Credentials.AccessKeyId,
secretAccessKey: assumedRole.Credentials.SecretAccessKey,
sessionToken: assumedRole.Credentials.SessionToken,
};
} catch (e) {
console.warn(`There was an error assuming the role: ${e}`);
return reject(Error(`Unable to assume role: ${e}`));
}
return resolve(creds);
});
}
async getBearerToken(
clusterName: string,
assumeRole: string | undefined,
): Promise<string> {
const credentials = await this.getCredentials(assumeRole);
if (!(credentials instanceof Credentials)) {
throw new Error('no AWS credentials found.');
}
await credentials.getPromise();
const request = {
host: `sts.amazonaws.com`,
path: `/?Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Expires=60`,
@@ -54,11 +109,8 @@ export class AwsIamKubernetesAuthTranslator
},
signQuery: true,
};
const signedRequest = sign(request, {
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
sessionToken: credentials.sessionToken,
});
const signedRequest = sign(request, credentials);
return pipe([
(signed: any) => `https://${signed.host}${signed.path}`,
@@ -70,15 +122,16 @@ export class AwsIamKubernetesAuthTranslator
}
async decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
): Promise<ClusterDetails> {
const clusterDetailsWithAuthToken: ClusterDetails = Object.assign(
clusterDetails: AWSClusterDetails,
): Promise<AWSClusterDetails> {
const clusterDetailsWithAuthToken: AWSClusterDetails = Object.assign(
{},
clusterDetails,
);
clusterDetailsWithAuthToken.serviceAccountToken = await this.getBearerToken(
clusterDetails.name,
clusterDetails.assumeRole,
);
return clusterDetailsWithAuthToken;
}
@@ -15,16 +15,16 @@
*/
import { KubernetesAuthTranslator } from './types';
import { ClusterDetails } from '../types/types';
import { GKEClusterDetails } from '../types/types';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
export class GoogleKubernetesAuthTranslator
implements KubernetesAuthTranslator {
async decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
clusterDetails: GKEClusterDetails,
requestBody: KubernetesRequestBody,
): Promise<ClusterDetails> {
const clusterDetailsWithAuthToken: ClusterDetails = Object.assign(
): Promise<GKEClusterDetails> {
const clusterDetailsWithAuthToken: GKEClusterDetails = Object.assign(
{},
clusterDetails,
);
@@ -15,18 +15,18 @@
*/
import { KubernetesAuthTranslator } from './types';
import { ClusterDetails } from '../types/types';
import { ServiceAccountClusterDetails } from '../types/types';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
export class ServiceAccountKubernetesAuthTranslator
implements KubernetesAuthTranslator {
async decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
clusterDetails: ServiceAccountClusterDetails,
// To ignore TS6133 linting error where it detects 'requestBody' is declared but its value is never read.
// @ts-ignore-start
requestBody: KubernetesRequestBody, // eslint-disable-line @typescript-eslint/no-unused-vars
// @ts-ignore-end
): Promise<ClusterDetails> {
): Promise<ServiceAccountClusterDetails> {
return clusterDetails;
}
}
+11 -1
View File
@@ -27,7 +27,11 @@ export interface CustomResource {
export interface ObjectFetchParams {
serviceId: string;
clusterDetails: ClusterDetails;
clusterDetails:
| AWSClusterDetails
| GKEClusterDetails
| ServiceAccountClusterDetails
| ClusterDetails;
objectTypesToFetch: Set<KubernetesObjectTypes>;
labelSelector: string;
customResources: CustomResource[];
@@ -77,3 +81,9 @@ export interface ClusterDetails {
serviceAccountToken?: string | undefined;
skipTLSVerify?: boolean;
}
export interface GKEClusterDetails extends ClusterDetails {}
export interface ServiceAccountClusterDetails extends ClusterDetails {}
export interface AWSClusterDetails extends ClusterDetails {
assumeRole?: string;
}
+7
View File
@@ -107,6 +107,13 @@ export const createFilesystemDeleteAction: () => TemplateAction<any>;
// @public (undocumented)
export const createFilesystemRenameAction: () => TemplateAction<any>;
// Warning: (ae-missing-release-tag) "createGithubActionsDispatchAction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function createGithubActionsDispatchAction(options: {
integrations: ScmIntegrationRegistry;
}): TemplateAction<any>;
// Warning: (ae-missing-release-tag) "createPublishAzureAction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -15,6 +15,11 @@
*/
export const mockGithubClient = {
rest: {
actions: {
createWorkflowDispatch: jest.fn(),
},
},
repos: {
createInOrg: jest.fn(),
createForAuthenticatedUser: jest.fn(),
@@ -37,6 +37,7 @@ import {
createPublishGithubPullRequestAction,
createPublishGitlabAction,
} from './publish';
import { createGithubActionsDispatchAction } from './github';
export const createBuiltinActions = (options: {
reader: UrlReader;
@@ -91,5 +92,8 @@ export const createBuiltinActions = (options: {
createCatalogWriteAction(),
createFilesystemDeleteAction(),
createFilesystemRenameAction(),
createGithubActionsDispatchAction({
integrations,
}),
];
};
@@ -0,0 +1,117 @@
/*
* Copyright 2021 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.
*/
jest.mock('@octokit/rest');
import { createGithubActionsDispatchAction } from './githubActionsDispatch';
import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
describe('github:actions:dispatch', () => {
const config = new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createGithubActionsDispatchAction({ integrations });
const mockContext = {
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
workflowId: 'a-workflow-id',
branchOrTagName: 'main',
},
workspacePath: 'lol',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
const { mockGithubClient } = require('@octokit/rest');
beforeEach(() => {
jest.resetAllMocks();
});
it('should throw an error when the repoUrl is not well formed', async () => {
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'github.com?repo=bob' },
}),
).rejects.toThrow(/missing owner/);
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'github.com?owner=owner' },
}),
).rejects.toThrow(/missing repo/);
});
it('should throw if there is no integration config provided', async () => {
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'missing.com?repo=bob&owner=owner' },
}),
).rejects.toThrow(/No matching integration configuration/);
});
it('should throw if there is no token in the integration config that is returned', async () => {
await expect(
action.handler({
...mockContext,
input: {
repoUrl: 'ghe.github.com?repo=bob&owner=owner',
},
}),
).rejects.toThrow(/No token available for host/);
});
it('should call the githubApis for creating WorkflowDispatch', async () => {
mockGithubClient.rest.actions.createWorkflowDispatch.mockResolvedValue({
data: {
foo: 'bar',
},
});
const repoUrl = 'github.com?repo=repo&owner=owner';
const workflowId = 'dispatch_workflow';
const branchOrTagName = 'main';
const ctx = Object.assign({}, mockContext, {
input: { repoUrl, workflowId, branchOrTagName },
});
await action.handler(ctx);
expect(
mockGithubClient.rest.actions.createWorkflowDispatch,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
workflow_id: workflowId,
ref: branchOrTagName,
});
});
});
@@ -0,0 +1,115 @@
/*
* Copyright 2021 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 { InputError } from '@backstage/errors';
import {
GithubCredentialsProvider,
ScmIntegrationRegistry,
} from '@backstage/integration';
import { Octokit } from '@octokit/rest';
import { parseRepoUrl } from '../publish/util';
import { createTemplateAction } from '../../createTemplateAction';
export function createGithubActionsDispatchAction(options: {
integrations: ScmIntegrationRegistry;
}) {
const { integrations } = options;
const credentialsProviders = new Map(
integrations.github.list().map(integration => {
const provider = GithubCredentialsProvider.create(integration.config);
return [integration.config.host, provider];
}),
);
return createTemplateAction<{
repoUrl: string;
workflowId: string;
branchOrTagName: string;
}>({
id: 'github:actions:dispatch',
description:
'Dispatches a GitHub Action workflow for a given branch or tag',
schema: {
input: {
type: 'object',
required: ['repoUrl', 'workflowId', 'branchOrTagName'],
properties: {
repoUrl: {
title: 'Repository Location',
description: `Accepts the format 'github.com?repo=reponame&owner=owner' where 'reponame' is the new repository name and 'owner' is an organization or username`,
type: 'string',
},
workflowId: {
title: 'Workflow ID',
description: 'The GitHub Action Workflow filename',
type: 'string',
},
branchOrTagName: {
title: 'Branch or Tag name',
description:
'The git branch or tag name used to dispatch the workflow',
type: 'string',
},
},
},
},
async handler(ctx) {
const { repoUrl, workflowId, branchOrTagName } = ctx.input;
const { owner, repo, host } = parseRepoUrl(repoUrl);
ctx.logger.info(
`Dispatching workflow ${workflowId} for repo ${repoUrl} on ${branchOrTagName}`,
);
const credentialsProvider = credentialsProviders.get(host);
const integrationConfig = integrations.github.byHost(host);
if (!credentialsProvider || !integrationConfig) {
throw new InputError(
`No matching integration configuration for host ${host}, please check your integrations config`,
);
}
const { token } = await credentialsProvider.getCredentials({
url: `https://${host}/${encodeURIComponent(owner)}/${encodeURIComponent(
repo,
)}`,
});
if (!token) {
throw new InputError(
`No token available for host: ${host}, with owner ${owner}, and repo ${repo}`,
);
}
const client = new Octokit({
auth: token,
baseUrl: integrationConfig.config.apiBaseUrl,
previews: ['nebula-preview'],
});
await client.rest.actions.createWorkflowDispatch({
owner,
repo,
workflow_id: workflowId,
ref: branchOrTagName,
});
ctx.logger.info(`Workflow ${workflowId} dispatched successfully`);
},
});
}
@@ -0,0 +1,17 @@
/*
* Copyright 2021 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.
*/
export { createGithubActionsDispatchAction } from './githubActionsDispatch';
@@ -20,6 +20,7 @@ export * from './debug';
export * from './fetch';
export * from './filesystem';
export * from './publish';
export * from './github';
export { createFetchCookiecutterAction } from '@backstage/plugin-scaffolder-backend-module-cookiecutter';
export { runCommand } from './helpers';
+11
View File
@@ -143,6 +143,17 @@ export const SearchResult: ({
children: (results: { results: SearchResult_2[] }) => JSX.Element;
}) => JSX.Element;
// Warning: (ae-forgotten-export) The symbol "SearchTypeProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "SearchType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const SearchType: ({
values,
className,
name,
defaultValue,
}: SearchTypeProps) => JSX.Element;
// Warning: (ae-missing-release-tag) "SidebarSearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -0,0 +1,231 @@
/*
* Copyright 2021 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 React from 'react';
import { screen, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchType } from './SearchType';
import { SearchContextProvider } from '../SearchContext';
import { useApi } from '@backstage/core-plugin-api';
jest.mock('@backstage/core-plugin-api', () => ({
...jest.requireActual('@backstage/core-plugin-api'),
useApi: jest.fn().mockReturnValue({}),
}));
describe('SearchType', () => {
const initialState = {
term: '',
filters: {},
types: [],
pageCursor: '',
};
const name = 'field';
const values = ['value1', 'value2'];
const typeValues = ['preselected'];
const query = jest.fn().mockResolvedValue({});
(useApi as jest.Mock).mockReturnValue({ query: query });
afterAll(() => {
jest.resetAllMocks();
});
describe('Type Filter', () => {
it('Renders field name and values when provided as props', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchType name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
userEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
expect(
screen.getByRole('option', { name: values[0] }),
).toBeInTheDocument();
expect(
screen.getByRole('option', { name: values[1] }),
).toBeInTheDocument();
});
it('Renders correctly based on type filter state', async () => {
render(
<SearchContextProvider
initialState={{
...initialState,
types: [values[0]],
}}
>
<SearchType name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
userEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
expect(screen.getByRole('option', { name: values[0] })).toHaveAttribute(
'aria-selected',
'true',
);
expect(
screen.getByRole('option', { name: values[1] }),
).not.toHaveAttribute('aria-selected');
expect(screen.getByRole('option', { name: 'All' })).not.toHaveAttribute(
'aria-selected',
);
});
it('Renders correctly based on type filter defaultValue', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchType name={name} values={values} defaultValue={values[0]} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
userEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
expect(screen.getByRole('option', { name: values[0] })).toHaveAttribute(
'aria-selected',
'true',
);
expect(
screen.getByRole('option', { name: values[1] }),
).not.toHaveAttribute('aria-selected');
expect(screen.getByRole('option', { name: 'All' })).not.toHaveAttribute(
'aria-selected',
);
});
it('Selecting a value sets type filter state', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchType name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
const button = screen.getByRole('button');
userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
userEvent.click(screen.getByRole('option', { name: values[0] }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
types: [values[0]],
}),
);
});
userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
userEvent.click(screen.getByRole('option', { name: 'All' }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
types: [],
}),
);
});
});
it('Selecting a value maintains unrelated filter state, selecting All defaults to default empty state', async () => {
render(
<SearchContextProvider
initialState={{
...initialState,
types: typeValues,
}}
>
<SearchType name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(name)).toBeInTheDocument();
});
const button = screen.getByRole('button');
userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
userEvent.click(screen.getByRole('option', { name: values[0] }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
types: [...typeValues, values[0]],
}),
);
});
userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
userEvent.click(screen.getByRole('option', { name: 'All' }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(expect.objectContaining([]));
});
});
});
});
@@ -0,0 +1,110 @@
/*
* Copyright 2021 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 { useSearch } from '../SearchContext';
import { useEffectOnce } from 'react-use';
import React, { ChangeEvent } from 'react';
import {
Chip,
FormControl,
InputLabel,
makeStyles,
MenuItem,
Select,
} from '@material-ui/core';
const useStyles = makeStyles({
label: {
textTransform: 'capitalize',
},
chips: {
display: 'flex',
flexWrap: 'wrap',
},
chip: {
margin: 2,
},
});
export type SearchTypeProps = {
className?: string;
name: string;
values?: string[];
defaultValue?: string[] | string | null;
};
const SearchType = ({
values = [],
className,
name,
defaultValue,
}: SearchTypeProps) => {
const classes = useStyles();
const { types, setTypes } = useSearch();
useEffectOnce(() => {
if (defaultValue && Array.isArray(defaultValue)) {
setTypes(defaultValue);
} else if (defaultValue) {
setTypes([defaultValue]);
}
});
const handleChange = (e: ChangeEvent<{ value: unknown }>) => {
const value = e.target.value as string[];
if (!value || value.includes('*')) {
setTypes([]);
} else {
setTypes(value.filter(it => it !== 'All'));
}
};
return (
<FormControl
className={className}
variant="filled"
fullWidth
data-testid="search-typefilter-next"
>
<InputLabel className={classes.label} margin="dense">
{name}
</InputLabel>
<Select
multiple
variant="outlined"
value={types.length ? types : ['All']}
onChange={handleChange}
renderValue={selected => (
<div className={classes.chips}>
{(selected as string[]).map(value => (
<Chip key={value} label={value} className={classes.chip} />
))}
</div>
)}
>
<MenuItem value="*">
<em>All</em>
</MenuItem>
{values.map((value: string) => (
<MenuItem key={value} value={value}>
{value}
</MenuItem>
))}
</Select>
</FormControl>
);
};
export { SearchType };
@@ -0,0 +1,17 @@
/*
* Copyright 2021 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.
*/
export { SearchType } from './SearchType';
+1
View File
@@ -16,6 +16,7 @@
export * from './Filters';
export * from './SearchFilter';
export * from './SearchType';
export * from './SearchBar';
export * from './SearchPage';
export * from './SearchResult';
+1
View File
@@ -32,6 +32,7 @@ export {
useSearch,
SearchPage as Router,
SearchFilter,
SearchType,
SearchFilterNext,
SidebarSearch,
} from './components';
+51
View File
@@ -3,9 +3,12 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { DocumentCollator } from '@backstage/search-common';
import express from 'express';
import { GeneratorBuilder } from '@backstage/techdocs-common';
import { IndexableDocument } from '@backstage/search-common';
import { Knex } from 'knex';
import { Logger as Logger_2 } from 'winston';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
@@ -18,6 +21,54 @@ import { PublisherBase } from '@backstage/techdocs-common';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// Warning: (ae-missing-release-tag) "DefaultTechDocsCollator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export class DefaultTechDocsCollator implements DocumentCollator {
constructor({
discovery,
locationTemplate,
logger,
catalogClient,
parallelismLimit,
}: {
discovery: PluginEndpointDiscovery;
logger: Logger_2;
locationTemplate?: string;
catalogClient?: CatalogApi;
parallelismLimit?: number;
});
// (undocumented)
protected applyArgsToFormat(
format: string,
args: Record<string, string>,
): string;
// (undocumented)
protected discovery: PluginEndpointDiscovery;
// (undocumented)
execute(): Promise<TechDocsDocument[]>;
// (undocumented)
protected locationTemplate: string;
// (undocumented)
readonly type: string;
}
// Warning: (ae-missing-release-tag) "TechDocsDocument" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface TechDocsDocument extends IndexableDocument {
// (undocumented)
kind: string;
// (undocumented)
lifecycle: string;
// (undocumented)
name: string;
// (undocumented)
namespace: string;
// (undocumented)
owner: string;
}
export * from '@backstage/techdocs-common';
// (No @packageDocumentation comment for this package)
+5
View File
@@ -35,6 +35,8 @@
"@backstage/catalog-model": "^0.9.0",
"@backstage/config": "^0.1.5",
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.5.8",
"@backstage/search-common": "^0.1.2",
"@backstage/techdocs-common": "^0.6.8",
"@types/express": "^4.17.6",
"cross-fetch": "^3.0.6",
@@ -43,10 +45,13 @@
"express-promise-router": "^4.1.0",
"fs-extra": "9.1.0",
"knex": "^0.95.1",
"lodash": "^4.17.21",
"p-limit": "^3.1.0",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.7.4",
"@backstage/test-utils": "^0.1.14",
"@types/dockerode": "^3.2.1",
"msw": "^0.29.0",
"supertest": "^6.1.3"
@@ -20,6 +20,7 @@ import {
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { NotModifiedError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import {
GeneratorBase,
GeneratorBuilder,
@@ -43,6 +44,7 @@ type DocsBuilderArguments = {
entity: Entity;
logger: Logger;
config: Config;
scmIntegrations: ScmIntegrationRegistry;
logStream?: Writable;
};
@@ -53,6 +55,7 @@ export class DocsBuilder {
private entity: Entity;
private logger: Logger;
private config: Config;
private scmIntegrations: ScmIntegrationRegistry;
private logStream: Writable | undefined;
constructor({
@@ -62,6 +65,7 @@ export class DocsBuilder {
entity,
logger,
config,
scmIntegrations,
logStream,
}: DocsBuilderArguments) {
this.preparer = preparers.get(entity);
@@ -70,6 +74,7 @@ export class DocsBuilder {
this.entity = entity;
this.logger = logger;
this.config = config;
this.scmIntegrations = scmIntegrations;
this.logStream = logStream;
}
@@ -166,7 +171,10 @@ export class DocsBuilder {
path.join(tmpdirResolvedPath, 'techdocs-tmp-'),
);
const parsedLocationAnnotation = getLocationForEntity(this.entity);
const parsedLocationAnnotation = getLocationForEntity(
this.entity,
this.scmIntegrations,
);
await this.generator.run({
inputDir: preparedDir,
outputDir,
+1
View File
@@ -15,4 +15,5 @@
*/
export { createRouter } from './service/router';
export * from './search';
export * from '@backstage/techdocs-common';
@@ -0,0 +1,149 @@
/*
* Copyright 2021 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 {
PluginEndpointDiscovery,
getVoidLogger,
} from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { DefaultTechDocsCollator } from './DefaultTechDocsCollator';
import { msw } from '@backstage/test-utils';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const logger = getVoidLogger();
const mockSearchDocIndex = {
config: {
lang: ['en'],
min_search_length: 3,
prebuild_index: false,
separator: '[\\s\\-]+',
},
docs: [
{
location: '',
text: 'docs docs docs',
title: 'Home',
},
{
location: 'local-development/',
text: 'Docs for first subtitle',
title: 'Local development',
},
{
location: 'local-development/#development',
text: 'Docs for sub-subtitle',
title: 'Development',
},
],
};
const expectedEntities: Entity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test-entity-with-docs',
description: 'Documented description',
annotations: {
'backstage.io/techdocs-ref': './',
},
},
spec: {
type: 'dog',
lifecycle: 'experimental',
owner: 'someone',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test-entity',
description: 'The expected description',
},
spec: {
type: 'some-type',
lifecycle: 'experimental',
},
},
];
describe('DefaultTechDocsCollator', () => {
let mockDiscoveryApi: jest.Mocked<PluginEndpointDiscovery>;
let collator: DefaultTechDocsCollator;
const worker = setupServer();
msw.setupDefaultHandlers(worker);
beforeEach(() => {
mockDiscoveryApi = {
getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'),
getExternalBaseUrl: jest.fn(),
};
collator = new DefaultTechDocsCollator({
discovery: mockDiscoveryApi,
logger,
});
worker.use(
rest.get(
'http://test-backend/static/docs/default/Component/test-entity-with-docs/search/search_index.json',
(_, res, ctx) => res(ctx.status(200), ctx.json(mockSearchDocIndex)),
),
rest.get('http://test-backend/entities', (_, res, ctx) =>
res(ctx.status(200), ctx.json(expectedEntities)),
),
);
});
it('fetches from the configured catalog and tech docs services', async () => {
const documents = await collator.execute();
expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('catalog');
expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('techdocs');
expect(documents).toHaveLength(mockSearchDocIndex.docs.length);
});
it('should create documents for each tech docs search index', async () => {
const documents = await collator.execute();
const entity = expectedEntities[0];
documents.forEach((document, idx) => {
expect(document).toMatchObject({
title: mockSearchDocIndex.docs[idx].title,
location: `/docs/default/Component/${entity.metadata.name}/${mockSearchDocIndex.docs[idx].location}`,
text: mockSearchDocIndex.docs[idx].text,
namespace: 'default',
componentType: entity!.spec!.type,
lifecycle: entity!.spec!.lifecycle,
owner: '',
});
});
});
it('maps a returned entity with a custom locationTemplate', async () => {
// Provide an alternate location template.
collator = new DefaultTechDocsCollator({
discovery: mockDiscoveryApi,
locationTemplate: '/software/:name',
logger,
});
const documents = await collator.execute();
expect(documents[0]).toMatchObject({
location: '/software/test-entity-with-docs',
});
});
});
@@ -0,0 +1,149 @@
/*
* Copyright 2021 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 { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import { IndexableDocument, DocumentCollator } from '@backstage/search-common';
import fetch from 'cross-fetch';
import unescape from 'lodash/unescape';
import { Logger } from 'winston';
import pLimit from 'p-limit';
import { CatalogApi, CatalogClient } from '@backstage/catalog-client';
interface MkSearchIndexDoc {
title: string;
text: string;
location: string;
}
export interface TechDocsDocument extends IndexableDocument {
kind: string;
namespace: string;
name: string;
lifecycle: string;
owner: string;
}
export class DefaultTechDocsCollator implements DocumentCollator {
protected discovery: PluginEndpointDiscovery;
protected locationTemplate: string;
private readonly logger: Logger;
private readonly catalogClient: CatalogApi;
private readonly parallelismLimit: number;
public readonly type: string = 'techdocs';
constructor({
discovery,
locationTemplate,
logger,
catalogClient,
parallelismLimit = 10,
}: {
discovery: PluginEndpointDiscovery;
logger: Logger;
locationTemplate?: string;
catalogClient?: CatalogApi;
parallelismLimit?: number;
}) {
this.discovery = discovery;
this.locationTemplate =
locationTemplate || '/docs/:namespace/:kind/:name/:path';
this.logger = logger;
this.catalogClient =
catalogClient || new CatalogClient({ discoveryApi: discovery });
this.parallelismLimit = parallelismLimit;
}
async execute() {
const limit = pLimit(this.parallelismLimit);
const techDocsBaseUrl = await this.discovery.getBaseUrl('techdocs');
const entities = await this.catalogClient.getEntities({
fields: [
'kind',
'namespace',
'metadata.annotations',
'metadata.name',
'metadata.namespace',
'spec.type',
'spec.lifecycle',
'relations',
],
});
const docPromises = entities.items
.filter(it => it.metadata?.annotations?.['backstage.io/techdocs-ref'])
.map((entity: Entity) =>
limit(
async (): Promise<TechDocsDocument[]> => {
const entityInfo = {
kind: entity.kind,
namespace: entity.metadata.namespace || 'default',
name: entity.metadata.name,
};
try {
const searchIndexResponse = await fetch(
DefaultTechDocsCollator.constructDocsIndexUrl(
techDocsBaseUrl,
entityInfo,
),
);
const searchIndex = await searchIndexResponse.json();
return searchIndex.docs.map((doc: MkSearchIndexDoc) => ({
title: unescape(doc.title),
text: unescape(doc.text || ''),
location: this.applyArgsToFormat(this.locationTemplate, {
...entityInfo,
path: doc.location,
}),
...entityInfo,
componentType: entity.spec?.type?.toString() || 'other',
lifecycle: (entity.spec?.lifecycle as string) || '',
owner:
entity.relations?.find(r => r.type === RELATION_OWNED_BY)
?.target?.name || '',
}));
} catch (e) {
this.logger.warn(
`Failed to retrieve tech docs search index for entity ${entityInfo.namespace}/${entityInfo.kind}/${entityInfo.name}`,
e,
);
return [];
}
},
),
);
return (await Promise.all(docPromises)).flat();
}
protected applyArgsToFormat(
format: string,
args: Record<string, string>,
): string {
let formatted = format;
for (const [key, value] of Object.entries(args)) {
formatted = formatted.replace(`:${key}`, value);
}
return formatted;
}
private static constructDocsIndexUrl(
techDocsBaseUrl: string,
entityInfo: { kind: string; namespace: string; name: string },
) {
return `${techDocsBaseUrl}/static/docs/${entityInfo.namespace}/${entityInfo.kind}/${entityInfo.name}/search/search_index.json`;
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2021 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.
*/
export { DefaultTechDocsCollator } from './DefaultTechDocsCollator';
export type { TechDocsDocument } from './DefaultTechDocsCollator';
@@ -19,6 +19,7 @@ import {
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import {
GeneratorBuilder,
PreparerBuilder,
@@ -69,6 +70,7 @@ describe('DocsSynchronizer', () => {
publisher,
config: new ConfigReader({}),
logger: getVoidLogger(),
scmIntegrations: ScmIntegrations.fromConfig(new ConfigReader({})),
});
});
@@ -17,6 +17,7 @@
import { Entity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { NotFoundError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import {
GeneratorBuilder,
PreparerBuilder,
@@ -36,19 +37,23 @@ export class DocsSynchronizer {
private readonly publisher: PublisherBase;
private readonly logger: winston.Logger;
private readonly config: Config;
private readonly scmIntegrations: ScmIntegrationRegistry;
constructor({
publisher,
logger,
config,
scmIntegrations,
}: {
publisher: PublisherBase;
logger: winston.Logger;
config: Config;
scmIntegrations: ScmIntegrationRegistry;
}) {
this.config = config;
this.logger = logger;
this.publisher = publisher;
this.scmIntegrations = scmIntegrations;
}
async doSync({
@@ -87,19 +92,20 @@ export class DocsSynchronizer {
return;
}
const docsBuilder = new DocsBuilder({
preparers,
generators,
publisher: this.publisher,
logger: taskLogger,
entity,
config: this.config,
logStream,
});
let foundDocs = false;
try {
const docsBuilder = new DocsBuilder({
preparers,
generators,
publisher: this.publisher,
logger: taskLogger,
entity,
config: this.config,
scmIntegrations: this.scmIntegrations,
logStream,
});
const updated = await docsBuilder.build();
if (!updated) {
@@ -29,6 +29,7 @@ import express, { Response } from 'express';
import Router from 'express-promise-router';
import { Knex } from 'knex';
import { Logger } from 'winston';
import { ScmIntegrations } from '@backstage/integration';
import { DocsSynchronizer, DocsSynchronizerSyncOpts } from './DocsSynchronizer';
/**
@@ -79,10 +80,12 @@ export async function createRouter(
const router = Router();
const { publisher, config, logger, discovery } = options;
const catalogClient = new CatalogClient({ discoveryApi: discovery });
const scmIntegrations = ScmIntegrations.fromConfig(config);
const docsSynchronizer = new DocsSynchronizer({
publisher: publisher,
logger: logger,
config: config,
publisher,
logger,
config,
scmIntegrations,
});
router.get('/metadata/techdocs/:namespace/:kind/:name', async (req, res) => {
@@ -126,7 +129,7 @@ export async function createRouter(
)
).json()) as Entity;
const locationMetadata = getLocationForEntity(entity);
const locationMetadata = getLocationForEntity(entity, scmIntegrations);
res.json({ ...entity, locationMetadata });
} catch (err) {
logger.info(
+11
View File
@@ -25,6 +25,17 @@ export const DocsCardGrid: ({
entities: Entity[] | undefined;
}) => JSX.Element | null;
// Warning: (ae-missing-release-tag) "DocsResultListItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const DocsResultListItem: ({
result,
lineClamp,
}: {
result: any;
lineClamp?: number | undefined;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "DocsTable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
+1
View File
@@ -51,6 +51,7 @@
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4",
"react-text-truncate": "^0.16.0",
"sanitize-html": "^2.3.2"
},
"devDependencies": {
@@ -0,0 +1,50 @@
/*
* Copyright 2021 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 React from 'react';
import { render } from '@testing-library/react';
import { DocsResultListItem } from './DocsResultListItem';
// Using canvas to render text..
jest.mock('react-text-truncate', () => {
return ({ text }: { text: string }) => <span>{text}</span>;
});
const validResult = {
location: 'https://backstage.io/docs',
title: 'Documentation',
text:
'Backstage is an open-source developer portal that puts the developer experience first.',
kind: 'library',
namespace: '',
name: 'Backstage',
lifecycle: 'production',
};
describe('DocsResultListItem test', () => {
it('should render search doc passed in', async () => {
const { findByText } = render(<DocsResultListItem result={validResult} />);
expect(
await findByText('Documentation | Backstage docs'),
).toBeInTheDocument();
expect(
await findByText(
'Backstage is an open-source developer portal that puts the developer experience first.',
),
).toBeInTheDocument();
});
});
@@ -0,0 +1,60 @@
/*
* Copyright 2021 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 React from 'react';
import { Divider, ListItem, ListItemText, makeStyles } from '@material-ui/core';
import { Link } from '@backstage/core-components';
import TextTruncate from 'react-text-truncate';
const useStyles = makeStyles({
flexContainer: {
flexWrap: 'wrap',
},
itemText: {
width: '100%',
marginBottom: '1rem',
},
});
export const DocsResultListItem = ({
result,
lineClamp = 5,
}: {
result: any;
lineClamp?: number;
}) => {
const classes = useStyles();
return (
<Link to={result.location}>
<ListItem alignItems="flex-start" className={classes.flexContainer}>
<ListItemText
className={classes.itemText}
primaryTypographyProps={{ variant: 'h6' }}
primary={`${result.title} | ${result.name} docs `}
secondary={
<TextTruncate
line={lineClamp}
truncateText="…"
text={result.text}
element="span"
/>
}
/>
</ListItem>
<Divider component="li" />
</Link>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 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.
*/
export { DocsResultListItem } from './DocsResultListItem';
+1
View File
@@ -19,6 +19,7 @@ export { techdocsApiRef, techdocsStorageApiRef } from './api';
export type { TechDocsApi, TechDocsStorageApi } from './api';
export { TechDocsClient, TechDocsStorageClient } from './client';
export type { PanelType } from './home/components/TechDocsCustomHome';
export * from './components/DocsResultListItem';
export {
DocsCardGrid,
DocsTable,
+160 -133
View File
@@ -61,18 +61,18 @@ const useStyles = makeStyles<BackstageTheme>(() => ({
export const Reader = ({ entityId, onReady }: Props) => {
const { kind, namespace, name } = entityId;
const { '*': path } = useParams();
const theme = useTheme<BackstageTheme>();
const classes = useStyles();
const {
state,
path,
contentReload,
content: rawPage,
contentErrorMessage,
syncErrorMessage,
buildLog,
} = useReaderState(kind, namespace, name, path);
} = useReaderState(kind, namespace, name, useParams()['*']);
const techdocsStorageApi = useApi(techdocsStorageApiRef);
const [sidebars, setSidebars] = useState<HTMLElement[]>();
@@ -109,31 +109,26 @@ export const Reader = ({ entityId, onReady }: Props) => {
// an update to "state" might lead to an updated UI so we include it as a trigger
}, [updateSidebarPosition, state]);
useEffect(() => {
if (!rawPage || !shadowDomRef.current) {
return;
}
if (onReady) {
onReady();
}
// Pre-render
const transformedElement = transformer(rawPage, [
sanitizeDOM(),
addBaseUrl({
techdocsStorageApi,
entityId: {
kind,
name,
namespace,
},
path,
}),
rewriteDocLinks(),
removeMkdocsHeader(),
simplifyMkdocsFooter(),
addGitFeedbackLink(scmIntegrationsApi),
injectCss({
css: `
// a function that performs transformations that are executed prior to adding it to the DOM
const preRender = useCallback(
(rawContent: string, contentPath: string) =>
transformer(rawContent, [
sanitizeDOM(),
addBaseUrl({
techdocsStorageApi,
entityId: {
kind,
name,
namespace,
},
path: contentPath,
}),
rewriteDocLinks(),
removeMkdocsHeader(),
simplifyMkdocsFooter(),
addGitFeedbackLink(scmIntegrationsApi),
injectCss({
css: `
body {
font-family: ${theme.typography.fontFamily};
--md-text-color: ${theme.palette.text.primary};
@@ -190,21 +185,21 @@ export const Reader = ({ entityId, onReady }: Props) => {
}
}
`,
}),
injectCss({
// Disable CSS animations on link colors as they lead to issues in dark
// mode. The dark mode color theme is applied later and theirfore there
// is always an animation from light to dark mode when navigation
// between pages.
css: `
}),
injectCss({
// Disable CSS animations on link colors as they lead to issues in dark
// mode. The dark mode color theme is applied later and theirfore there
// is always an animation from light to dark mode when navigation
// between pages.
css: `
.md-nav__link, .md-typeset a, .md-typeset a::before, .md-typeset .headerlink {
transition: none;
}
`,
}),
injectCss({
// Properly style code blocks.
css: `
}),
injectCss({
// Properly style code blocks.
css: `
.md-typeset pre > code::-webkit-scrollbar-thumb {
background-color: hsla(0, 0%, 0%, 0.32);
}
@@ -212,17 +207,17 @@ export const Reader = ({ entityId, onReady }: Props) => {
background-color: hsla(0, 0%, 0%, 0.87);
}
`,
}),
injectCss({
// Admonitions and others are using SVG masks to define icons. These
// masks are defined as CSS variables.
// As the MkDocs output is rendered in shadow DOM, the CSS variable
// definitions on the root selector are not applied. Instead, the have
// to be applied on :host.
// As there is no way to transform the served main*.css yet (for
// example in the backend), we have to copy from main*.css and modify
// them.
css: `
}),
injectCss({
// Admonitions and others are using SVG masks to define icons. These
// masks are defined as CSS variables.
// As the MkDocs output is rendered in shadow DOM, the CSS variable
// definitions on the root selector are not applied. Instead, the have
// to be applied on :host.
// As there is no way to transform the served main*.css yet (for
// example in the backend), we have to copy from main*.css and modify
// them.
css: `
:host {
--md-admonition-icon--note: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.71 7.04c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.37-.39-1.02-.39-1.41 0l-1.84 1.83 3.75 3.75M3 17.25V21h3.75L17.81 9.93l-3.75-3.75L3 17.25z"/></svg>');
--md-admonition-icon--abstract: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 5h16v2H4V5m0 4h16v2H4V9m0 4h16v2H4v-2m0 4h10v2H4v-2z"/></svg>');
@@ -248,97 +243,129 @@ export const Reader = ({ entityId, onReady }: Props) => {
--md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2m-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>');
}
`,
}),
]);
}),
]),
[
kind,
name,
namespace,
scmIntegrationsApi,
techdocsStorageApi,
theme.palette.background.default,
theme.palette.background.paper,
theme.palette.primary.main,
theme.palette.text.primary,
theme.typography.fontFamily,
],
);
if (!transformedElement) {
return; // An unexpected error occurred
// a function that performs transformations that are executed after adding it to the DOM
const postRender = useCallback(
async (shadowRoot: ShadowRoot) =>
transformer(shadowRoot.children[0], [
dom => {
setTimeout(() => {
// Scoll to the desired anchor on initial navigation
if (window.location.hash) {
const hash = window.location.hash.slice(1);
shadowRoot?.getElementById(hash)?.scrollIntoView();
}
}, 200);
return dom;
},
addLinkClickListener({
baseUrl: window.location.origin,
onClick: (_: MouseEvent, url: string) => {
const parsedUrl = new URL(url);
if (parsedUrl.hash) {
navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
// Scroll to hash if it's on the current page
shadowRoot
?.getElementById(parsedUrl.hash.slice(1))
?.scrollIntoView();
} else {
navigate(parsedUrl.pathname);
}
},
}),
onCssReady({
docStorageUrl: await techdocsStorageApi.getApiOrigin(),
onLoading: (dom: Element) => {
(dom as HTMLElement).style.setProperty('opacity', '0');
},
onLoaded: (dom: Element) => {
(dom as HTMLElement).style.removeProperty('opacity');
// disable MkDocs drawer toggling ('for' attribute => checkbox mechanism)
(dom as HTMLElement)
.querySelector('.md-nav__title')
?.removeAttribute('for');
const sideDivs: HTMLElement[] = Array.from(
shadowRoot!.querySelectorAll('.md-sidebar'),
);
setSidebars(sideDivs);
// set sidebar height so they don't initially render in wrong position
const docTopPosition = (dom as HTMLElement).getBoundingClientRect()
.top;
const mdTabs = dom.querySelector('.md-container > .md-tabs');
sideDivs!.forEach(sidebar => {
sidebar.style.top = mdTabs
? `${docTopPosition + mdTabs.getBoundingClientRect().height}px`
: `${docTopPosition}px`;
});
},
}),
]),
[navigate, techdocsStorageApi],
);
useEffect(() => {
if (!rawPage || !shadowDomRef.current) {
// clear the shadow dom if no content is available
if (shadowDomRef.current?.shadowRoot) {
shadowDomRef.current.shadowRoot.innerHTML = '';
}
return () => {};
}
if (onReady) {
onReady();
}
const shadowDiv: HTMLElement = shadowDomRef.current!;
const shadowRoot =
shadowDiv.shadowRoot || shadowDiv.attachShadow({ mode: 'open' });
Array.from(shadowRoot.children).forEach(child =>
shadowRoot.removeChild(child),
);
shadowRoot.appendChild(transformedElement);
// if false, there is already a newer execution of this effect
let shouldReplaceContent = true;
// Scroll to top after render
window.scroll({ top: 0 });
// Pre-render
preRender(rawPage, path).then(async transformedElement => {
if (!transformedElement?.innerHTML) {
return; // An unexpected error occurred
}
// Post-render
transformer(shadowRoot.children[0], [
dom => {
setTimeout(() => {
// Scoll to the desired anchor on initial navigation
if (window.location.hash) {
const hash = window.location.hash.slice(1);
shadowRoot?.getElementById(hash)?.scrollIntoView();
}
}, 200);
return dom;
},
addLinkClickListener({
baseUrl: window.location.origin,
onClick: (_: MouseEvent, url: string) => {
const parsedUrl = new URL(url);
// don't manipulate the shadow dom if this isn't the latest effect execution
if (!shouldReplaceContent) {
return;
}
if (parsedUrl.hash) {
navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
const shadowDiv: HTMLElement = shadowDomRef.current!;
const shadowRoot =
shadowDiv.shadowRoot || shadowDiv.attachShadow({ mode: 'open' });
Array.from(shadowRoot.children).forEach(child =>
shadowRoot.removeChild(child),
);
shadowRoot.appendChild(transformedElement);
// Scroll to hash if it's on the current page
shadowRoot
?.getElementById(parsedUrl.hash.slice(1))
?.scrollIntoView();
} else {
navigate(parsedUrl.pathname);
}
},
}),
onCssReady({
docStorageUrl: techdocsStorageApi.getApiOrigin(),
onLoading: (dom: Element) => {
(dom as HTMLElement).style.setProperty('opacity', '0');
},
onLoaded: (dom: Element) => {
(dom as HTMLElement).style.removeProperty('opacity');
// disable MkDocs drawer toggling ('for' attribute => checkbox mechanism)
(dom as HTMLElement)
.querySelector('.md-nav__title')
?.removeAttribute('for');
const sideDivs: HTMLElement[] = Array.from(
shadowRoot!.querySelectorAll('.md-sidebar'),
);
setSidebars(sideDivs);
// set sidebar height so they don't initially render in wrong position
const docTopPosition = (dom as HTMLElement).getBoundingClientRect()
.top;
const mdTabs = dom.querySelector('.md-container > .md-tabs');
sideDivs!.forEach(sidebar => {
sidebar.style.top = mdTabs
? `${docTopPosition + mdTabs.getBoundingClientRect().height}px`
: `${docTopPosition}px`;
});
},
}),
]);
}, [
path,
kind,
namespace,
name,
rawPage,
navigate,
onReady,
shadowDomRef,
techdocsStorageApi,
theme.typography.fontFamily,
theme.palette.text.primary,
theme.palette.primary.main,
theme.palette.background.paper,
theme.palette.background.default,
scmIntegrationsApi,
]);
// Scroll to top after render
window.scroll({ top: 0 });
// Post-render
await postRender(shadowRoot);
});
// cancel this execution
return () => {
shouldReplaceContent = false;
};
}, [onReady, path, postRender, preRender, rawPage]);
return (
<>
@@ -86,7 +86,7 @@ describe('useReaderState', () => {
};
it('should return a copy of the state', () => {
expect(reducer(oldState, { type: 'navigate', path: '/' })).toEqual({
expect(reducer(oldState, { type: 'content', path: '/' })).toEqual({
activeSyncState: 'CHECKING',
contentLoading: false,
path: '/',
@@ -102,13 +102,13 @@ describe('useReaderState', () => {
});
it.each`
type | oldActiveSyncState | newActiveSyncState
${'content'} | ${'BUILD_READY'} | ${'UP_TO_DATE'}
${'content'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'}
${'navigate'} | ${'BUILD_READY'} | ${'UP_TO_DATE'}
${'navigate'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'}
${'sync'} | ${'BUILD_READY'} | ${undefined}
${'sync'} | ${'BUILD_READY_RELOAD'} | ${undefined}
type | oldActiveSyncState | newActiveSyncState
${'contentLoading'} | ${'BUILD_READY'} | ${'UP_TO_DATE'}
${'contentLoading'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'}
${'content'} | ${'BUILD_READY'} | ${'UP_TO_DATE'}
${'content'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'}
${'sync'} | ${'BUILD_READY'} | ${undefined /* undefined, because we don't set an input */}
${'sync'} | ${'BUILD_READY_RELOAD'} | ${undefined /* undefined, because we don't set an input */}
`(
'should, when type=$type and activeSyncState=$oldActiveSyncState, set activeSyncState=$newActiveSyncState',
({ type, oldActiveSyncState, newActiveSyncState }) => {
@@ -124,18 +124,45 @@ describe('useReaderState', () => {
},
);
describe('"content" action', () => {
describe('"contentLoading" action', () => {
it('should set loading', () => {
expect(
reducer(oldState, {
type: 'contentLoading',
}),
).toEqual({
...oldState,
contentLoading: true,
});
});
it('should keep content', () => {
expect(
reducer(
{
...oldState,
content: 'some-old-content',
},
{
type: 'contentLoading',
},
),
).toEqual({
...oldState,
contentLoading: true,
content: 'some-old-content',
});
});
it('should reset errors', () => {
expect(
reducer(
{
...oldState,
contentError: new Error(),
},
{
type: 'content',
contentLoading: true,
type: 'contentLoading',
},
),
).toEqual({
@@ -143,7 +170,9 @@ describe('useReaderState', () => {
contentLoading: true,
});
});
});
describe('"content" action', () => {
it('should set content', () => {
expect(
reducer(
@@ -164,6 +193,27 @@ describe('useReaderState', () => {
});
});
it('should set content and update path', () => {
expect(
reducer(
{
...oldState,
contentLoading: true,
},
{
type: 'content',
content: 'asdf',
path: '/new-path',
},
),
).toEqual({
...oldState,
contentLoading: false,
content: 'asdf',
path: '/new-path',
});
});
it('should set error', () => {
expect(
reducer(
@@ -185,20 +235,6 @@ describe('useReaderState', () => {
});
});
describe('"navigate" action', () => {
it('should work', () => {
expect(
reducer(oldState, {
type: 'navigate',
path: '/',
}),
).toEqual({
...oldState,
path: '/',
});
});
});
describe('"sync" action', () => {
it('should update state', () => {
expect(
@@ -256,6 +292,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -267,6 +304,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -313,6 +351,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -324,6 +363,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'INITIAL_BUILD',
path: '/example',
content: undefined,
contentErrorMessage: 'NotFoundError: Page Not Found',
syncErrorMessage: undefined,
@@ -335,6 +375,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -346,6 +387,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -394,6 +436,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -405,6 +448,7 @@ describe('useReaderState', () => {
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -416,6 +460,7 @@ describe('useReaderState', () => {
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_STALE_REFRESHING',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -427,6 +472,7 @@ describe('useReaderState', () => {
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_STALE_READY',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -441,7 +487,8 @@ describe('useReaderState', () => {
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CHECKING',
content: undefined,
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
@@ -452,6 +499,7 @@ describe('useReaderState', () => {
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
content: 'my new content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -475,6 +523,103 @@ describe('useReaderState', () => {
});
});
it('should handle navigation', async () => {
techdocsStorageApi.getEntityDocs
.mockResolvedValueOnce('my content')
.mockImplementationOnce(async () => {
await new Promise(resolve => setTimeout(resolve, 1100));
return 'my new content';
})
.mockRejectedValueOnce(new NotFoundError('Some error description'));
techdocsStorageApi.syncEntityDocs.mockResolvedValue('cached');
await act(async () => {
const { result, waitForValueToChange, rerender } = await renderHook(
({ path }: { path: string }) =>
useReaderState('Component', 'default', 'backstage', path),
{ initialProps: { path: '/example' }, wrapper: Wrapper as any },
);
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
// show the content
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
// navigate
rerender({ path: '/new' });
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: 'my content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/new',
content: 'my new content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
// navigate
rerender({ path: '/missing' });
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_NOT_FOUND',
path: '/missing',
content: undefined,
contentErrorMessage: 'NotFoundError: Some error description',
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/new',
);
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
});
it('should handle content error', async () => {
techdocsStorageApi.getEntityDocs.mockRejectedValue(
new NotFoundError('Some error description'),
@@ -489,6 +634,7 @@ describe('useReaderState', () => {
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
@@ -500,6 +646,7 @@ describe('useReaderState', () => {
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CONTENT_NOT_FOUND',
path: '/example',
content: undefined,
contentErrorMessage: 'NotFoundError: Some error description',
syncErrorMessage: undefined,
@@ -15,7 +15,7 @@
*/
import { useApi } from '@backstage/core-plugin-api';
import { useEffect, useMemo, useReducer, useRef } from 'react';
import { useMemo, useReducer, useRef } from 'react';
import { useAsync, useAsyncRetry } from 'react-use';
import { techdocsStorageApiRef } from '../../api';
@@ -131,13 +131,13 @@ type ReducerActions =
state: SyncStates;
syncError?: Error;
}
| { type: 'contentLoading' }
| {
type: 'content';
path?: string;
content?: string;
contentLoading?: true;
contentError?: Error;
}
| { type: 'navigate'; path: string }
| { type: 'buildLog'; log: string };
type ReducerState = {
@@ -186,14 +186,22 @@ export function reducer(
newState.syncError = action.syncError;
break;
case 'content':
newState.content = action.content;
newState.contentLoading = action.contentLoading ?? false;
newState.contentError = action.contentError;
case 'contentLoading':
newState.contentLoading = true;
// only reset errors but keep the old content until it is replaced by the 'content' action
newState.contentError = undefined;
break;
case 'navigate':
newState.path = action.path;
case 'content':
// only override the path if it is part of the action
if (typeof action.path === 'string') {
newState.path = action.path;
}
newState.contentLoading = false;
newState.content = action.content;
newState.contentError = action.contentError;
break;
case 'buildLog':
@@ -204,10 +212,10 @@ export function reducer(
throw new Error();
}
// a navigation or a content update loads fresh content so the build is updated to being up-to-date
// a content update loads fresh content so the build is updated to being up-to-date
if (
['BUILD_READY', 'BUILD_READY_RELOAD'].includes(newState.activeSyncState) &&
['content', 'navigate'].includes(action.type)
['contentLoading', 'content'].includes(action.type)
) {
newState.activeSyncState = 'UP_TO_DATE';
newState.buildLog = [];
@@ -223,6 +231,7 @@ export function useReaderState(
path: string,
): {
state: ContentStateTypes;
path: string;
contentReload: () => void;
content?: string;
contentErrorMessage?: string;
@@ -238,14 +247,9 @@ export function useReaderState(
const techdocsStorageApi = useApi(techdocsStorageApiRef);
// convert all path changes into actions
useEffect(() => {
dispatch({ type: 'navigate', path });
}, [path]);
// try to load the content. the function will fire events and we don't care for the return values
const { retry: contentReload } = useAsyncRetry(async () => {
dispatch({ type: 'content', contentLoading: true });
dispatch({ type: 'contentLoading' });
try {
const entityDocs = await techdocsStorageApi.getEntityDocs(
@@ -253,11 +257,12 @@ export function useReaderState(
path,
);
dispatch({ type: 'content', content: entityDocs });
// update content and path at the same time
dispatch({ type: 'content', content: entityDocs, path });
return entityDocs;
} catch (e) {
dispatch({ type: 'content', contentError: e });
dispatch({ type: 'content', contentError: e, path });
}
return undefined;
@@ -335,6 +340,7 @@ export function useReaderState(
return {
state: displayState,
contentReload,
path: state.path,
content: state.content,
contentErrorMessage: state.contentError?.toString(),
syncErrorMessage: state.syncError?.toString(),
@@ -15,9 +15,9 @@
*/
import { waitFor } from '@testing-library/react';
import { createTestShadowDom } from '../../test-utils';
import { addBaseUrl } from '../transformers';
import { TechDocsStorageApi } from '../../api';
import { createTestShadowDom } from '../../test-utils';
import { addBaseUrl } from './addBaseUrl';
const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com';
const API_ORIGIN_URL = 'https://backstage.example.com/api/techdocs';
@@ -62,8 +62,8 @@ describe('addBaseUrl', () => {
global.fetch = originalFetch;
});
it('contains relative paths', () => {
createTestShadowDom(fixture, {
it('contains relative paths', async () => {
await createTestShadowDom(fixture, {
preTransformers: [
addBaseUrl({
techdocsStorageApi,
@@ -110,7 +110,7 @@ describe('addBaseUrl', () => {
text: jest.fn().mockResolvedValue(svgContent),
});
const root = createTestShadowDom('<img id="x" src="test.svg" />', {
const root = await createTestShadowDom('<img id="x" src="test.svg" />', {
preTransformers: [
addBaseUrl({
techdocsStorageApi,
@@ -137,7 +137,7 @@ describe('addBaseUrl', () => {
text: jest.fn().mockResolvedValue(svgContent),
});
const root = createTestShadowDom(
const root = await createTestShadowDom(
`<img id="x" src="${API_ORIGIN_URL}/test.svg" />`,
{
preTransformers: [
@@ -162,16 +162,19 @@ describe('addBaseUrl', () => {
it('does not inline external svgs', async () => {
const expectedSrc = 'https://example.com/test.svg';
const root = createTestShadowDom(`<img id="x" src="${expectedSrc}" />`, {
preTransformers: [
addBaseUrl({
techdocsStorageApi,
entityId: mockEntityId,
path: '',
}),
],
postTransformers: [],
});
const root = await createTestShadowDom(
`<img id="x" src="${expectedSrc}" />`,
{
preTransformers: [
addBaseUrl({
techdocsStorageApi,
entityId: mockEntityId,
path: '',
}),
],
postTransformers: [],
},
);
await new Promise<void>(done => {
process.nextTick(() => {
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { EntityName } from '@backstage/catalog-model';
import type { Transformer } from './transformer';
import { TechDocsStorageApi } from '../../api';
import type { Transformer } from './transformer';
type AddBaseUrlOptions = {
techdocsStorageApi: TechDocsStorageApi;
@@ -44,14 +44,15 @@ export const addBaseUrl = ({
entityId,
path,
}: AddBaseUrlOptions): Transformer => {
return dom => {
const updateDom = <T extends Element>(
return async dom => {
const apiOrigin = await techdocsStorageApi.getApiOrigin();
const updateDom = async <T extends Element>(
list: HTMLCollectionOf<T> | NodeListOf<T>,
attributeName: string,
): void => {
Array.from(list)
.filter(elem => !!elem.getAttribute(attributeName))
.forEach(async (elem: T) => {
) => {
for (const elem of list) {
if (elem.hasAttribute(attributeName)) {
const elemAttribute = elem.getAttribute(attributeName);
if (!elemAttribute) return;
@@ -61,7 +62,7 @@ export const addBaseUrl = ({
entityId,
path,
);
const apiOrigin = await techdocsStorageApi.getApiOrigin();
if (isSvgNeedingInlining(attributeName, elemAttribute, apiOrigin)) {
try {
const svg = await fetch(newValue, { credentials: 'include' });
@@ -76,13 +77,16 @@ export const addBaseUrl = ({
} else {
elem.setAttribute(attributeName, newValue);
}
});
}
}
};
updateDom<HTMLImageElement>(dom.querySelectorAll('img'), 'src');
updateDom<HTMLScriptElement>(dom.querySelectorAll('script'), 'src');
updateDom<HTMLLinkElement>(dom.querySelectorAll('link'), 'href');
updateDom<HTMLAnchorElement>(dom.querySelectorAll('a[download]'), 'href');
await Promise.all([
updateDom<HTMLImageElement>(dom.querySelectorAll('img'), 'src'),
updateDom<HTMLScriptElement>(dom.querySelectorAll('script'), 'src'),
updateDom<HTMLLinkElement>(dom.querySelectorAll('link'), 'href'),
updateDom<HTMLAnchorElement>(dom.querySelectorAll('a[download]'), 'href'),
]);
return dom;
};
@@ -28,8 +28,8 @@ const integrations = ScmIntegrations.fromConfig(
);
describe('addGitFeedbackLink', () => {
it('adds a feedback link when a Gitlab source edit link is available', () => {
const shadowDom = createTestShadowDom(
it('adds a feedback link when a Gitlab source edit link is available', async () => {
const shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
@@ -53,8 +53,8 @@ describe('addGitFeedbackLink', () => {
);
});
it('adds a feedback link when a Github source edit link is available', () => {
const shadowDom = createTestShadowDom(
it('adds a feedback link when a Github source edit link is available', async () => {
const shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
@@ -78,8 +78,8 @@ describe('addGitFeedbackLink', () => {
);
});
it('does not add a feedback link when no source edit link is available', () => {
const shadowDom = createTestShadowDom(
it('does not add a feedback link when no source edit link is available', async () => {
const shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
@@ -97,8 +97,8 @@ describe('addGitFeedbackLink', () => {
expect(shadowDom.querySelector('#git-feedback-link')).toBeFalsy();
});
it('does not add a feedback link when a Gitlab or Github source edit link is not available', () => {
const shadowDom = createTestShadowDom(
it('does not add a feedback link when a Gitlab or Github source edit link is not available', async () => {
const shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
@@ -117,8 +117,8 @@ describe('addGitFeedbackLink', () => {
expect(shadowDom.querySelector('#git-feedback-link')).toBeFalsy();
});
it('adds a feedback link when a Gitlab or Github source edit link is not available but hostname matches an integrations host', () => {
const shadowDom = createTestShadowDom(
it('adds a feedback link when a Gitlab or Github source edit link is not available but hostname matches an integrations host', async () => {
const shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
@@ -18,9 +18,9 @@ import { createTestShadowDom } from '../../test-utils';
import { addLinkClickListener } from './addLinkClickListener';
describe('addLinkClickListener', () => {
it('calls onClick when a link has been clicked', () => {
it('calls onClick when a link has been clicked', async () => {
const fn = jest.fn();
const shadowDom = createTestShadowDom(
const shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
@@ -45,9 +45,9 @@ describe('addLinkClickListener', () => {
expect(fn).toHaveBeenCalledTimes(1);
});
it('does not call onClick when a link links to another baseUrl', () => {
it('does not call onClick when a link links to another baseUrl', async () => {
const fn = jest.fn();
const shadowDom = createTestShadowDom(
const shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
@@ -17,14 +17,14 @@
import { Transformer, transform } from './transformer';
describe('transform', () => {
it('calls the transformers', () => {
it('calls the transformers', async () => {
const fn = jest.fn();
const mockTransformer = (): Transformer => (dom: Element) => {
fn(dom);
return dom;
};
transform('<html></html>', [mockTransformer()]);
await transform('<html></html>', [mockTransformer()]);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith(expect.any(Element));
@@ -15,10 +15,10 @@
*/
import { createTestShadowDom } from '../../test-utils';
import { injectCss } from '../transformers';
import { injectCss } from './injectCss';
describe('injectCss', () => {
it('should inject style with passed css in head', () => {
it('should inject style with passed css in head', async () => {
const html = `
<html>
<head></head>
@@ -27,7 +27,7 @@ describe('injectCss', () => {
`;
const injectedCss = '* {background-color: #fff}';
const shadowDom = createTestShadowDom(html, {
const shadowDom = await createTestShadowDom(html, {
preTransformers: [injectCss({ css: injectedCss })],
postTransformers: [],
});
@@ -15,16 +15,15 @@
*/
import {
createTestShadowDom,
mockStylesheetEventListener,
executeStylesheetEventListeners,
clearStylesheetEventListeners,
createTestShadowDom,
executeStylesheetEventListeners,
mockStylesheetEventListener,
} from '../../test-utils';
import { onCssReady } from '../transformers';
import { onCssReady } from './onCssReady';
const docStorageUrl: Promise<string> = Promise.resolve(
'https://techdocs-mock-sites.storage.googleapis.com',
);
const docStorageUrl: string =
'https://techdocs-mock-sites.storage.googleapis.com';
const fixture = `
<link rel="stylesheet" href="${docStorageUrl}/test.css" />
@@ -48,11 +47,11 @@ describe('onCssReady', () => {
clearStylesheetEventListeners();
});
it('does not call onLoading and onLoaded without the onCssReady transformer', () => {
it('does not call onLoading and onLoaded without the onCssReady transformer', async () => {
const onLoading = jest.fn();
const onLoaded = jest.fn();
createTestShadowDom(fixture, {
await createTestShadowDom(fixture, {
preTransformers: [],
postTransformers: [],
});
@@ -62,11 +61,11 @@ describe('onCssReady', () => {
expect(onLoaded).not.toHaveBeenCalled();
});
it('calls the onLoading and onLoaded correctly', () => {
it('calls the onLoading and onLoaded correctly', async () => {
const onLoading = jest.fn();
const onLoaded = jest.fn();
createTestShadowDom(fixture, {
await createTestShadowDom(fixture, {
preTransformers: [],
postTransformers: [
onCssReady({
@@ -17,7 +17,7 @@
import type { Transformer } from './transformer';
type OnCssReadyOptions = {
docStorageUrl: Promise<string>;
docStorageUrl: string;
onLoading: (dom: Element) => void;
onLoaded: (dom: Element) => void;
};
@@ -30,9 +30,7 @@ export const onCssReady = ({
return dom => {
const cssPages = Array.from(
dom.querySelectorAll('head > link[rel="stylesheet"]'),
).filter(async elem =>
elem.getAttribute('href')?.startsWith(await docStorageUrl),
);
).filter(elem => elem.getAttribute('href')?.startsWith(docStorageUrl));
let count = cssPages.length;
@@ -18,20 +18,26 @@ import { createTestShadowDom, FIXTURES } from '../../test-utils';
import { removeMkdocsHeader } from '../transformers';
describe('removeMkdocsHeader', () => {
it('does not remove mkdocs header', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
preTransformers: [],
postTransformers: [],
});
it('does not remove mkdocs header', async () => {
const shadowDom = await createTestShadowDom(
FIXTURES.FIXTURE_STANDARD_PAGE,
{
preTransformers: [],
postTransformers: [],
},
);
expect(shadowDom.querySelector('.md-header')).toBeTruthy();
});
it('does remove mkdocs header', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
preTransformers: [removeMkdocsHeader()],
postTransformers: [],
});
it('does remove mkdocs header', async () => {
const shadowDom = await createTestShadowDom(
FIXTURES.FIXTURE_STANDARD_PAGE,
{
preTransformers: [removeMkdocsHeader()],
postTransformers: [],
},
);
expect(shadowDom.querySelector('.md-header')).toBeFalsy();
});
@@ -19,8 +19,8 @@ import { rewriteDocLinks } from '../transformers';
import { normalizeUrl } from './rewriteDocLinks';
describe('rewriteDocLinks', () => {
it('should not do anything', () => {
const shadowDom = createTestShadowDom(`
it('should not do anything', async () => {
const shadowDom = await createTestShadowDom(`
<a href="http://example.org/">Test</a>
<a href="../example">Test</a>
<a href="example-docs">Test</a>
@@ -35,8 +35,8 @@ describe('rewriteDocLinks', () => {
]);
});
it('should transform a href with localhost as baseUrl', () => {
const shadowDom = createTestShadowDom(
it('should transform a href with localhost as baseUrl', async () => {
const shadowDom = await createTestShadowDom(
`
<a href="http://example.org/">Test</a>
<a href="../example">Test</a>
@@ -57,9 +57,9 @@ describe('rewriteDocLinks', () => {
]);
});
it('should rewrite non-parseable URLs as text', () => {
it('should rewrite non-parseable URLs as text', async () => {
const expectedText = `www.my-internet.[top-level-domain]/pathname/[URLkey]`;
const shadowDom = createTestShadowDom(
const shadowDom = await createTestShadowDom(
`<a href="http://${expectedText}">${expectedText}</a>`,
{
preTransformers: [rewriteDocLinks()],
@@ -16,7 +16,7 @@
import { createTestShadowDom, FIXTURES } from '../../../test-utils';
import { Transformer } from '../index';
import { sanitizeDOM } from '../sanitizeDOM';
import { sanitizeDOM } from './index';
const injectMaliciousLink = (): Transformer => dom => {
const link = document.createElement('a');
@@ -27,55 +27,64 @@ const injectMaliciousLink = (): Transformer => dom => {
};
describe('sanitizeDOM', () => {
it('contains a script tag', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE);
it('contains a script tag', async () => {
const shadowDom = await createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE);
expect(shadowDom.querySelectorAll('script').length).toBeGreaterThan(0);
});
it('does not contain a script tag', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
preTransformers: [sanitizeDOM()],
postTransformers: [],
});
it('does not contain a script tag', async () => {
const shadowDom = await createTestShadowDom(
FIXTURES.FIXTURE_STANDARD_PAGE,
{
preTransformers: [sanitizeDOM()],
postTransformers: [],
},
);
expect(shadowDom.querySelectorAll('script').length).toBe(0);
});
it('contains link with a onClick attribute', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
preTransformers: [injectMaliciousLink()],
postTransformers: [],
});
it('contains link with a onClick attribute', async () => {
const shadowDom = await createTestShadowDom(
FIXTURES.FIXTURE_STANDARD_PAGE,
{
preTransformers: [injectMaliciousLink()],
postTransformers: [],
},
);
expect(
shadowDom.querySelector('#test-malicious-link')?.hasAttribute('onclick'),
).toBeTruthy();
});
it('does not contain link with a onClick attribute', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
preTransformers: [sanitizeDOM()],
postTransformers: [],
});
it('does not contain link with a onClick attribute', async () => {
const shadowDom = await createTestShadowDom(
FIXTURES.FIXTURE_STANDARD_PAGE,
{
preTransformers: [sanitizeDOM()],
postTransformers: [],
},
);
expect(
shadowDom.querySelector('#test-malicious-link')?.hasAttribute('onclick'),
).toBeFalsy();
});
it('removes style tags', () => {
it('removes style tags', async () => {
const html = `
<html>
<head>
<style>* {color: #f0f;}<style>
<style>* {color: #f0f;}</style>
</head>
<body>
</body>
</html>
`;
const shadowDom = createTestShadowDom(html, {
const shadowDom = await createTestShadowDom(html, {
preTransformers: [sanitizeDOM()],
postTransformers: [],
});
@@ -83,7 +92,7 @@ describe('sanitizeDOM', () => {
expect(shadowDom.querySelectorAll('style').length).toEqual(0);
});
it('does not remove link tags', () => {
it('does not remove link tags', async () => {
const html = `
<html>
<head>
@@ -94,7 +103,7 @@ describe('sanitizeDOM', () => {
</html>
`;
const shadowDom = createTestShadowDom(html, {
const shadowDom = await createTestShadowDom(html, {
preTransformers: [sanitizeDOM()],
postTransformers: [],
});
@@ -18,20 +18,26 @@ import { createTestShadowDom, FIXTURES } from '../../test-utils';
import { simplifyMkdocsFooter } from './simplifyMkdocsFooter';
describe('simplifyMkdocsFooter', () => {
it('does not remove mkdocs copyright', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
preTransformers: [],
postTransformers: [],
});
it('does not remove mkdocs copyright', async () => {
const shadowDom = await createTestShadowDom(
FIXTURES.FIXTURE_STANDARD_PAGE,
{
preTransformers: [],
postTransformers: [],
},
);
expect(shadowDom.querySelector('.md-footer-copyright')).toBeTruthy();
});
it('does remove mkdocs copyright', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
preTransformers: [simplifyMkdocsFooter()],
postTransformers: [],
});
it('does remove mkdocs copyright', async () => {
const shadowDom = await createTestShadowDom(
FIXTURES.FIXTURE_STANDARD_PAGE,
{
preTransformers: [simplifyMkdocsFooter()],
postTransformers: [],
},
);
expect(shadowDom.querySelector('.md-footer-copyright')).toBeFalsy();
});
@@ -14,12 +14,12 @@
* limitations under the License.
*/
export type Transformer = (dom: Element) => Element;
export type Transformer = (dom: Element) => Element | Promise<Element>;
export const transform = (
export const transform = async (
html: string | Element,
transformers: Transformer[],
): Element => {
): Promise<Element> => {
let dom: Element;
if (typeof html === 'string') {
@@ -30,9 +30,9 @@ export const transform = (
throw new Error('dom is not a recognized type');
}
transformers.forEach(transformer => {
dom = transformer(dom);
});
for (const transformer of transformers) {
dom = await transformer(dom);
}
return dom;
};
+4 -4
View File
@@ -22,13 +22,13 @@ export type CreateTestShadowDomOptions = {
postTransformers: Transformer[];
};
export const createTestShadowDom = (
export const createTestShadowDom = async (
fixture: string,
opts: CreateTestShadowDomOptions = {
preTransformers: [],
postTransformers: [],
},
): ShadowRoot => {
): Promise<ShadowRoot> => {
const divElement = document.createElement('div');
divElement.attachShadow({ mode: 'open' });
document.body.appendChild(divElement);
@@ -39,7 +39,7 @@ export const createTestShadowDom = (
'text/html',
).documentElement;
if (opts.preTransformers) {
dom = transformer(dom, opts.preTransformers);
dom = await transformer(dom, opts.preTransformers);
}
// Mount the UI
@@ -47,7 +47,7 @@ export const createTestShadowDom = (
// Transformers after the UI is rendered
if (opts.postTransformers) {
transformer(dom, opts.postTransformers);
await transformer(dom, opts.postTransformers);
}
return divElement.shadowRoot!;
+3 -3
View File
@@ -5846,9 +5846,9 @@
"@types/istanbul-lib-report" "*"
"@types/jenkins@^0.23.1":
version "0.23.1"
resolved "https://registry.npmjs.org/@types/jenkins/-/jenkins-0.23.1.tgz#d0f0ef5511beff975c91cbd2365e580d700ca7f9"
integrity sha512-3oGxVCq+5esbjb0BQXUv0Iz0/7ogJxmzaxKtxwwMik5vGtRvfjWf/sXGA1RzkVAG0+rJUZNKStjKRdtqJfEyRg==
version "0.23.2"
resolved "https://registry.npmjs.org/@types/jenkins/-/jenkins-0.23.2.tgz#96736a2be4904efdfe7fe2650569479fd77dc48e"
integrity sha512-BELmIZ6brxwGFqBHfLjLTjYRWAUqcT1d2BydH1CcRTZEjCYw3DRVfZkXU7BVlyIsKXm2ZMIKVkEjKKtVDvPiXA==
dependencies:
"@types/node" "*"