backstage
296 строк · 8.6 Кб
1/*
2* Copyright 2022 The Backstage Authors
3*
4* Licensed under the Apache License, Version 2.0 (the "License");
5* you may not use this file except in compliance with the License.
6* You may obtain a copy of the License at
7*
8* http://www.apache.org/licenses/LICENSE-2.0
9*
10* Unless required by applicable law or agreed to in writing, software
11* distributed under the License is distributed on an "AS IS" BASIS,
12* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13* See the License for the specific language governing permissions and
14* limitations under the License.
15*/
16
17import React, { ReactElement } from 'react';
18
19// Shadow DOM support for the simple and complete DOM testing utilities
20// https://github.com/testing-library/dom-testing-library/issues/742#issuecomment-674987855
21import { screen } from 'testing-library__dom';
22import { Route } from 'react-router-dom';
23import { act, render } from '@testing-library/react';
24
25import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils';
26import { FlatRoutes } from '@backstage/core-app-api';
27import { ApiRef } from '@backstage/core-plugin-api';
28
29import {
30TechDocsAddons,
31techdocsApiRef,
32TechDocsEntityMetadata,
33TechDocsMetadata,
34techdocsStorageApiRef,
35} from '@backstage/plugin-techdocs-react';
36import { TechDocsReaderPage, techdocsPlugin } from '@backstage/plugin-techdocs';
37import { entityRouteRef } from '@backstage/plugin-catalog-react';
38import { searchApiRef } from '@backstage/plugin-search-react';
39import { scmIntegrationsApiRef } from '@backstage/integration-react';
40
41// Since React 18 react-dom/server eagerly uses TextEncoder, so lazy load and make it available globally first
42if (!global.TextEncoder) {
43global.TextEncoder = require('util').TextEncoder;
44}
45const { renderToStaticMarkup } =
46require('react-dom/server') as typeof import('react-dom/server');
47
48const techdocsApi = {
49getTechDocsMetadata: jest.fn(),
50getEntityMetadata: jest.fn(),
51};
52
53const techdocsStorageApi = {
54getApiOrigin: jest.fn(),
55getBaseUrl: jest.fn(),
56getEntityDocs: jest.fn(),
57syncEntityDocs: jest.fn(),
58};
59
60const searchApi = {
61query: jest.fn().mockResolvedValue({ results: [] }),
62};
63
64const scmIntegrationsApi = {
65fromConfig: jest.fn().mockReturnValue({}),
66};
67
68/** @ignore */
69type TechDocsAddonTesterTestApiPair<TApi> = TApi extends infer TImpl
70? readonly [ApiRef<TApi>, Partial<TImpl>]
71: never;
72
73/** @ignore */
74type TechdocsAddonTesterApis<TApiPairs> = {
75[TIndex in keyof TApiPairs]: TechDocsAddonTesterTestApiPair<
76TApiPairs[TIndex]
77>;
78};
79
80type TechDocsAddonTesterOptions = {
81dom: ReactElement;
82entity: Partial<TechDocsEntityMetadata>;
83metadata: Partial<TechDocsMetadata>;
84componentId: string;
85apis: TechdocsAddonTesterApis<any[]>;
86path: string;
87};
88
89const defaultOptions: TechDocsAddonTesterOptions = {
90dom: <></>,
91entity: { metadata: { name: '' } },
92metadata: {},
93componentId: 'docs',
94apis: [],
95path: '',
96};
97
98const defaultMetadata = {
99site_name: 'Tech Docs',
100site_description: 'Tech Docs',
101};
102
103const defaultEntity = {
104kind: 'Component',
105metadata: { namespace: 'default', name: 'docs' },
106};
107
108const defaultDom = (
109<html lang="en">
110<head />
111<body>
112<div data-md-component="container">
113<div data-md-component="navigation" />
114<div data-md-component="toc" />
115<div data-md-component="main" />
116</div>
117</body>
118</html>
119);
120
121/**
122* Utility class for rendering TechDocs Addons end-to-end within the TechDocs
123* reader page, with a set of givens (e.g. page DOM, metadata, etc).
124*
125* @example
126* ```tsx
127* const { getByText } = await TechDocsAddonTester.buildAddonsInTechDocs([<AnAddon />])
128* .withDom(<body>TEST_CONTENT</body>)
129* .renderWithEffects();
130*
131* expect(getByText('TEST_CONTENT')).toBeInTheDocument();
132* ```
133*
134* @public
135*/
136export class TechDocsAddonTester {
137private options: TechDocsAddonTesterOptions = defaultOptions;
138private addons: ReactElement[];
139
140/**
141* Get a TechDocsAddonTester instance for a given set of Addons.
142*/
143static buildAddonsInTechDocs(addons: ReactElement[]) {
144return new TechDocsAddonTester(addons);
145}
146
147// Protected in order to allow extension but not direct instantiation.
148protected constructor(addons: ReactElement[]) {
149this.addons = addons;
150}
151
152/**
153* Provide mock API implementations if your Addon expects any.
154*/
155withApis<T extends any[]>(apis: TechdocsAddonTesterApis<T>) {
156const refs = apis.map(([ref]) => ref);
157this.options.apis = this.options.apis
158.filter(([ref]) => !refs.includes(ref))
159.concat(apis);
160return this;
161}
162
163/**
164* Provide mock HTML if your Addon expects it in the shadow DOM.
165*/
166withDom(dom: ReactElement) {
167this.options.dom = dom;
168return this;
169}
170
171/**
172* Provide mock techdocs_metadata.json values if your Addon needs it.
173*/
174withMetadata(metadata: Partial<TechDocsMetadata>) {
175this.options.metadata = metadata;
176return this;
177}
178
179/**
180* Provide a mock entity if your Addon needs it. This also controls the base
181* path at which the Addon is rendered.
182*/
183withEntity(entity: Partial<TechDocsEntityMetadata>) {
184this.options.entity = entity;
185return this;
186}
187
188/**
189* Provide the TechDocs page path at which the Addon is rendered (e.g. the
190* part of the path after the entity namespace/kind/name).
191*/
192atPath(path: string) {
193this.options.path = path;
194return this;
195}
196
197/**
198* Return a fully configured and mocked TechDocs reader page within a test
199* App instance, using the given Addon(s).
200*/
201build() {
202const apis: TechdocsAddonTesterApis<any[]> = [
203[techdocsApiRef, techdocsApi],
204[techdocsStorageApiRef, techdocsStorageApi],
205[searchApiRef, searchApi],
206[scmIntegrationsApiRef, scmIntegrationsApi],
207...this.options.apis,
208];
209
210const entityName = {
211namespace:
212this.options.entity?.metadata?.namespace ||
213defaultEntity.metadata.namespace,
214kind: this.options.entity?.kind || defaultEntity.kind,
215name: this.options.entity?.metadata?.name || defaultEntity.metadata.name,
216};
217
218techdocsApi.getTechDocsMetadata.mockReturnValue(
219this.options.metadata || { ...defaultMetadata },
220);
221techdocsApi.getEntityMetadata.mockResolvedValue(
222this.options.entity || { ...defaultEntity },
223);
224
225techdocsStorageApi.syncEntityDocs.mockResolvedValue('cached');
226techdocsStorageApi.getApiOrigin.mockResolvedValue(
227'https://backstage.example.com/api/techdocs',
228);
229techdocsStorageApi.getBaseUrl.mockResolvedValue(
230`https://backstage.example.com/api/techdocs/${entityName.namespace}/${entityName.kind}/${entityName.name}/${this.options.path}`,
231);
232techdocsStorageApi.getEntityDocs.mockResolvedValue(
233renderToStaticMarkup(this.options.dom || defaultDom),
234);
235
236const TechDocsAddonsPage = () => {
237return (
238<TestApiProvider apis={apis}>
239<FlatRoutes>
240<Route
241path="/docs/:namespace/:kind/:name/*"
242element={<TechDocsReaderPage />}
243>
244<TechDocsAddons>
245{this.addons.map((addon, index) =>
246React.cloneElement(addon, { key: index }),
247)}
248</TechDocsAddons>
249</Route>
250</FlatRoutes>
251</TestApiProvider>
252);
253};
254
255return wrapInTestApp(<TechDocsAddonsPage />, {
256routeEntries: [
257`/docs/${entityName.namespace}/${entityName.kind}/${entityName.name}/${this.options.path}`,
258],
259mountedRoutes: {
260'/docs': techdocsPlugin.routes.root,
261'/docs/:namespace/:kind/:name/*': techdocsPlugin.routes.docRoot,
262'/catalog/:namespace/:kind/:name': entityRouteRef,
263},
264});
265}
266
267/**
268* Render the Addon within a fully configured and mocked TechDocs reader.
269*
270* @remarks
271* Components using useEffect to perform an asynchronous action (such as
272* fetch) must be rendered within an async act call to properly get the final
273* state, even with mocked responses. This utility method makes the signature
274* a bit cleaner, since act doesn't return the result of the evaluated
275* function.
276*
277* @see https://github.com/testing-library/react-testing-library/issues/281
278* @see https://github.com/facebook/react/pull/14853
279*/
280async renderWithEffects(): Promise<
281typeof screen & { shadowRoot: ShadowRoot | null }
282> {
283await act(async () => {
284render(this.build());
285});
286
287const shadowHost = await screen.findByTestId('techdocs-native-shadowroot');
288
289return {
290...screen,
291shadowRoot: shadowHost?.shadowRoot || null,
292};
293}
294}
295
296export default TechDocsAddonTester.buildAddonsInTechDocs;
297