Merge pull request #6341 from SDA-SE/feat/techdocs-build-logs
This commit is contained in:
@@ -50,7 +50,7 @@ export const Reader: ({ entityId, onReady }: Props_2) => JSX.Element;
|
||||
export const Router: () => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export type SyncResult = 'cached' | 'updated' | 'timeout';
|
||||
export type SyncResult = 'cached' | 'updated';
|
||||
|
||||
// @public (undocumented)
|
||||
export interface TechDocsApi {
|
||||
@@ -129,7 +129,10 @@ export interface TechDocsStorageApi {
|
||||
// (undocumented)
|
||||
getStorageUrl(): Promise<string>;
|
||||
// (undocumented)
|
||||
syncEntityDocs(entityId: EntityName): Promise<SyncResult>;
|
||||
syncEntityDocs(
|
||||
entityId: EntityName,
|
||||
logHandler?: (line: string) => void,
|
||||
): Promise<SyncResult>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -165,7 +168,10 @@ export class TechDocsStorageClient implements TechDocsStorageApi {
|
||||
getStorageUrl(): Promise<string>;
|
||||
// (undocumented)
|
||||
identityApi: IdentityApi;
|
||||
syncEntityDocs(entityId: EntityName): Promise<SyncResult>;
|
||||
syncEntityDocs(
|
||||
entityId: EntityName,
|
||||
logHandler?: (line: string) => void,
|
||||
): Promise<SyncResult>;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import React from 'react';
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import {
|
||||
Reader,
|
||||
SyncResult,
|
||||
@@ -81,9 +82,18 @@ function createPage({
|
||||
});
|
||||
}
|
||||
|
||||
async syncEntityDocs() {
|
||||
async syncEntityDocs(_: EntityName, logHandler?: (line: string) => void) {
|
||||
if (syncDocsDelay) {
|
||||
await new Promise(resolve => setTimeout(resolve, syncDocsDelay));
|
||||
for (let i = 0; i < 10; i++) {
|
||||
setTimeout(
|
||||
() => logHandler?.call(this, `Log line ${i}`),
|
||||
((i + 1) * syncDocsDelay) / 10,
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, syncDocsDelay);
|
||||
});
|
||||
}
|
||||
|
||||
return syncDocs();
|
||||
@@ -138,6 +148,11 @@ createDevApp()
|
||||
|
||||
<TabbedLayout.Route path="/stale" title="Stale">
|
||||
{createPage({
|
||||
entityDocs: ({ called, content }) => {
|
||||
return called === 0
|
||||
? content
|
||||
: content.replace(/World/, 'New World');
|
||||
},
|
||||
syncDocs: () => 'updated',
|
||||
syncDocsDelay: 2000,
|
||||
})}
|
||||
@@ -195,13 +210,6 @@ createDevApp()
|
||||
syncDocsDelay: 2000,
|
||||
})}
|
||||
</TabbedLayout.Route>
|
||||
|
||||
<TabbedLayout.Route path="/timeout" title="Sync Timeout">
|
||||
{createPage({
|
||||
syncDocs: () => 'timeout',
|
||||
syncDocsDelay: 2000,
|
||||
})}
|
||||
</TabbedLayout.Route>
|
||||
</TabbedLayout>
|
||||
</Page>
|
||||
),
|
||||
|
||||
@@ -44,8 +44,10 @@
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"@material-ui/styles": "^4.10.0",
|
||||
"eventsource": "^1.1.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-lazylog": "^4.5.2",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^17.2.4",
|
||||
@@ -60,6 +62,7 @@
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/react-hooks": "^3.4.2",
|
||||
"@testing-library/user-event": "^13.1.8",
|
||||
"@types/eventsource": "^1.1.5",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^14.14.32",
|
||||
"@types/react": "^16.9",
|
||||
|
||||
@@ -28,14 +28,17 @@ export const techdocsApiRef = createApiRef<TechDocsApi>({
|
||||
description: 'Used to make requests towards techdocs API',
|
||||
});
|
||||
|
||||
export type SyncResult = 'cached' | 'updated' | 'timeout';
|
||||
export type SyncResult = 'cached' | 'updated';
|
||||
|
||||
export interface TechDocsStorageApi {
|
||||
getApiOrigin(): Promise<string>;
|
||||
getStorageUrl(): Promise<string>;
|
||||
getBuilder(): Promise<string>;
|
||||
getEntityDocs(entityId: EntityName, path: string): Promise<string>;
|
||||
syncEntityDocs(entityId: EntityName): Promise<SyncResult>;
|
||||
syncEntityDocs(
|
||||
entityId: EntityName,
|
||||
logHandler?: (line: string) => void,
|
||||
): Promise<SyncResult>;
|
||||
getBaseUrl(
|
||||
oldBaseUrl: string,
|
||||
entityId: EntityName,
|
||||
|
||||
@@ -13,9 +13,19 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { TechDocsStorageClient } from './client';
|
||||
import { UrlPatternDiscovery } from '@backstage/core-app-api';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import EventSource from 'eventsource';
|
||||
import { TechDocsStorageClient } from './client';
|
||||
|
||||
const MockedEventSource: jest.MockedClass<
|
||||
typeof EventSource
|
||||
> = EventSource as any;
|
||||
|
||||
jest.mock('eventsource');
|
||||
|
||||
const mockEntity = {
|
||||
kind: 'Component',
|
||||
@@ -29,6 +39,16 @@ describe('TechDocsStorageClient', () => {
|
||||
getOptionalString: () => 'http://backstage:9191/api/techdocs',
|
||||
} as Partial<Config>;
|
||||
const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl);
|
||||
const identityApi: jest.Mocked<IdentityApi> = {
|
||||
getIdToken: jest.fn(),
|
||||
getProfile: jest.fn(),
|
||||
getUserId: jest.fn(),
|
||||
signOut: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return correct base url based on defined storage', async () => {
|
||||
// @ts-ignore Partial<Config> not assignable to Config.
|
||||
@@ -51,4 +71,183 @@ describe('TechDocsStorageClient', () => {
|
||||
`${mockBaseUrl}/static/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('syncEntityDocs', () => {
|
||||
it('should create eventsource without headers', async () => {
|
||||
const storageApi = new TechDocsStorageClient({
|
||||
// @ts-ignore Partial<Config> not assignable to Config.
|
||||
configApi,
|
||||
discoveryApi,
|
||||
identityApi,
|
||||
});
|
||||
|
||||
MockedEventSource.prototype.addEventListener.mockImplementation(
|
||||
(type, fn) => {
|
||||
if (type === 'finish') {
|
||||
fn({ data: '{"updated": false}' } as any);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await storageApi.syncEntityDocs(mockEntity);
|
||||
|
||||
expect(
|
||||
MockedEventSource,
|
||||
).toBeCalledWith(
|
||||
'http://backstage:9191/api/techdocs/sync/default/Component/test-component',
|
||||
{ withCredentials: true, headers: {} },
|
||||
);
|
||||
});
|
||||
|
||||
it('should create eventsource with headers', async () => {
|
||||
const storageApi = new TechDocsStorageClient({
|
||||
// @ts-ignore Partial<Config> not assignable to Config.
|
||||
configApi,
|
||||
discoveryApi,
|
||||
identityApi,
|
||||
});
|
||||
|
||||
MockedEventSource.prototype.addEventListener.mockImplementation(
|
||||
(type, fn) => {
|
||||
if (type === 'finish') {
|
||||
fn({ data: '{"updated": false}' } as any);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
identityApi.getIdToken.mockResolvedValue('token');
|
||||
|
||||
await storageApi.syncEntityDocs(mockEntity);
|
||||
|
||||
expect(
|
||||
MockedEventSource,
|
||||
).toBeCalledWith(
|
||||
'http://backstage:9191/api/techdocs/sync/default/Component/test-component',
|
||||
{ withCredentials: true, headers: { Authorization: 'Bearer token' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve to cached', async () => {
|
||||
const storageApi = new TechDocsStorageClient({
|
||||
// @ts-ignore Partial<Config> not assignable to Config.
|
||||
configApi,
|
||||
discoveryApi,
|
||||
identityApi,
|
||||
});
|
||||
|
||||
MockedEventSource.prototype.addEventListener.mockImplementation(
|
||||
(type, fn) => {
|
||||
if (type === 'finish') {
|
||||
fn({ data: '{"updated": false}' } as any);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await expect(storageApi.syncEntityDocs(mockEntity)).resolves.toEqual(
|
||||
'cached',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve to updated', async () => {
|
||||
const storageApi = new TechDocsStorageClient({
|
||||
// @ts-ignore Partial<Config> not assignable to Config.
|
||||
configApi,
|
||||
discoveryApi,
|
||||
identityApi,
|
||||
});
|
||||
|
||||
MockedEventSource.prototype.addEventListener.mockImplementation(
|
||||
(type, fn) => {
|
||||
if (type === 'finish') {
|
||||
fn({ data: '{"updated": true}' } as any);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await expect(storageApi.syncEntityDocs(mockEntity)).resolves.toEqual(
|
||||
'updated',
|
||||
);
|
||||
});
|
||||
|
||||
it('should log values', async () => {
|
||||
const storageApi = new TechDocsStorageClient({
|
||||
// @ts-ignore Partial<Config> not assignable to Config.
|
||||
configApi,
|
||||
discoveryApi,
|
||||
identityApi,
|
||||
});
|
||||
|
||||
MockedEventSource.prototype.addEventListener.mockImplementation(
|
||||
(type, fn) => {
|
||||
if (type === 'log') {
|
||||
fn({ data: '"A log message"' } as any);
|
||||
}
|
||||
|
||||
if (type === 'finish') {
|
||||
fn({ data: '{"updated": false}' } as any);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const logHandler = jest.fn();
|
||||
await expect(
|
||||
storageApi.syncEntityDocs(mockEntity, logHandler),
|
||||
).resolves.toEqual('cached');
|
||||
|
||||
expect(logHandler).toBeCalledTimes(1);
|
||||
expect(logHandler).toBeCalledWith('A log message');
|
||||
});
|
||||
|
||||
it('should throw NotFoundError', async () => {
|
||||
const storageApi = new TechDocsStorageClient({
|
||||
// @ts-ignore Partial<Config> not assignable to Config.
|
||||
configApi,
|
||||
discoveryApi,
|
||||
identityApi,
|
||||
});
|
||||
|
||||
// we await later after we emitted the error
|
||||
const promise = storageApi.syncEntityDocs(mockEntity).then();
|
||||
|
||||
// flush the event loop
|
||||
await new Promise(setImmediate);
|
||||
|
||||
const instance = MockedEventSource.mock
|
||||
.instances[0] as jest.Mocked<EventSource>;
|
||||
|
||||
instance.onerror({
|
||||
status: 404,
|
||||
message: 'Some not found warning',
|
||||
} as any);
|
||||
|
||||
await expect(promise).rejects.toThrow(NotFoundError);
|
||||
await expect(promise).rejects.toThrowError('Some not found warning');
|
||||
});
|
||||
|
||||
it('should throw generic errors', async () => {
|
||||
const storageApi = new TechDocsStorageClient({
|
||||
// @ts-ignore Partial<Config> not assignable to Config.
|
||||
configApi,
|
||||
discoveryApi,
|
||||
identityApi,
|
||||
});
|
||||
|
||||
// we await later after we emitted the error
|
||||
const promise = storageApi.syncEntityDocs(mockEntity).then();
|
||||
|
||||
// flush the event loop
|
||||
await new Promise(setImmediate);
|
||||
|
||||
const instance = MockedEventSource.mock
|
||||
.instances[0] as jest.Mocked<EventSource>;
|
||||
|
||||
instance.onerror({
|
||||
type: 'error',
|
||||
data: 'Some other error',
|
||||
} as any);
|
||||
|
||||
await expect(promise).rejects.toThrow(Error);
|
||||
await expect(promise).rejects.toThrowError('Some other error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import EventSource from 'eventsource';
|
||||
import { SyncResult, TechDocsApi, TechDocsStorageApi } from './api';
|
||||
import { TechDocsEntityMetadata, TechDocsMetadata } from './types';
|
||||
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
|
||||
|
||||
/**
|
||||
* API to talk to techdocs-backend.
|
||||
@@ -192,44 +193,59 @@ export class TechDocsStorageClient implements TechDocsStorageApi {
|
||||
* Check if docs are on the latest version and trigger rebuild if not
|
||||
*
|
||||
* @param {EntityName} entityId Object containing entity data like name, namespace, etc.
|
||||
* @param {Function} logHandler Callback to receive log messages from the build process
|
||||
* @returns {SyncResult} Whether documents are currently synchronized to newest version
|
||||
* @throws {Error} Throws error on error from sync endpoint in Techdocs Backend
|
||||
*/
|
||||
async syncEntityDocs(entityId: EntityName): Promise<SyncResult> {
|
||||
async syncEntityDocs(
|
||||
entityId: EntityName,
|
||||
logHandler: (line: string) => void = () => {},
|
||||
): Promise<SyncResult> {
|
||||
const { kind, namespace, name } = entityId;
|
||||
|
||||
const apiOrigin = await this.getApiOrigin();
|
||||
const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`;
|
||||
const token = await this.identityApi.getIdToken();
|
||||
let request;
|
||||
let attempts: number = 0;
|
||||
// retry if request times out, up to 5 times
|
||||
// can happen due to docs taking too long to generate
|
||||
while (!request || (request.status === 408 && attempts < 5)) {
|
||||
attempts++;
|
||||
request = await fetch(url, {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const source = new EventSource(url, {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}
|
||||
|
||||
switch (request.status) {
|
||||
case 404:
|
||||
throw new NotFoundError((await request.json()).error);
|
||||
source.addEventListener('log', (e: any) => {
|
||||
if (e.data) {
|
||||
logHandler(JSON.parse(e.data));
|
||||
}
|
||||
});
|
||||
|
||||
case 200:
|
||||
case 304:
|
||||
return 'cached';
|
||||
source.addEventListener('finish', (e: any) => {
|
||||
let updated: boolean = false;
|
||||
|
||||
case 201:
|
||||
return 'updated';
|
||||
if (e.data) {
|
||||
({ updated } = JSON.parse(e.data));
|
||||
}
|
||||
|
||||
// for timeout and misc errors, handle without error to allow viewing older docs
|
||||
// if older docs not available,
|
||||
// Reader will show 404 error coming from getEntityDocs
|
||||
case 408:
|
||||
default:
|
||||
return 'timeout';
|
||||
}
|
||||
resolve(updated ? 'updated' : 'cached');
|
||||
});
|
||||
|
||||
source.onerror = (e: any) => {
|
||||
source.close();
|
||||
|
||||
switch (e.status) {
|
||||
// the endpoint returned a 404 status
|
||||
case 404:
|
||||
reject(new NotFoundError(e.message));
|
||||
return;
|
||||
|
||||
// also handles the event-stream close. the reject is ignored if the Promise was already
|
||||
// resolved by a finish event.
|
||||
default:
|
||||
reject(new Error(e.data));
|
||||
return;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getBaseUrl(
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
*/
|
||||
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { Progress } from '@backstage/core-components';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { scmIntegrationsApiRef } from '@backstage/integration-react';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { useTheme } from '@material-ui/core';
|
||||
import { Button, CircularProgress, useTheme } from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
@@ -34,10 +36,9 @@ import {
|
||||
simplifyMkdocsFooter,
|
||||
transform as transformer,
|
||||
} from '../transformers';
|
||||
import { TechDocsBuildLogs } from './TechDocsBuildLogs';
|
||||
import { TechDocsNotFound } from './TechDocsNotFound';
|
||||
import TechDocsProgressBar from './TechDocsProgressBar';
|
||||
import { useReaderState } from './useReaderState';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
type Props = {
|
||||
entityId: EntityName;
|
||||
@@ -49,12 +50,14 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
const { '*': path } = useParams();
|
||||
const theme = useTheme<BackstageTheme>();
|
||||
|
||||
const { state, content: rawPage, errorMessage } = useReaderState(
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
path,
|
||||
);
|
||||
const {
|
||||
state,
|
||||
contentReload,
|
||||
content: rawPage,
|
||||
contentErrorMessage,
|
||||
syncErrorMessage,
|
||||
buildLog,
|
||||
} = useReaderState(kind, namespace, name, path);
|
||||
|
||||
const techdocsStorageApi = useApi(techdocsStorageApiRef);
|
||||
const [sidebars, setSidebars] = useState<HTMLElement[]>();
|
||||
@@ -324,34 +327,67 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{(state === 'CHECKING' || state === 'INITIAL_BUILD') && (
|
||||
<TechDocsProgressBar />
|
||||
{state === 'CHECKING' && <Progress />}
|
||||
{state === 'INITIAL_BUILD' && (
|
||||
<Alert
|
||||
variant="outlined"
|
||||
severity="info"
|
||||
icon={<CircularProgress size="24px" />}
|
||||
action={<TechDocsBuildLogs buildLog={buildLog} />}
|
||||
>
|
||||
Documentation is accessed for the first time and is being prepared.
|
||||
The subsequent loads are much faster.
|
||||
</Alert>
|
||||
)}
|
||||
{state === 'CONTENT_STALE_REFRESHING' && (
|
||||
<Alert variant="outlined" severity="info">
|
||||
<Alert
|
||||
variant="outlined"
|
||||
severity="info"
|
||||
icon={<CircularProgress size="24px" />}
|
||||
action={<TechDocsBuildLogs buildLog={buildLog} />}
|
||||
>
|
||||
A newer version of this documentation is being prepared and will be
|
||||
available shortly.
|
||||
</Alert>
|
||||
)}
|
||||
{state === 'CONTENT_STALE_READY' && (
|
||||
<Alert variant="outlined" severity="success">
|
||||
<Alert
|
||||
variant="outlined"
|
||||
severity="success"
|
||||
action={
|
||||
<Button color="inherit" onClick={() => contentReload()}>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
A newer version of this documentation is now available, please refresh
|
||||
to view.
|
||||
</Alert>
|
||||
)}
|
||||
{state === 'CONTENT_STALE_TIMEOUT' && (
|
||||
<Alert variant="outlined" severity="warning">
|
||||
Building a newer version of this documentation took longer than
|
||||
expected. Please refresh to try again.
|
||||
</Alert>
|
||||
)}
|
||||
{state === 'CONTENT_STALE_ERROR' && (
|
||||
<Alert variant="outlined" severity="error">
|
||||
Building a newer version of this documentation failed. {errorMessage}
|
||||
<Alert
|
||||
variant="outlined"
|
||||
severity="error"
|
||||
action={<TechDocsBuildLogs buildLog={buildLog} />}
|
||||
>
|
||||
Building a newer version of this documentation failed.{' '}
|
||||
{syncErrorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{state === 'CONTENT_NOT_FOUND' && (
|
||||
<TechDocsNotFound errorMessage={errorMessage} />
|
||||
<>
|
||||
{syncErrorMessage && (
|
||||
<Alert
|
||||
variant="outlined"
|
||||
severity="error"
|
||||
action={<TechDocsBuildLogs buildLog={buildLog} />}
|
||||
>
|
||||
Building a newer version of this documentation failed.{' '}
|
||||
{syncErrorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
<TechDocsNotFound errorMessage={contentErrorMessage} />
|
||||
</>
|
||||
)}
|
||||
<div data-testid="techdocs-content-shadowroot" ref={shadowDomRef} />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import {
|
||||
TechDocsBuildLogs,
|
||||
TechDocsBuildLogsDrawerContent,
|
||||
} from './TechDocsBuildLogs';
|
||||
|
||||
// react-lazylog is based on a react-virtualized component which doesn't
|
||||
// write the content to the dom, so we mock it.
|
||||
jest.mock('react-lazylog', () => {
|
||||
return {
|
||||
LazyLog: ({ text }: { text: string }) => {
|
||||
return <p>{text}</p>;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('<TechDocsBuildLogs />', () => {
|
||||
it('should render with button', () => {
|
||||
const rendered = render(<TechDocsBuildLogs buildLog={[]} />);
|
||||
expect(rendered.getByText(/Show Build Logs/i)).toBeInTheDocument();
|
||||
expect(rendered.queryByText(/Build Details/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open drawer', () => {
|
||||
const rendered = render(<TechDocsBuildLogs buildLog={[]} />);
|
||||
rendered.getByText(/Show Build Logs/i).click();
|
||||
expect(rendered.getByText(/Build Details/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('<TechDocsBuildLogsDrawerContent />', () => {
|
||||
it('should render with empty log', () => {
|
||||
const onClose = jest.fn();
|
||||
const rendered = render(
|
||||
<TechDocsBuildLogsDrawerContent buildLog={[]} onClose={onClose} />,
|
||||
);
|
||||
expect(rendered.getByText(/Build Details/i)).toBeInTheDocument();
|
||||
expect(rendered.getByText(/Waiting for logs.../i)).toBeInTheDocument();
|
||||
|
||||
expect(onClose).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should render with empty logs', () => {
|
||||
const onClose = jest.fn();
|
||||
const rendered = render(
|
||||
<TechDocsBuildLogsDrawerContent
|
||||
buildLog={['Line 1', 'Line 2']}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
expect(rendered.getByText(/Build Details/i)).toBeInTheDocument();
|
||||
expect(
|
||||
rendered.queryByText(/Waiting for logs.../i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(rendered.getByText(/Line 1/i)).toBeInTheDocument();
|
||||
expect(rendered.getByText(/Line 2/i)).toBeInTheDocument();
|
||||
|
||||
expect(onClose).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should call onClose', () => {
|
||||
const onClose = jest.fn();
|
||||
const rendered = render(
|
||||
<TechDocsBuildLogsDrawerContent buildLog={[]} onClose={onClose} />,
|
||||
);
|
||||
rendered.getByRole('button').click();
|
||||
|
||||
expect(onClose).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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 {
|
||||
Button,
|
||||
createStyles,
|
||||
Drawer,
|
||||
Grid,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import Close from '@material-ui/icons/Close';
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { LazyLog } from 'react-lazylog';
|
||||
|
||||
const useDrawerStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
paper: {
|
||||
width: '100%',
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
width: '75%',
|
||||
},
|
||||
[theme.breakpoints.up('md')]: {
|
||||
width: '50%',
|
||||
},
|
||||
padding: theme.spacing(2.5),
|
||||
},
|
||||
root: {
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const TechDocsBuildLogsDrawerContent = ({
|
||||
buildLog,
|
||||
onClose,
|
||||
}: {
|
||||
buildLog: string[];
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const classes = useDrawerStyles();
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
direction="column"
|
||||
className={classes.root}
|
||||
spacing={0}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
justify="space-between"
|
||||
alignItems="center"
|
||||
spacing={0}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Typography variant="h5">Build Details</Typography>
|
||||
<IconButton
|
||||
key="dismiss"
|
||||
title="Close the drawer"
|
||||
onClick={onClose}
|
||||
color="inherit"
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
|
||||
<LazyLog
|
||||
text={
|
||||
buildLog.length === 0 ? 'Waiting for logs...' : buildLog.join('\n')
|
||||
}
|
||||
extraLines={1}
|
||||
follow
|
||||
selectableLines
|
||||
enableSearch
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export const TechDocsBuildLogs = ({ buildLog }: { buildLog: string[] }) => {
|
||||
const classes = useDrawerStyles();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button color="inherit" onClick={() => setOpen(true)}>
|
||||
Show Build Logs
|
||||
</Button>
|
||||
<Drawer
|
||||
classes={{ paper: classes.paper }}
|
||||
anchor="right"
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<TechDocsBuildLogsDrawerContent
|
||||
buildLog={buildLog}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 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 TechDocsProgressBar from './TechDocsProgressBar';
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('<TechDocsProgressBar />', () => {
|
||||
it('should render a message if techdocs page takes more time to load', () => {
|
||||
const rendered = render(wrapInTestApp(<TechDocsProgressBar />));
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(250);
|
||||
});
|
||||
expect(rendered.getByTestId('progress')).toBeInTheDocument();
|
||||
expect(rendered.queryByTestId('delay-reason')).toBeNull();
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(rendered.getByTestId('delay-reason')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 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, { useState, useEffect } from 'react';
|
||||
import { useMountedState } from 'react-use';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import { Progress } from '@backstage/core-components';
|
||||
|
||||
const TechDocsProgressBar = () => {
|
||||
const isMounted = useMountedState();
|
||||
const [hasBeenDelayed, setHasBeenDelayed] = useState(false);
|
||||
|
||||
const delayReason = `Docs are still loading...Backstage takes some extra time to load docs
|
||||
for the first time. The subsequent loads are much faster.`;
|
||||
|
||||
// Allowed time that docs can take to load (in milliseconds)
|
||||
const allowedDelayTime = 5000;
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (isMounted()) {
|
||||
setHasBeenDelayed(true);
|
||||
}
|
||||
}, allowedDelayTime);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasBeenDelayed ? (
|
||||
<Typography data-testid="delay-reason">{delayReason}</Typography>
|
||||
) : null}
|
||||
<Progress />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TechDocsProgressBar;
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
@@ -23,7 +24,6 @@ import {
|
||||
reducer,
|
||||
useReaderState,
|
||||
} from './useReaderState';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
|
||||
describe('useReaderState', () => {
|
||||
let Wrapper: React.ComponentType;
|
||||
@@ -55,14 +55,12 @@ describe('useReaderState', () => {
|
||||
${false} | ${undefined} | ${'BUILDING'} | ${'INITIAL_BUILD'}
|
||||
${false} | ${undefined} | ${'BUILD_READY'} | ${'CONTENT_NOT_FOUND'}
|
||||
${false} | ${undefined} | ${'BUILD_READY_RELOAD'} | ${'CHECKING'}
|
||||
${false} | ${undefined} | ${'BUILD_TIMED_OUT'} | ${'CONTENT_NOT_FOUND'}
|
||||
${false} | ${undefined} | ${'UP_TO_DATE'} | ${'CONTENT_NOT_FOUND'}
|
||||
${false} | ${undefined} | ${'ERROR'} | ${'CONTENT_NOT_FOUND'}
|
||||
${false} | ${'asdf'} | ${'CHECKING'} | ${'CONTENT_FRESH'}
|
||||
${false} | ${'asdf'} | ${'BUILDING'} | ${'CONTENT_STALE_REFRESHING'}
|
||||
${false} | ${'asdf'} | ${'BUILD_READY'} | ${'CONTENT_STALE_READY'}
|
||||
${false} | ${'asdf'} | ${'BUILD_READY_RELOAD'} | ${'CHECKING'}
|
||||
${false} | ${'asdf'} | ${'BUILD_TIMED_OUT'} | ${'CONTENT_STALE_TIMEOUT'}
|
||||
${false} | ${'asdf'} | ${'UP_TO_DATE'} | ${'CONTENT_FRESH'}
|
||||
${false} | ${'asdf'} | ${'ERROR'} | ${'CONTENT_STALE_ERROR'}
|
||||
`(
|
||||
@@ -84,6 +82,7 @@ describe('useReaderState', () => {
|
||||
activeSyncState: 'CHECKING',
|
||||
contentLoading: false,
|
||||
path: '',
|
||||
buildLog: ['1', '2'],
|
||||
};
|
||||
|
||||
it('should return a copy of the state', () => {
|
||||
@@ -91,12 +90,14 @@ describe('useReaderState', () => {
|
||||
activeSyncState: 'CHECKING',
|
||||
contentLoading: false,
|
||||
path: '/',
|
||||
buildLog: ['1', '2'],
|
||||
});
|
||||
|
||||
expect(oldState).toEqual({
|
||||
activeSyncState: 'CHECKING',
|
||||
contentLoading: false,
|
||||
path: '',
|
||||
buildLog: ['1', '2'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,6 +211,33 @@ describe('useReaderState', () => {
|
||||
activeSyncState: 'BUILDING',
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear buildLog on "CHECKING"', () => {
|
||||
expect(
|
||||
reducer(oldState, {
|
||||
type: 'sync',
|
||||
state: 'CHECKING',
|
||||
}),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
activeSyncState: 'CHECKING',
|
||||
buildLog: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('"buildLog" action', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
reducer(oldState, {
|
||||
type: 'buildLog',
|
||||
log: 'Another Line',
|
||||
}),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
buildLog: ['1', '2', 'Another Line'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -229,7 +257,10 @@ describe('useReaderState', () => {
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
content: undefined,
|
||||
errorMessage: '',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
@@ -237,18 +268,24 @@ describe('useReaderState', () => {
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_FRESH',
|
||||
content: 'my content',
|
||||
errorMessage: '',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
|
||||
{ kind: 'Component', namespace: 'default', name: 'backstage' },
|
||||
'/example',
|
||||
);
|
||||
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'backstage',
|
||||
});
|
||||
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith(
|
||||
{
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'backstage',
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,10 +296,14 @@ describe('useReaderState', () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
return 'my content';
|
||||
});
|
||||
techdocsStorageApi.syncEntityDocs.mockImplementation(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
return 'updated';
|
||||
});
|
||||
techdocsStorageApi.syncEntityDocs.mockImplementation(
|
||||
async (_, logHandler) => {
|
||||
logHandler?.call(this, 'Line 1');
|
||||
logHandler?.call(this, 'Line 2');
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
return 'updated';
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const { result, waitForValueToChange } = await renderHook(
|
||||
@@ -273,7 +314,10 @@ describe('useReaderState', () => {
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
content: undefined,
|
||||
errorMessage: '',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
@@ -281,7 +325,10 @@ describe('useReaderState', () => {
|
||||
expect(result.current).toEqual({
|
||||
state: 'INITIAL_BUILD',
|
||||
content: undefined,
|
||||
errorMessage: ' Load error: NotFoundError: Page Not Found',
|
||||
contentErrorMessage: 'NotFoundError: Page Not Found',
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: ['Line 1', 'Line 2'],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
@@ -289,7 +336,10 @@ describe('useReaderState', () => {
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
content: undefined,
|
||||
errorMessage: '',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
@@ -297,7 +347,10 @@ describe('useReaderState', () => {
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_FRESH',
|
||||
content: 'my content',
|
||||
errorMessage: '',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
expect(techdocsStorageApi.getEntityDocs).toBeCalledTimes(2);
|
||||
@@ -306,20 +359,32 @@ describe('useReaderState', () => {
|
||||
'/example',
|
||||
);
|
||||
expect(techdocsStorageApi.syncEntityDocs).toBeCalledTimes(1);
|
||||
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'backstage',
|
||||
});
|
||||
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith(
|
||||
{
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'backstage',
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle stale content', async () => {
|
||||
techdocsStorageApi.getEntityDocs.mockResolvedValue('my content');
|
||||
techdocsStorageApi.syncEntityDocs.mockImplementation(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
return 'updated';
|
||||
});
|
||||
techdocsStorageApi.getEntityDocs
|
||||
.mockResolvedValueOnce('my content')
|
||||
.mockImplementationOnce(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
return 'my new content';
|
||||
});
|
||||
techdocsStorageApi.syncEntityDocs.mockImplementation(
|
||||
async (_, logHandler) => {
|
||||
logHandler?.call(this, 'Line 1');
|
||||
logHandler?.call(this, 'Line 2');
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
return 'updated';
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const { result, waitForValueToChange } = await renderHook(
|
||||
@@ -330,7 +395,10 @@ describe('useReaderState', () => {
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
content: undefined,
|
||||
errorMessage: '',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
// the content is returned but the sync is in progress
|
||||
@@ -338,7 +406,10 @@ describe('useReaderState', () => {
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_FRESH',
|
||||
content: 'my content',
|
||||
errorMessage: '',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: ['Line 1', 'Line 2'],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
// the sync takes longer than 1 seconds so the refreshing state starts
|
||||
@@ -346,62 +417,61 @@ describe('useReaderState', () => {
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_STALE_REFRESHING',
|
||||
content: 'my content',
|
||||
errorMessage: '',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: ['Line 1', 'Line 2'],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
// the content is up-to-date
|
||||
// the content is updated but not yet displayed
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_STALE_READY',
|
||||
content: 'my content',
|
||||
errorMessage: '',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: ['Line 1', 'Line 2'],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
|
||||
{ kind: 'Component', namespace: 'default', name: 'backstage' },
|
||||
'/example',
|
||||
);
|
||||
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'backstage',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle timed-out refresh', async () => {
|
||||
techdocsStorageApi.getEntityDocs.mockResolvedValue('my content');
|
||||
techdocsStorageApi.syncEntityDocs.mockResolvedValue('timeout');
|
||||
|
||||
await act(async () => {
|
||||
const { result, waitForValueToChange } = await renderHook(
|
||||
() => useReaderState('Component', 'default', 'backstage', '/example'),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
// reload the content
|
||||
result.current.contentReload();
|
||||
|
||||
// the new content refresh is triggered
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
content: undefined,
|
||||
errorMessage: '',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
// the content is returned but the sync is in progress
|
||||
// the new content is loaded
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_STALE_TIMEOUT',
|
||||
content: 'my content',
|
||||
errorMessage: '',
|
||||
state: 'CONTENT_FRESH',
|
||||
content: 'my new content',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
expect(techdocsStorageApi.getEntityDocs).toBeCalledTimes(2);
|
||||
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
|
||||
{ kind: 'Component', namespace: 'default', name: 'backstage' },
|
||||
'/example',
|
||||
);
|
||||
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'backstage',
|
||||
});
|
||||
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith(
|
||||
{
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'backstage',
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -420,7 +490,10 @@ describe('useReaderState', () => {
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
content: undefined,
|
||||
errorMessage: '',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
// the content loading threw an error
|
||||
@@ -428,18 +501,24 @@ describe('useReaderState', () => {
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_NOT_FOUND',
|
||||
content: undefined,
|
||||
errorMessage: ' Load error: NotFoundError: Some error description',
|
||||
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.syncEntityDocs).toBeCalledWith({
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'backstage',
|
||||
});
|
||||
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith(
|
||||
{
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'backstage',
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { useAsync, useAsyncRetry } from 'react-use';
|
||||
import { techdocsStorageApiRef } from '../../api';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
/**
|
||||
* A state representation that is used to configure the UI of <Reader />
|
||||
@@ -35,9 +35,6 @@ type ContentStateTypes =
|
||||
/** There is content, but after a reload, the content will be different */
|
||||
| 'CONTENT_STALE_READY'
|
||||
|
||||
/** There is content, the backend tried to update it, but it took too long */
|
||||
| 'CONTENT_STALE_TIMEOUT'
|
||||
|
||||
/** There is content, the backend tried to update it, but failed */
|
||||
| 'CONTENT_STALE_ERROR'
|
||||
|
||||
@@ -93,11 +90,6 @@ export function calculateDisplayState({
|
||||
return 'CONTENT_STALE_READY';
|
||||
}
|
||||
|
||||
// the build timed out, but the content is still stale
|
||||
if (activeSyncState === 'BUILD_TIMED_OUT') {
|
||||
return 'CONTENT_STALE_TIMEOUT';
|
||||
}
|
||||
|
||||
// the build failed, but the content is still stale
|
||||
if (activeSyncState === 'ERROR') {
|
||||
return 'CONTENT_STALE_ERROR';
|
||||
@@ -127,9 +119,6 @@ type SyncStates =
|
||||
*/
|
||||
| 'BUILD_READY_RELOAD'
|
||||
|
||||
/** Building the documentation timed out */
|
||||
| 'BUILD_TIMED_OUT'
|
||||
|
||||
/** No need for a sync. The content was already up-to-date. */
|
||||
| 'UP_TO_DATE'
|
||||
|
||||
@@ -148,7 +137,8 @@ type ReducerActions =
|
||||
contentLoading?: true;
|
||||
contentError?: Error;
|
||||
}
|
||||
| { type: 'navigate'; path: string };
|
||||
| { type: 'navigate'; path: string }
|
||||
| { type: 'buildLog'; log: string };
|
||||
|
||||
type ReducerState = {
|
||||
/**
|
||||
@@ -172,6 +162,11 @@ type ReducerState = {
|
||||
|
||||
contentError?: Error;
|
||||
syncError?: Error;
|
||||
|
||||
/**
|
||||
* A list of log messages that were emitted by the build process.
|
||||
*/
|
||||
buildLog: string[];
|
||||
};
|
||||
|
||||
export function reducer(
|
||||
@@ -182,6 +177,11 @@ export function reducer(
|
||||
|
||||
switch (action.type) {
|
||||
case 'sync':
|
||||
// reset the build log when a new check starts
|
||||
if (action.state === 'CHECKING') {
|
||||
newState.buildLog = [];
|
||||
}
|
||||
|
||||
newState.activeSyncState = action.state;
|
||||
newState.syncError = action.syncError;
|
||||
break;
|
||||
@@ -196,6 +196,10 @@ export function reducer(
|
||||
newState.path = action.path;
|
||||
break;
|
||||
|
||||
case 'buildLog':
|
||||
newState.buildLog = newState.buildLog.concat(action.log);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
@@ -206,6 +210,7 @@ export function reducer(
|
||||
['content', 'navigate'].includes(action.type)
|
||||
) {
|
||||
newState.activeSyncState = 'UP_TO_DATE';
|
||||
newState.buildLog = [];
|
||||
}
|
||||
|
||||
return newState;
|
||||
@@ -216,11 +221,19 @@ export function useReaderState(
|
||||
namespace: string,
|
||||
name: string,
|
||||
path: string,
|
||||
): { state: ContentStateTypes; content?: string; errorMessage?: string } {
|
||||
): {
|
||||
state: ContentStateTypes;
|
||||
contentReload: () => void;
|
||||
content?: string;
|
||||
contentErrorMessage?: string;
|
||||
syncErrorMessage?: string;
|
||||
buildLog: string[];
|
||||
} {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
activeSyncState: 'CHECKING',
|
||||
path,
|
||||
contentLoading: true,
|
||||
buildLog: [],
|
||||
});
|
||||
|
||||
const techdocsStorageApi = useApi(techdocsStorageApiRef);
|
||||
@@ -268,24 +281,38 @@ export function useReaderState(
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
const result = await techdocsStorageApi.syncEntityDocs({
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
});
|
||||
const result = await techdocsStorageApi.syncEntityDocs(
|
||||
{
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
},
|
||||
log => {
|
||||
dispatch({ type: 'buildLog', log });
|
||||
},
|
||||
);
|
||||
|
||||
if (result === 'updated') {
|
||||
// if there was no content prior to building, retry the loading
|
||||
if (!contentRef.current.content) {
|
||||
contentRef.current.reload();
|
||||
dispatch({ type: 'sync', state: 'BUILD_READY_RELOAD' });
|
||||
} else {
|
||||
dispatch({ type: 'sync', state: 'BUILD_READY' });
|
||||
}
|
||||
} else if (result === 'cached') {
|
||||
dispatch({ type: 'sync', state: 'UP_TO_DATE' });
|
||||
} else {
|
||||
dispatch({ type: 'sync', state: 'BUILD_TIMED_OUT' });
|
||||
switch (result) {
|
||||
case 'updated':
|
||||
// if there was no content prior to building, retry the loading
|
||||
if (!contentRef.current.content) {
|
||||
contentRef.current.reload();
|
||||
dispatch({ type: 'sync', state: 'BUILD_READY_RELOAD' });
|
||||
} else {
|
||||
dispatch({ type: 'sync', state: 'BUILD_READY' });
|
||||
}
|
||||
break;
|
||||
case 'cached':
|
||||
dispatch({ type: 'sync', state: 'UP_TO_DATE' });
|
||||
break;
|
||||
|
||||
default:
|
||||
dispatch({
|
||||
type: 'sync',
|
||||
state: 'ERROR',
|
||||
syncError: new Error('Unexpected return state'),
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
dispatch({ type: 'sync', state: 'ERROR', syncError: e });
|
||||
@@ -305,19 +332,12 @@ export function useReaderState(
|
||||
[state.activeSyncState, state.content, state.contentLoading],
|
||||
);
|
||||
|
||||
const errorMessage = useMemo(() => {
|
||||
let errMessage = '';
|
||||
if (state.contentError) {
|
||||
errMessage += ` Load error: ${state.contentError}`;
|
||||
}
|
||||
if (state.syncError) errMessage += ` Build error: ${state.syncError}`;
|
||||
|
||||
return errMessage;
|
||||
}, [state.syncError, state.contentError]);
|
||||
|
||||
return {
|
||||
state: displayState,
|
||||
contentReload,
|
||||
content: state.content,
|
||||
errorMessage,
|
||||
contentErrorMessage: state.contentError?.toString(),
|
||||
syncErrorMessage: state.syncError?.toString(),
|
||||
buildLog: state.buildLog,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user