diff --git a/.changeset/techdocs-cool-rivers-suffer.md b/.changeset/techdocs-cool-rivers-suffer.md
new file mode 100644
index 0000000000..2386d27525
--- /dev/null
+++ b/.changeset/techdocs-cool-rivers-suffer.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs': patch
+---
+
+Refactor the implicit logic from `` into an explicit state machine. This resolves some state synchronization issues when content is refreshed or rebuilt in the backend.
diff --git a/.changeset/techdocs-poor-forks-repeat.md b/.changeset/techdocs-poor-forks-repeat.md
new file mode 100644
index 0000000000..89c4e0dc5a
--- /dev/null
+++ b/.changeset/techdocs-poor-forks-repeat.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs-backend': patch
+---
+
+Return a `304 Not Modified` from the `/sync/:namespace/:kind/:name` endpoint if nothing was built. This enables the caller to know whether a refresh of the docs page will return updated content (-> `201 Created`) or not (-> `304 Not Modified`).
diff --git a/plugins/techdocs-backend/src/DocsBuilder/builder.ts b/plugins/techdocs-backend/src/DocsBuilder/builder.ts
index dce391ff3f..24549f8b6f 100644
--- a/plugins/techdocs-backend/src/DocsBuilder/builder.ts
+++ b/plugins/techdocs-backend/src/DocsBuilder/builder.ts
@@ -68,7 +68,11 @@ export class DocsBuilder {
this.config = config;
}
- public async build(): Promise {
+ /**
+ * Build the docs and return whether they have been newly generated or have been cached
+ * @returns true, if the docs have been built. false, if the cached docs are still up-to-date.
+ */
+ public async build(): Promise {
if (!this.entity.metadata.uid) {
throw new Error(
'Trying to build documentation for entity not in service catalog',
@@ -125,7 +129,7 @@ export class DocsBuilder {
this.entity,
)} are unmodified. Using cache, skipping generate and prepare`,
);
- return;
+ return false;
}
throw new Error(err.message);
}
@@ -205,5 +209,7 @@ export class DocsBuilder {
// Update the last check time for the entity
new BuildMetadataStorage(this.entity.metadata.uid).setLastUpdated();
+
+ return true;
}
}
diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts
index 1cd4075da0..ff47e16bc9 100644
--- a/plugins/techdocs-backend/src/service/router.ts
+++ b/plugins/techdocs-backend/src/service/router.ts
@@ -16,7 +16,7 @@
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
-import { NotFoundError } from '@backstage/errors';
+import { NotFoundError, NotModifiedError } from '@backstage/errors';
import {
GeneratorBuilder,
getLocationForEntity,
@@ -172,10 +172,15 @@ export async function createRouter({
case 'awsS3':
case 'azureBlobStorage':
case 'openStackSwift':
- case 'googleGcs':
+ case 'googleGcs': {
// This block should be valid for all storage implementations. So no need to duplicate in future,
// add the publisher type in the list here.
- await docsBuilder.build();
+ const updated = await docsBuilder.build();
+
+ if (!updated) {
+ throw new NotModifiedError();
+ }
+
// With a maximum of ~5 seconds wait, check if the files got published and if docs will be fetched
// on the user's page. If not, respond with a message asking them to check back later.
// The delay here is to make sure GCS/AWS/etc. registers newly uploaded files which is usually <1 second
@@ -194,10 +199,13 @@ export async function createRouter({
'Sorry! It took too long for the generated docs to show up in storage. Check back later.',
);
}
+
res
.status(201)
.json({ message: 'Docs updated or did not need updating' });
break;
+ }
+
default:
throw new NotFoundError(
`Publisher type ${publisherType} is not supported by techdocs-backend docs builder.`,
diff --git a/plugins/techdocs/dev/api.ts b/plugins/techdocs/dev/api.ts
deleted file mode 100644
index e764bf82bb..0000000000
--- a/plugins/techdocs/dev/api.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * 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 { EntityName } from '@backstage/catalog-model';
-import { Config } from '@backstage/config';
-import { DiscoveryApi, IdentityApi } from '@backstage/core';
-import { NotFoundError } from '@backstage/errors';
-import { TechDocsStorageApi } from '../src/api';
-
-export class TechDocsDevStorageApi implements TechDocsStorageApi {
- public configApi: Config;
- public discoveryApi: DiscoveryApi;
- public identityApi: IdentityApi;
-
- constructor({
- configApi,
- discoveryApi,
- identityApi,
- }: {
- configApi: Config;
- discoveryApi: DiscoveryApi;
- identityApi: IdentityApi;
- }) {
- this.configApi = configApi;
- this.discoveryApi = discoveryApi;
- this.identityApi = identityApi;
- }
-
- async getApiOrigin() {
- return (
- this.configApi.getOptionalString('techdocs.requestUrl') ??
- (await this.discoveryApi.getBaseUrl('techdocs'))
- );
- }
-
- async getStorageUrl() {
- return (
- this.configApi.getOptionalString('techdocs.storageUrl') ??
- `${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs`
- );
- }
-
- async getBuilder() {
- return this.configApi.getString('techdocs.builder');
- }
-
- async fetchUrl(url: string) {
- const token = await this.identityApi.getIdToken();
- return fetch(url, {
- headers: token ? { Authorization: `Bearer ${token}` } : {},
- });
- }
-
- async getEntityDocs(entityId: EntityName, path: string) {
- const { kind, namespace, name } = entityId;
-
- const storageUrl = await this.getStorageUrl();
- const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`;
- const token = await this.identityApi.getIdToken();
-
- const request = await fetch(
- `${url.endsWith('/') ? url : `${url}/`}index.html`,
- {
- headers: token ? { Authorization: `Bearer ${token}` } : {},
- },
- );
-
- let errorMessage = '';
- switch (request.status) {
- case 404:
- errorMessage = 'Page not found. ';
- // path is empty for the home page of an entity's docs site
- if (!path) {
- errorMessage +=
- 'This could be because there is no index.md file in the root of the docs directory of this repository.';
- }
- throw new NotFoundError(errorMessage);
- case 500:
- errorMessage =
- 'Could not generate documentation or an error in the TechDocs backend. ';
- throw new Error(errorMessage);
- default:
- // Do nothing
- break;
- }
-
- return request.text();
- }
-
- /**
- * Check if docs are the latest version and trigger rebuilds if not
- *
- * @param {EntityName} entityId Object containing entity data like name, namespace, etc.
- * @returns {boolean} Whether documents are currently synchronized to newest version
- * @throws {Error} Throws error on error from sync endpoint
- */
- async syncEntityDocs(entityId: EntityName) {
- const { kind, namespace, name } = entityId;
-
- const apiOrigin = await this.getApiOrigin();
- const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`;
- 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 this.fetchUrl(
- `${url.endsWith('/') ? url : `${url}/`}index.html`,
- );
- }
-
- switch (request.status) {
- case 404:
- throw (await request.json()).error;
- case 200:
- case 201:
- return true;
- // 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 false;
- }
- }
-
- async getBaseUrl(
- oldBaseUrl: string,
- entityId: EntityName,
- path: string,
- ): Promise {
- const { name } = entityId;
- const apiOrigin = await this.getApiOrigin();
- return new URL(oldBaseUrl, `${apiOrigin}/${name}/${path}`).toString();
- }
-}
diff --git a/plugins/techdocs/dev/index.tsx b/plugins/techdocs/dev/index.tsx
index 3eefa0a814..a778161b35 100644
--- a/plugins/techdocs/dev/index.tsx
+++ b/plugins/techdocs/dev/index.tsx
@@ -14,11 +14,105 @@
* limitations under the License.
*/
-import { configApiRef, discoveryApiRef, identityApiRef } from '@backstage/core';
+import {
+ configApiRef,
+ discoveryApiRef,
+ Header,
+ identityApiRef,
+ Page,
+ TabbedLayout,
+} from '@backstage/core';
import { createDevApp } from '@backstage/dev-utils';
-import { techdocsPlugin } from '../src/plugin';
-import { TechDocsDevStorageApi } from './api';
-import { techdocsStorageApiRef } from '../src';
+import { NotFoundError } from '@backstage/errors';
+import React from 'react';
+import {
+ Reader,
+ SyncResult,
+ TechDocsStorageApi,
+ techdocsStorageApiRef,
+} from '../src';
+
+// used so each route can provide it's own implementation in the constructor of the react component
+let apiHolder: TechDocsStorageApi | undefined = undefined;
+
+const apiBridge: TechDocsStorageApi = {
+ getApiOrigin: async () => '',
+ getBaseUrl: (...args) => apiHolder!.getBaseUrl(...args),
+ getBuilder: () => apiHolder!.getBuilder(),
+ getStorageUrl: () => apiHolder!.getStorageUrl(),
+ getEntityDocs: (...args) => apiHolder!.getEntityDocs(...args),
+ syncEntityDocs: (...args) => apiHolder!.syncEntityDocs(...args),
+};
+
+const mockContent = `
+
Hello World!
+
This is an example content that will actually be provided by a MkDocs powered site
+`;
+
+function createPage({
+ entityDocs,
+ syncDocs,
+ syncDocsDelay,
+}: {
+ entityDocs?: (props: {
+ called: number;
+ content: string;
+ }) => string | Promise;
+ syncDocs: () => SyncResult;
+ syncDocsDelay?: number;
+}) {
+ class Api implements TechDocsStorageApi {
+ private entityDocsCallCount: number = 0;
+
+ getApiOrigin = async () => '';
+ getBaseUrl = async () => '';
+ getBuilder = async () => 'local';
+ getStorageUrl = async () => '';
+
+ async getEntityDocs() {
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ if (!entityDocs) {
+ return mockContent;
+ }
+
+ return entityDocs({
+ called: this.entityDocsCallCount++,
+ content: mockContent,
+ });
+ }
+
+ async syncEntityDocs() {
+ if (syncDocsDelay) {
+ await new Promise(resolve => setTimeout(resolve, syncDocsDelay));
+ }
+
+ return syncDocs();
+ }
+ }
+
+ class Component extends React.Component {
+ constructor(props: {}) {
+ super(props);
+
+ apiHolder = new Api();
+ }
+
+ render() {
+ return (
+
+ );
+ }
+ }
+
+ return ;
+}
createDevApp()
.registerApi({
@@ -28,12 +122,89 @@ createDevApp()
discoveryApi: discoveryApiRef,
identityApi: identityApiRef,
},
- factory: ({ configApi, discoveryApi, identityApi }) =>
- new TechDocsDevStorageApi({
- configApi,
- discoveryApi,
- identityApi,
- }),
+ factory: () => apiBridge,
+ })
+
+ .addPage({
+ title: 'TechDocs',
+ element: (
+
+
+
+
+ {createPage({
+ syncDocs: () => 'cached',
+ })}
+
+
+
+ {createPage({
+ syncDocs: () => 'updated',
+ syncDocsDelay: 2000,
+ })}
+
+
+
+ {createPage({
+ entityDocs: ({ called, content }) => {
+ if (called < 1) {
+ throw new NotFoundError();
+ }
+
+ return content;
+ },
+ syncDocs: () => 'updated',
+ syncDocsDelay: 10000,
+ })}
+
+
+
+ {createPage({
+ entityDocs: () => {
+ throw new NotFoundError('Not found, some error message...');
+ },
+ syncDocs: () => 'cached',
+ })}
+
+
+
+ {createPage({
+ entityDocs: () => {
+ throw new Error('Another more critical error');
+ },
+ syncDocs: () => 'cached',
+ })}
+
+
+
+ {createPage({
+ syncDocs: () => {
+ throw new Error('Some random error');
+ },
+ syncDocsDelay: 2000,
+ })}
+
+
+
+ {createPage({
+ entityDocs: () => {
+ throw new Error('Some random error');
+ },
+ syncDocs: () => {
+ throw new Error('Some random error');
+ },
+ syncDocsDelay: 2000,
+ })}
+
+
+
+ {createPage({
+ syncDocs: () => 'timeout',
+ syncDocsDelay: 2000,
+ })}
+
+
+
+ ),
})
- .registerPlugin(techdocsPlugin)
.render();
diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json
index 01b412b501..bc336ec976 100644
--- a/plugins/techdocs/package.json
+++ b/plugins/techdocs/package.json
@@ -56,6 +56,7 @@
"@backstage/test-utils": "^0.1.13",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
+ "@testing-library/react-hooks": "^3.4.2",
"@testing-library/user-event": "^13.1.8",
"@types/react": "^16.9",
"@types/jest": "^26.0.7",
diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts
index 204cc1b5a8..b9c8725ce9 100644
--- a/plugins/techdocs/src/api.ts
+++ b/plugins/techdocs/src/api.ts
@@ -28,12 +28,14 @@ export const techdocsApiRef = createApiRef({
description: 'Used to make requests towards techdocs API',
});
+export type SyncResult = 'cached' | 'updated' | 'timeout';
+
export interface TechDocsStorageApi {
getApiOrigin(): Promise;
getStorageUrl(): Promise;
getBuilder(): Promise;
getEntityDocs(entityId: EntityName, path: string): Promise;
- syncEntityDocs(entityId: EntityName): Promise;
+ syncEntityDocs(entityId: EntityName): Promise;
getBaseUrl(
oldBaseUrl: string,
entityId: EntityName,
diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts
index 245cfb0154..83cfc88d56 100644
--- a/plugins/techdocs/src/client.ts
+++ b/plugins/techdocs/src/client.ts
@@ -18,7 +18,7 @@ import { EntityName } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { DiscoveryApi, IdentityApi } from '@backstage/core';
import { NotFoundError } from '@backstage/errors';
-import { TechDocsApi, TechDocsStorageApi } from './api';
+import { SyncResult, TechDocsApi, TechDocsStorageApi } from './api';
import { TechDocsEntityMetadata, TechDocsMetadata } from './types';
/**
@@ -192,10 +192,10 @@ 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.
- * @returns {boolean} Whether documents are currently synchronized to newest version
+ * @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 {
+ async syncEntityDocs(entityId: EntityName): Promise {
const { kind, namespace, name } = entityId;
const apiOrigin = await this.getApiOrigin();
@@ -215,16 +215,20 @@ export class TechDocsStorageClient implements TechDocsStorageApi {
switch (request.status) {
case 404:
throw new NotFoundError((await request.json()).error);
+
case 200:
- case 201:
case 304:
- return true;
+ return 'cached';
+
+ case 201:
+ return 'updated';
+
// 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 false;
+ return 'timeout';
}
}
diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx
index c779985364..75dade20fa 100644
--- a/plugins/techdocs/src/reader/components/Reader.tsx
+++ b/plugins/techdocs/src/reader/components/Reader.tsx
@@ -13,15 +13,15 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
import { EntityName } from '@backstage/catalog-model';
import { useApi } from '@backstage/core';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import { BackstageTheme } from '@backstage/theme';
import { useTheme } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
-import { useAsync } from 'react-use';
import { techdocsStorageApiRef } from '../../api';
import {
addBaseUrl,
@@ -37,7 +37,7 @@ import {
} from '../transformers';
import { TechDocsNotFound } from './TechDocsNotFound';
import TechDocsProgressBar from './TechDocsProgressBar';
-import { useRawPage } from './useRawPage';
+import { useReaderState } from './useReaderState';
type Props = {
entityId: EntityName;
@@ -49,61 +49,37 @@ export const Reader = ({ entityId, onReady }: Props) => {
const { '*': path } = useParams();
const theme = useTheme();
+ const { state, content: rawPage, errorMessage } = useReaderState(
+ kind,
+ namespace,
+ name,
+ path,
+ );
+
const techdocsStorageApi = useApi(techdocsStorageApiRef);
const [sidebars, setSidebars] = useState();
const navigate = useNavigate();
const shadowDomRef = useRef(null);
- const [loadedPath, setLoadedPath] = useState('');
- const [atInitialLoad, setAtInitialLoad] = useState(true);
- const [newerDocsExist, setNewerDocsExist] = useState(false);
const scmIntegrationsApi = useApi(scmIntegrationsApiRef);
- const {
- value: isSynced,
- loading: syncInProgress,
- error: syncError,
- } = useAsync(async () => {
- // Attempt to sync only if `techdocs.builder` in app config is set to 'local'
- if ((await techdocsStorageApi.getBuilder()) !== 'local') {
- return Promise.resolve({
- value: true,
- loading: null,
- error: null,
+ const updateSidebarPosition = useCallback(() => {
+ if (!!shadowDomRef.current && !!sidebars) {
+ const mdTabs = shadowDomRef.current!.querySelector(
+ '.md-container > .md-tabs',
+ );
+ sidebars!.forEach(sidebar => {
+ const newTop = Math.max(
+ shadowDomRef.current!.getBoundingClientRect().top,
+ 0,
+ );
+ sidebar.style.top = mdTabs
+ ? `${newTop + mdTabs.getBoundingClientRect().height}px`
+ : `${newTop}px`;
});
}
- return techdocsStorageApi.syncEntityDocs({ kind, namespace, name });
- }, [techdocsStorageApi, kind, namespace, name]);
-
- const {
- value: rawPage,
- loading: docLoading,
- error: docLoadError,
- retry,
- } = useRawPage(path, kind, namespace, name);
+ }, [shadowDomRef, sidebars]);
useEffect(() => {
- if (isSynced && newerDocsExist && path !== loadedPath) {
- retry();
- }
- });
-
- useEffect(() => {
- const updateSidebarPosition = () => {
- if (!!shadowDomRef.current && !!sidebars) {
- const mdTabs = shadowDomRef.current!.querySelector(
- '.md-container > .md-tabs',
- );
- sidebars!.forEach(sidebar => {
- const newTop = Math.max(
- shadowDomRef.current!.getBoundingClientRect().top,
- 0,
- );
- sidebar.style.top = mdTabs
- ? `${newTop + mdTabs.getBoundingClientRect().height}px`
- : `${newTop}px`;
- });
- }
- };
updateSidebarPosition();
window.addEventListener('scroll', updateSidebarPosition);
window.addEventListener('resize', updateSidebarPosition);
@@ -111,28 +87,8 @@ export const Reader = ({ entityId, onReady }: Props) => {
window.removeEventListener('scroll', updateSidebarPosition);
window.removeEventListener('resize', updateSidebarPosition);
};
- }, [shadowDomRef, sidebars]);
-
- useEffect(() => {
- if (rawPage) {
- setLoadedPath(path);
- }
- }, [rawPage, path]);
-
- useEffect(() => {
- if (atInitialLoad === false) {
- return;
- }
- setTimeout(() => {
- setAtInitialLoad(false);
- }, 5000);
- });
-
- useEffect(() => {
- if (!atInitialLoad && !!rawPage && syncInProgress) {
- setNewerDocsExist(true);
- }
- }, [atInitialLoad, rawPage, syncInProgress]);
+ // an update to "state" might lead to an updated UI so we include it as a trigger
+ }, [updateSidebarPosition, state]);
useEffect(() => {
if (!rawPage || !shadowDomRef.current) {
@@ -142,12 +98,16 @@ export const Reader = ({ entityId, onReady }: Props) => {
onReady();
}
// Pre-render
- const transformedElement = transformer(rawPage.content, [
+ const transformedElement = transformer(rawPage, [
sanitizeDOM(),
addBaseUrl({
techdocsStorageApi,
- entityId: rawPage.entityId,
- path: rawPage.path,
+ entityId: {
+ kind,
+ name,
+ namespace,
+ },
+ path,
}),
rewriteDocLinks(),
removeMkdocsHeader(),
@@ -292,10 +252,6 @@ export const Reader = ({ entityId, onReady }: Props) => {
baseUrl: window.location.origin,
onClick: (_: MouseEvent, url: string) => {
const parsedUrl = new URL(url);
- if (newerDocsExist && isSynced) {
- // link navigation will load newer docs
- setNewerDocsExist(false);
- }
if (parsedUrl.hash) {
navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
@@ -337,6 +293,10 @@ export const Reader = ({ entityId, onReady }: Props) => {
}),
]);
}, [
+ path,
+ kind,
+ namespace,
+ name,
rawPage,
navigate,
onReady,
@@ -347,39 +307,40 @@ export const Reader = ({ entityId, onReady }: Props) => {
theme.palette.primary.main,
theme.palette.background.paper,
theme.palette.background.default,
- newerDocsExist,
- isSynced,
scmIntegrationsApi,
]);
- // docLoadError not considered an error state if sync request is still ongoing
- // or sync just completed and doc is loading again
- if ((docLoadError && !syncInProgress && !docLoading) || syncError) {
- let errMessage = '';
- if (docLoadError) {
- errMessage += ` Load error: ${docLoadError}`;
- }
- if (syncError) errMessage += ` Build error: ${syncError}`;
- return ;
- }
-
return (
<>
- {newerDocsExist && !isSynced ? (
+ {(state === 'CHECKING' || state === 'INITIAL_BUILD') && (
+
+ )}
+ {state === 'CONTENT_STALE_REFRESHING' && (
A newer version of this documentation is being prepared and will be
available shortly.
- ) : null}
- {newerDocsExist && isSynced ? (
+ )}
+ {state === 'CONTENT_STALE_READY' && (
A newer version of this documentation is now available, please refresh
to view.
- ) : null}
- {docLoading || (docLoadError && syncInProgress) ? (
-
- ) : null}
+ )}
+ {state === 'CONTENT_STALE_TIMEOUT' && (
+
+ Building a newer version of this documentation took longer than
+ expected. Please refresh to try again.
+
+ )}
+ {state === 'CONTENT_STALE_ERROR' && (
+
+ Building a newer version of this documentation failed. {errorMessage}
+
+ )}
+ {state === 'CONTENT_NOT_FOUND' && (
+
+ )}
>
);
diff --git a/plugins/techdocs/src/reader/components/useReaderState.test.tsx b/plugins/techdocs/src/reader/components/useReaderState.test.tsx
new file mode 100644
index 0000000000..8a09241588
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/useReaderState.test.tsx
@@ -0,0 +1,446 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * 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 { ApiProvider, ApiRegistry } from '@backstage/core';
+import { NotFoundError } from '@backstage/errors';
+import { act, renderHook } from '@testing-library/react-hooks';
+import React from 'react';
+import { techdocsStorageApiRef } from '../../api';
+import {
+ calculateDisplayState,
+ reducer,
+ useReaderState,
+} from './useReaderState';
+
+describe('useReaderState', () => {
+ let Wrapper: React.ComponentType;
+
+ const techdocsStorageApi: jest.Mocked = {
+ getApiOrigin: jest.fn(),
+ getBaseUrl: jest.fn(),
+ getBuilder: jest.fn(),
+ getEntityDocs: jest.fn(),
+ getStorageUrl: jest.fn(),
+ syncEntityDocs: jest.fn(),
+ };
+
+ beforeEach(() => {
+ const apis = ApiRegistry.with(techdocsStorageApiRef, techdocsStorageApi);
+
+ Wrapper = ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ );
+ });
+
+ afterEach(() => jest.resetAllMocks());
+
+ describe('calculateDisplayState', () => {
+ it.each`
+ contentLoading | content | activeSyncState | expected
+ ${true} | ${''} | ${''} | ${'CHECKING'}
+ ${false} | ${undefined} | ${'CHECKING'} | ${'CHECKING'}
+ ${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'}
+ `(
+ 'should, when contentLoading=$contentLoading and content="$content" and activeSyncState=$activeSyncState, resolve to $expected',
+ ({ contentLoading, content, activeSyncState, expected }) => {
+ expect(
+ calculateDisplayState({
+ contentLoading,
+ content,
+ activeSyncState,
+ }),
+ ).toEqual(expected);
+ },
+ );
+ });
+
+ describe('reducer', () => {
+ const oldState: Parameters[0] = {
+ activeSyncState: 'CHECKING',
+ contentLoading: false,
+ path: '',
+ };
+
+ it('should return a copy of the state', () => {
+ expect(reducer(oldState, { type: 'navigate', path: '/' })).toEqual({
+ activeSyncState: 'CHECKING',
+ contentLoading: false,
+ path: '/',
+ });
+
+ expect(oldState).toEqual({
+ activeSyncState: 'CHECKING',
+ contentLoading: false,
+ path: '',
+ });
+ });
+
+ 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}
+ `(
+ 'should, when type=$type and activeSyncState=$oldActiveSyncState, set activeSyncState=$newActiveSyncState',
+ ({ type, oldActiveSyncState, newActiveSyncState }) => {
+ expect(
+ reducer(
+ {
+ ...oldState,
+ activeSyncState: oldActiveSyncState,
+ },
+ { type },
+ ).activeSyncState,
+ ).toEqual(newActiveSyncState);
+ },
+ );
+
+ describe('"content" action', () => {
+ it('should set loading', () => {
+ expect(
+ reducer(
+ {
+ ...oldState,
+ content: 'some-old-content',
+ contentError: new Error(),
+ },
+ {
+ type: 'content',
+ contentLoading: true,
+ },
+ ),
+ ).toEqual({
+ ...oldState,
+ contentLoading: true,
+ });
+ });
+
+ it('should set content', () => {
+ expect(
+ reducer(
+ {
+ ...oldState,
+ contentLoading: true,
+ contentError: new Error(),
+ },
+ {
+ type: 'content',
+ content: 'asdf',
+ },
+ ),
+ ).toEqual({
+ ...oldState,
+ contentLoading: false,
+ content: 'asdf',
+ });
+ });
+
+ it('should set error', () => {
+ expect(
+ reducer(
+ {
+ ...oldState,
+ contentLoading: true,
+ content: 'asdf',
+ },
+ {
+ type: 'content',
+ contentError: new Error(),
+ },
+ ),
+ ).toEqual({
+ ...oldState,
+ contentLoading: false,
+ contentError: new Error(),
+ });
+ });
+ });
+
+ describe('"navigate" action', () => {
+ it('should work', () => {
+ expect(
+ reducer(oldState, {
+ type: 'navigate',
+ path: '/',
+ }),
+ ).toEqual({
+ ...oldState,
+ path: '/',
+ });
+ });
+ });
+
+ describe('"sync" action', () => {
+ it('should update state', () => {
+ expect(
+ reducer(oldState, {
+ type: 'sync',
+ state: 'BUILDING',
+ }),
+ ).toEqual({
+ ...oldState,
+ activeSyncState: 'BUILDING',
+ });
+ });
+ });
+ });
+
+ describe('hook', () => {
+ it('should handle up-to-date content', async () => {
+ techdocsStorageApi.getEntityDocs.mockResolvedValue('my content');
+ techdocsStorageApi.syncEntityDocs.mockImplementation(async () => {
+ return 'cached';
+ });
+
+ await act(async () => {
+ const { result, waitForValueToChange } = await renderHook(
+ () => useReaderState('Component', 'default', 'backstage', '/example'),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current).toEqual({
+ state: 'CHECKING',
+ content: undefined,
+ errorMessage: '',
+ });
+
+ await waitForValueToChange(() => result.current.state);
+
+ expect(result.current).toEqual({
+ state: 'CONTENT_FRESH',
+ content: 'my content',
+ errorMessage: '',
+ });
+
+ expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
+ { kind: 'Component', namespace: 'default', name: 'backstage' },
+ '/example',
+ );
+ expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
+ kind: 'Component',
+ namespace: 'default',
+ name: 'backstage',
+ });
+ });
+ });
+
+ it('should reload initially missing content', async () => {
+ techdocsStorageApi.getEntityDocs
+ .mockRejectedValueOnce(new NotFoundError('Page Not Found'))
+ .mockImplementationOnce(async () => {
+ await new Promise(resolve => setTimeout(resolve, 500));
+ return 'my content';
+ });
+ techdocsStorageApi.syncEntityDocs.mockImplementation(async () => {
+ await new Promise(resolve => setTimeout(resolve, 1100));
+ return 'updated';
+ });
+
+ await act(async () => {
+ const { result, waitForValueToChange } = await renderHook(
+ () => useReaderState('Component', 'default', 'backstage', '/example'),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current).toEqual({
+ state: 'CHECKING',
+ content: undefined,
+ errorMessage: '',
+ });
+
+ await waitForValueToChange(() => result.current.state);
+
+ expect(result.current).toEqual({
+ state: 'INITIAL_BUILD',
+ content: undefined,
+ errorMessage: ' Load error: NotFoundError: Page Not Found',
+ });
+
+ await waitForValueToChange(() => result.current.state);
+
+ expect(result.current).toEqual({
+ state: 'CHECKING',
+ content: undefined,
+ errorMessage: '',
+ });
+
+ await waitForValueToChange(() => result.current.state);
+
+ expect(result.current).toEqual({
+ state: 'CONTENT_FRESH',
+ content: 'my content',
+ errorMessage: '',
+ });
+
+ expect(techdocsStorageApi.getEntityDocs).toBeCalledTimes(2);
+ expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
+ { kind: 'Component', namespace: 'default', name: 'backstage' },
+ '/example',
+ );
+ expect(techdocsStorageApi.syncEntityDocs).toBeCalledTimes(1);
+ expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
+ kind: 'Component',
+ namespace: 'default',
+ name: 'backstage',
+ });
+ });
+ });
+
+ it('should handle stale content', async () => {
+ techdocsStorageApi.getEntityDocs.mockResolvedValue('my content');
+ techdocsStorageApi.syncEntityDocs.mockImplementation(async () => {
+ await new Promise(resolve => setTimeout(resolve, 1100));
+ return 'updated';
+ });
+
+ await act(async () => {
+ const { result, waitForValueToChange } = await renderHook(
+ () => useReaderState('Component', 'default', 'backstage', '/example'),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current).toEqual({
+ state: 'CHECKING',
+ content: undefined,
+ errorMessage: '',
+ });
+
+ // the content is returned but the sync is in progress
+ await waitForValueToChange(() => result.current.state);
+ expect(result.current).toEqual({
+ state: 'CONTENT_FRESH',
+ content: 'my content',
+ errorMessage: '',
+ });
+
+ // the sync takes longer than 1 seconds so the refreshing state starts
+ await waitForValueToChange(() => result.current.state);
+ expect(result.current).toEqual({
+ state: 'CONTENT_STALE_REFRESHING',
+ content: 'my content',
+ errorMessage: '',
+ });
+
+ // the content is up-to-date
+ await waitForValueToChange(() => result.current.state);
+ expect(result.current).toEqual({
+ state: 'CONTENT_STALE_READY',
+ content: 'my content',
+ errorMessage: '',
+ });
+
+ 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 },
+ );
+
+ expect(result.current).toEqual({
+ state: 'CHECKING',
+ content: undefined,
+ errorMessage: '',
+ });
+
+ // the content is returned but the sync is in progress
+ await waitForValueToChange(() => result.current.state);
+ expect(result.current).toEqual({
+ state: 'CONTENT_STALE_TIMEOUT',
+ content: 'my content',
+ errorMessage: '',
+ });
+
+ expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
+ { kind: 'Component', namespace: 'default', name: 'backstage' },
+ '/example',
+ );
+ expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
+ kind: 'Component',
+ namespace: 'default',
+ name: 'backstage',
+ });
+ });
+ });
+
+ it('should handle content error', async () => {
+ techdocsStorageApi.getEntityDocs.mockRejectedValue(
+ new NotFoundError('Some error description'),
+ );
+ techdocsStorageApi.syncEntityDocs.mockResolvedValue('cached');
+
+ await act(async () => {
+ const { result, waitForValueToChange } = await renderHook(
+ () => useReaderState('Component', 'default', 'backstage', '/example'),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current).toEqual({
+ state: 'CHECKING',
+ content: undefined,
+ errorMessage: '',
+ });
+
+ // the content loading threw an error
+ await waitForValueToChange(() => result.current.state);
+ expect(result.current).toEqual({
+ state: 'CONTENT_NOT_FOUND',
+ content: undefined,
+ errorMessage: ' Load error: NotFoundError: Some error description',
+ });
+
+ expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
+ { kind: 'Component', namespace: 'default', name: 'backstage' },
+ '/example',
+ );
+ expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
+ kind: 'Component',
+ namespace: 'default',
+ name: 'backstage',
+ });
+ });
+ });
+ });
+});
diff --git a/plugins/techdocs/src/reader/components/useReaderState.ts b/plugins/techdocs/src/reader/components/useReaderState.ts
new file mode 100644
index 0000000000..1dc4bc2677
--- /dev/null
+++ b/plugins/techdocs/src/reader/components/useReaderState.ts
@@ -0,0 +1,323 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * 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 { useApi } from '@backstage/core';
+import { useEffect, useMemo, useReducer, useRef } from 'react';
+import { useAsync, useAsyncRetry } from 'react-use';
+import { techdocsStorageApiRef } from '../../api';
+
+/**
+ * A state representation that is used to configure the UI of
+ */
+type ContentStateTypes =
+ /** There is nothing to display but a loading indicator */
+ | 'CHECKING'
+
+ /** There is no content yet -> present a full screen loading page */
+ | 'INITIAL_BUILD'
+
+ /** There is content, but the backend is about to update it */
+ | 'CONTENT_STALE_REFRESHING'
+
+ /** 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'
+
+ /** There is nothing to see but a "not found" page. Is also shown on page load errors */
+ | 'CONTENT_NOT_FOUND'
+
+ /** There is only the latest and greatest content */
+ | 'CONTENT_FRESH';
+
+/**
+ * Calculate the state that should be reported to the display component.
+ */
+export function calculateDisplayState({
+ contentLoading,
+ content,
+ activeSyncState,
+}: Pick<
+ ReducerState,
+ 'contentLoading' | 'content' | 'activeSyncState'
+>): ContentStateTypes {
+ // we have nothing to display yet
+ if (contentLoading) {
+ return 'CHECKING';
+ }
+
+ // the build is ready, but it triggered a content reload and the content variable is not trusted
+ if (activeSyncState === 'BUILD_READY_RELOAD') {
+ return 'CHECKING';
+ }
+
+ // there is no content, but the sync process is still evaluating
+ if (!content && activeSyncState === 'CHECKING') {
+ return 'CHECKING';
+ }
+
+ // there is no content yet so we assume that we are building it for the first time
+ if (!content && activeSyncState === 'BUILDING') {
+ return 'INITIAL_BUILD';
+ }
+
+ // if there is still no content after building, it might just not exist
+ if (!content) {
+ return 'CONTENT_NOT_FOUND';
+ }
+
+ // we are still building, but we already show stale content
+ if (activeSyncState === 'BUILDING') {
+ return 'CONTENT_STALE_REFRESHING';
+ }
+
+ // the build is ready, but the content is still stale
+ if (activeSyncState === 'BUILD_READY') {
+ 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';
+ }
+
+ // seems like the content is up-to-date (or we don't know yet and the sync process is still evaluating in the background)
+ return 'CONTENT_FRESH';
+}
+
+/**
+ * The state of the synchronization task. It checks whether the docs are
+ * up-to-date. If they aren't, it triggers a build.
+ */
+type SyncStates =
+ /** Checking if it should be synced */
+ | 'CHECKING'
+
+ /** Building the documentation */
+ | 'BUILDING'
+
+ /** Finished building the documentation */
+ | 'BUILD_READY'
+
+ /**
+ * Finished building the documentation and triggered a content reload.
+ * This state is left toward UP_TO_DATE when the content loading has finished.
+ */
+ | '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'
+
+ /** An error occurred */
+ | 'ERROR';
+
+type ReducerActions =
+ | {
+ type: 'sync';
+ state: SyncStates;
+ syncError?: Error;
+ }
+ | {
+ type: 'content';
+ content?: string;
+ contentLoading?: true;
+ contentError?: Error;
+ }
+ | { type: 'navigate'; path: string };
+
+type ReducerState = {
+ /**
+ * The path of the current page
+ */
+ path: string;
+
+ /**
+ * The current sync state
+ */
+ activeSyncState: SyncStates;
+
+ /**
+ * If true, the content is downloading from the storage.
+ */
+ contentLoading: boolean;
+ /**
+ * The content that has been downloaded and should be displayed.
+ */
+ content?: string;
+
+ contentError?: Error;
+ syncError?: Error;
+};
+
+export function reducer(
+ oldState: ReducerState,
+ action: ReducerActions,
+): ReducerState {
+ const newState = { ...oldState };
+
+ switch (action.type) {
+ case 'sync':
+ newState.activeSyncState = action.state;
+ newState.syncError = action.syncError;
+ break;
+
+ case 'content':
+ newState.content = action.content;
+ newState.contentLoading = action.contentLoading ?? false;
+ newState.contentError = action.contentError;
+ break;
+
+ case 'navigate':
+ newState.path = action.path;
+ break;
+
+ default:
+ throw new Error();
+ }
+
+ // a navigation or 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)
+ ) {
+ newState.activeSyncState = 'UP_TO_DATE';
+ }
+
+ return newState;
+}
+
+export function useReaderState(
+ kind: string,
+ namespace: string,
+ name: string,
+ path: string,
+): { state: ContentStateTypes; content?: string; errorMessage?: string } {
+ const [state, dispatch] = useReducer(reducer, {
+ activeSyncState: 'CHECKING',
+ path,
+ contentLoading: true,
+ });
+
+ 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 });
+
+ try {
+ const entityDocs = await techdocsStorageApi.getEntityDocs(
+ { kind, namespace, name },
+ path,
+ );
+
+ dispatch({ type: 'content', content: entityDocs });
+
+ return entityDocs;
+ } catch (e) {
+ dispatch({ type: 'content', contentError: e });
+ }
+
+ return undefined;
+ }, [techdocsStorageApi, kind, namespace, name, path]);
+
+ // create a ref that holds the latest content. This provides a useAsync hook
+ // with the latest content without restarting the useAsync hook.
+ const contentRef = useRef<{ content?: string; reload: () => void }>({
+ content: undefined,
+ reload: () => {},
+ });
+ contentRef.current = { content: state.content, reload: contentReload };
+
+ // try to derive the state. the function will fire events and we don't care for the return values
+ useAsync(async () => {
+ dispatch({ type: 'sync', state: 'CHECKING' });
+
+ // should only switch to BUILDING if the request takes more than 1 seconds
+ const buildingTimeout = setTimeout(() => {
+ dispatch({ type: 'sync', state: 'BUILDING' });
+ }, 1000);
+
+ try {
+ const result = await techdocsStorageApi.syncEntityDocs({
+ kind,
+ namespace,
+ name,
+ });
+
+ 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' });
+ }
+ } catch (e) {
+ dispatch({ type: 'sync', state: 'ERROR', syncError: e });
+ } finally {
+ // Cancel the timer that sets the state "BUILDING"
+ clearTimeout(buildingTimeout);
+ }
+ }, [kind, name, namespace, techdocsStorageApi, dispatch, contentRef]);
+
+ const displayState = useMemo(
+ () =>
+ calculateDisplayState({
+ activeSyncState: state.activeSyncState,
+ contentLoading: state.contentLoading,
+ content: state.content,
+ }),
+ [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,
+ content: state.content,
+ errorMessage,
+ };
+}
diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts
index 6b6eae4b0d..9bfcbe624a 100644
--- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts
+++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts
@@ -27,7 +27,7 @@ const techdocsStorageApi: TechDocsStorageApi = {
Promise.resolve(new URL(o, DOC_STORAGE_URL).toString()),
),
getEntityDocs: () => new Promise(resolve => resolve('yes!')),
- syncEntityDocs: () => new Promise(resolve => resolve(true)),
+ syncEntityDocs: () => new Promise(resolve => resolve('updated')),
getApiOrigin: jest.fn(() => new Promise(resolve => resolve(API_ORIGIN_URL))),
getBuilder: jest.fn(),
getStorageUrl: jest.fn(),