diff --git a/packages/test-utils-core/api-report.md b/packages/test-utils-core/api-report.md index 4dad7a3a8a..60d4b5b0f7 100644 --- a/packages/test-utils-core/api-report.md +++ b/packages/test-utils-core/api-report.md @@ -6,21 +6,17 @@ import { ReactElement } from 'react'; import { RenderResult } from '@testing-library/react'; -// Warning: (ae-missing-release-tag) "AsyncLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export type AsyncLogCollector = () => Promise; -// Warning: (ae-missing-release-tag) "CollectedLogs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export type CollectedLogs = { [key in T]: string[]; }; -// Warning: (ae-missing-release-tag) "Keyboard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative // -// @public (undocumented) +// @public @deprecated (undocumented) export class Keyboard { constructor( target: any, @@ -66,48 +62,35 @@ export class Keyboard { static typeDebug(target: any, input: any): Promise; } -// Warning: (ae-missing-release-tag) "LogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export type LogCollector = AsyncLogCollector | SyncLogCollector; -// Warning: (ae-missing-release-tag) "LogFuncs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export type LogFuncs = 'log' | 'warn' | 'error'; -// Warning: (ae-missing-release-tag) "renderWithEffects" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated export function renderWithEffects(nodes: ReactElement): Promise; -// Warning: (ae-missing-release-tag) "SyncLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export type SyncLogCollector = () => void; -// Warning: (ae-missing-release-tag) "withLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// Warning: (ae-missing-release-tag) "withLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// Warning: (ae-missing-release-tag) "withLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// Warning: (ae-missing-release-tag) "withLogCollector" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated export function withLogCollector( callback: AsyncLogCollector, ): Promise>; -// @public (undocumented) +// @public @deprecated export function withLogCollector( callback: SyncLogCollector, ): CollectedLogs; -// @public (undocumented) +// @public @deprecated export function withLogCollector( logsToCollect: T[], callback: AsyncLogCollector, ): Promise>; -// @public (undocumented) +// @public @deprecated export function withLogCollector( logsToCollect: T[], callback: SyncLogCollector, diff --git a/packages/test-utils-core/src/testUtils/Keyboard.js b/packages/test-utils-core/src/testUtils/Keyboard.js index 3f45ca90f6..b0600eb78d 100644 --- a/packages/test-utils-core/src/testUtils/Keyboard.js +++ b/packages/test-utils-core/src/testUtils/Keyboard.js @@ -23,6 +23,9 @@ const codes = { Esc: 27, }; +/** + * @public + * @deprecated because of no usages. */ export class Keyboard { static async type(target, input) { await new Keyboard(target).type(input); diff --git a/packages/test-utils-core/src/testUtils/logCollector.ts b/packages/test-utils-core/src/testUtils/logCollector.ts index 3180010529..22f06e172b 100644 --- a/packages/test-utils-core/src/testUtils/logCollector.ts +++ b/packages/test-utils-core/src/testUtils/logCollector.ts @@ -16,36 +16,69 @@ /* eslint-disable no-console */ +/** + * @public + * @deprecated import from test-utils instead */ export type LogFuncs = 'log' | 'warn' | 'error'; +/** + * @public + * @deprecated import from test-utils instead */ export type AsyncLogCollector = () => Promise; +/** + * @public + * @deprecated import from test-utils instead */ export type SyncLogCollector = () => void; +/** + * @public + * @deprecated import from test-utils instead */ export type LogCollector = AsyncLogCollector | SyncLogCollector; +/** + * @public + * @deprecated import from test-utils instead */ export type CollectedLogs = { [key in T]: string[] }; const allCategories = ['log', 'warn', 'error']; -// Asynchronous log collector with that collects all categories +/** + * Asynchronous log collector with that collects all categories + * @public + * @deprecated import from test-utils instead */ export function withLogCollector( callback: AsyncLogCollector, ): Promise>; -// Synchronous log collector with that collects all categories +/** + * Synchronous log collector with that collects all categories + * @public + * @deprecated import from test-utils instead */ export function withLogCollector( callback: SyncLogCollector, ): CollectedLogs; -// Asynchronous log collector with that only collects selected categories +/** + * Asynchronous log collector with that only collects selected categories + * @public + * @deprecated import from test-utils instead + */ + export function withLogCollector( logsToCollect: T[], callback: AsyncLogCollector, ): Promise>; -// Synchronous log collector with that only collects selected categories +/** + * Synchronous log collector with that only collects selected categories + * @public + * @deprecated import from test-utils instead */ export function withLogCollector( logsToCollect: T[], callback: SyncLogCollector, ): CollectedLogs; +/** + * @public + * @deprecated import from test-utils instead + * */ export function withLogCollector( logsToCollect: LogFuncs[] | LogCollector, callback?: LogCollector, diff --git a/packages/test-utils-core/src/testUtils/testingLibrary.ts b/packages/test-utils-core/src/testUtils/testingLibrary.ts index 48e5132825..953a414968 100644 --- a/packages/test-utils-core/src/testUtils/testingLibrary.ts +++ b/packages/test-utils-core/src/testUtils/testingLibrary.ts @@ -18,11 +18,17 @@ import { ReactElement } from 'react'; import { act } from 'react-dom/test-utils'; import { render, RenderResult } from '@testing-library/react'; -// Components using useEffect to perform an asynchronous action (such as fetch) must be rendered within an async -// act call to properly get the final state, even with mocked responses. This utility method makes the signature a bit -// cleaner, since act doesn't return the result of the evaluated function. -// https://github.com/testing-library/react-testing-library/issues/281 -// https://github.com/facebook/react/pull/14853 +/** + * Simplifies rendering of async components in by taking care of the wrapping inside act + * @remarks + * Components using useEffect to perform an asynchronous action (such as fetch) must be rendered within an async + * act call to properly get the final state, even with mocked responses. This utility method makes the signature a bit + * cleaner, since act doesn't return the result of the evaluated function. + * https://github.com/testing-library/react-testing-library/issues/281 + * https://github.com/facebook/react/pull/14853 + * @public + * @deprecated import from test-utils instead + */ export async function renderWithEffects( nodes: ReactElement, ): Promise { diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index f4b3fe12bb..b432141fa1 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -17,9 +17,73 @@ import { RouteRef } from '@backstage/core-plugin-api'; import { StorageApi } from '@backstage/core-plugin-api'; import { StorageValueChange } from '@backstage/core-plugin-api'; -// Warning: (ae-missing-release-tag) "MockAnalyticsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public +export type AsyncLogCollector = () => Promise; + +// @public +export type CollectedLogs = { + [key in T]: string[]; +}; + +// @public +export type ErrorWithContext = { + error: Error; + context?: ErrorContext; +}; + +// @public @deprecated (undocumented) +export class Keyboard { + constructor( + target: any, + { + debug, + }?: { + debug?: boolean | undefined; + }, + ); + // (undocumented) + click(): Promise; + // (undocumented) + debug: boolean; + // (undocumented) + document: any; + // (undocumented) + enter(value: any): Promise; + // (undocumented) + escape(): Promise; + // (undocumented) + get focused(): any; + // (undocumented) + static fromReadableInput(input: any): any; + // (undocumented) + _log(message: any, ...args: any[]): void; + // (undocumented) + _pretty(element: any): string; + // (undocumented) + send(chars: any): Promise; + // (undocumented) + _sendKey(key: any, charCode: any, action: any): Promise; + // (undocumented) + tab(): Promise; + // (undocumented) + static toReadableInput(chars: any): any; + // (undocumented) + toString(): string; + // (undocumented) + static type(target: any, input: any): Promise; + // (undocumented) + type(input: any): Promise; + // (undocumented) + static typeDebug(target: any, input: any): Promise; +} + +// @public +export type LogCollector = AsyncLogCollector | SyncLogCollector; + +// @public +export type LogFuncs = 'log' | 'warn' | 'error'; + +// @public export class MockAnalyticsApi implements AnalyticsApi { // (undocumented) captureEvent({ @@ -33,9 +97,6 @@ export class MockAnalyticsApi implements AnalyticsApi { getEvents(): AnalyticsEvent[]; } -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "mockBreakpoint" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function mockBreakpoint({ matches, @@ -43,19 +104,14 @@ export function mockBreakpoint({ matches?: boolean | undefined; }): void; -// Warning: (ae-missing-release-tag) "MockErrorApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export class MockErrorApi implements ErrorApi { - // Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts - constructor(options?: Options); + constructor(options?: MockErrorApiOptions); // (undocumented) error$(): Observable<{ error: Error; context?: ErrorContext; }>; - // Warning: (ae-forgotten-export) The symbol "ErrorWithContext" needs to be exported by the entry point index.d.ts - // // (undocumented) getErrors(): ErrorWithContext[]; // (undocumented) @@ -64,9 +120,12 @@ export class MockErrorApi implements ErrorApi { waitForError(pattern: RegExp, timeoutMs?: number): Promise; } -// Warning: (ae-missing-release-tag) "MockStorageApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public +export type MockErrorApiOptions = { + collect?: boolean; +}; + +// @public export class MockStorageApi implements StorageApi { // (undocumented) static create(data?: MockStorageBucket): MockStorageApi; @@ -82,9 +141,7 @@ export class MockStorageApi implements StorageApi { set(key: string, data: T): Promise; } -// Warning: (ae-missing-release-tag) "MockStorageBucket" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type MockStorageBucket = { [key: string]: any; }; @@ -100,22 +157,51 @@ export const msw: { }) => void; }; -// Warning: (ae-forgotten-export) The symbol "TestAppOptions" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "renderInTestApp" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export function renderInTestApp( Component: ComponentType | ReactNode, options?: TestAppOptions, ): Promise; -// Warning: (ae-missing-release-tag) "wrapInTestApp" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// +// @public +export function renderWithEffects(nodes: ReactElement): Promise; + +// @public +export type SyncLogCollector = () => void; + +// @public +export type TestAppOptions = { + routeEntries?: string[]; + mountedRoutes?: { + [path: string]: RouteRef | ExternalRouteRef; + }; +}; + +// @public +export function withLogCollector( + callback: AsyncLogCollector, +): Promise>; + +// @public +export function withLogCollector( + callback: SyncLogCollector, +): CollectedLogs; + +// @public +export function withLogCollector( + logsToCollect: T[], + callback: AsyncLogCollector, +): Promise>; + +// @public +export function withLogCollector( + logsToCollect: T[], + callback: SyncLogCollector, +): CollectedLogs; + // @public export function wrapInTestApp( Component: ComponentType | ReactNode, options?: TestAppOptions, ): ReactElement; - -export * from '@backstage/test-utils-core'; ``` diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 22dc950bd4..44a2bb1e15 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -31,7 +31,6 @@ "dependencies": { "@backstage/core-app-api": "^0.1.18", "@backstage/core-plugin-api": "^0.1.11", - "@backstage/test-utils-core": "^0.1.3", "@backstage/theme": "^0.2.11", "@material-ui/core": "^4.12.2", "@testing-library/jest-dom": "^5.10.1", diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index 545a5b9961..83a955baa7 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -21,4 +21,3 @@ */ export * from './testUtils'; -export * from '@backstage/test-utils-core'; diff --git a/packages/test-utils/src/testUtils/Keyboard.js b/packages/test-utils/src/testUtils/Keyboard.js new file mode 100644 index 0000000000..3f4724d562 --- /dev/null +++ b/packages/test-utils/src/testUtils/Keyboard.js @@ -0,0 +1,225 @@ +/* + * 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 { act, fireEvent } from '@testing-library/react'; + +const codes = { + Tab: 9, + Enter: 10, + Click: 17 /* This keyboard can click, deal with it */, + Esc: 27, +}; + +/** + * @public + * @deprecated because it has no usages. Perhaps resurfaced in the future when need be. + */ +export class Keyboard { + static async type(target, input) { + await new Keyboard(target).type(input); + } + + static async typeDebug(target, input) { + await new Keyboard(target, { debug: true }).type(input); + } + + static toReadableInput(chars) { + return chars.split('').map(char => { + switch (char.charCodeAt(0)) { + case codes.Tab: + return ''; + case codes.Enter: + return ''; + case codes.Click: + return ''; + case codes.Esc: + return ''; + default: + return char; + } + }); + } + + static fromReadableInput(input) { + return input.trim().replace(/\s*<([a-zA-Z]+)>\s*/g, (match, name) => { + if (name in codes) { + return String.fromCharCode(codes[name]); + } + throw new Error(`Unknown char name: '${name}'`); + }); + } + + constructor(target, { debug = false } = {}) { + this.debug = debug; + + if (target.ownerDocument) { + this.document = target.ownerDocument; + } else if (target.baseElement) { + this.document = target.baseElement.ownerDocument; + } else { + throw new TypeError( + 'Keyboard(target): target must be DOM node or react-testing-library render() output', + ); + } + } + + toString() { + return `Keyboard{document=${this.document}, debug=${this.debug}}`; + } + + _log(message, ...args) { + if (this.debug) { + // eslint-disable-next-line no-console + console.log(`[Keyboard] ${message}`, ...args); + } + } + + _pretty(element) { + const attrs = [...element.attributes] + .map(attr => `${attr.name}="${attr.value}"`) + .join(' '); + return `<${element.nodeName.toLocaleLowerCase('en-US')} ${attrs}>`; + } + + get focused() { + return this.document.activeElement; + } + + async type(input) { + this._log( + `sending sequence '${input}' with initial focus ${this._pretty( + this.focused, + )}`, + ); + await this.send(Keyboard.fromReadableInput(input)); + } + + async send(chars) { + for (const key of chars.split('')) { + const charCode = key.charCodeAt(0); + + if (charCode === codes.Tab) { + await this.tab(); + continue; + } + + const focused = this.focused; + if (!focused || focused === this.document.body) { + throw Error( + `No element focused in document while trying to type '${Keyboard.toReadableInput( + chars, + )}'`, + ); + } + const nextValue = (focused.value || '') + key; + + if (charCode >= 32) { + await this._sendKey(key, charCode, () => { + this._log( + `sending +${key} = '${nextValue}' to ${this._pretty(focused)}`, + ); + fireEvent.change(focused, { + target: { value: nextValue }, + bubbles: true, + cancelable: true, + }); + }); + } else if (charCode === codes.Enter) { + await this.enter(focused.value || ''); + } else if (charCode === codes.Esc) { + await this.escape(); + } else if (charCode === codes.Click) { + await this.click(); + } else { + throw new Error(`Unsupported char code, ${charCode}`); + } + } + } + + async click() { + this._log(`clicking ${this._pretty(this.focused)}`); + await act(async () => fireEvent.click(this.focused)); + } + + async tab() { + await this._sendKey('Tab', codes.Tab, () => { + const focusable = this.document.querySelectorAll( + [ + 'a[href]', + 'area[href]', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'button:not([disabled])', + 'iframe', + 'object', + 'embed', + '*[tabindex]', + '*[contenteditable]', + ].join(','), + ); + + const tabbable = [...focusable].filter(el => { + return el.tabIndex >= 0; + }); + + const focused = this.document.activeElement; + const focusedIndex = tabbable.indexOf(focused); + const nextFocus = tabbable[focusedIndex + (1 % tabbable.length)]; + + this._log( + `tabbing to ${this._pretty(nextFocus)} ${this.focused.textContent}`, + ); + nextFocus.focus(); + }); + } + + async enter(value) { + this._log(`submitting '${value}' via ${this._pretty(this.focused)}`); + await act(() => + this._sendKey('Enter', codes.Enter, () => { + if (this.focused.type === 'button') { + fireEvent.click(this.focused, { target: { value } }); + } else { + fireEvent.submit(this.focused, { + target: { value }, + bubbles: true, + cancelable: true, + }); + } + }), + ); + } + + async escape() { + this._log(`escape from ${this._pretty(this.focused)}`); + await act(async () => this._sendKey('Escape', codes.Esc)); + } + + async _sendKey(key, charCode, action) { + const event = { key, charCode, keyCode: charCode, which: charCode }; + const focused = this.focused; + + if (fireEvent.keyDown(focused, event)) { + if (fireEvent.keyPress(focused, event)) { + if (action) { + action(); + } + } + } + fireEvent.keyUp(focused, event); + } +} diff --git a/packages/test-utils/src/testUtils/Keyboard.test.js b/packages/test-utils/src/testUtils/Keyboard.test.js new file mode 100644 index 0000000000..41ee3a12d6 --- /dev/null +++ b/packages/test-utils/src/testUtils/Keyboard.test.js @@ -0,0 +1,108 @@ +/* + * 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 from 'react'; +import { Keyboard } from './Keyboard'; +import { render } from '@testing-library/react'; + +describe('testUtils.Keyboard', () => { + it('types into some inputs with focus and submits a form', async () => { + const typed1 = []; + const typed2 = []; + const typed3 = []; + + let submitted = false; + const handleSubmit = event => { + event.preventDefault(); + submitted = true; + }; + + const rendered = render( +
+ typed1.push(value)} /> + typed2.push(value)} + /* eslint-disable-next-line jsx-a11y/no-autofocus */ + autoFocus + /> + typed3.push(value)} /> +
, + ); + + const keyboard = new Keyboard(rendered); + await keyboard.send('xy'); + await keyboard.tab(); + await keyboard.send('abc'); + await keyboard.enter(); + + expect(typed1).toEqual([]); + expect(typed2).toEqual(['x', 'xy']); + expect(typed3).toEqual(['a', 'ab', 'abc']); + expect(submitted).toBe(true); + }); + + it('can use Keyboard.type to send readable input', async () => { + const typed1 = []; + const typed2 = []; + const typed3 = []; + + let submitted = false; + const handleSubmit = event => { + event.preventDefault(); + submitted = true; + }; + + const rendered = render( +
+ typed1.push(value)} + /> + typed2.push(value)} + /> + typed3.push(value)} + /> +
, + ); + + await Keyboard.type(rendered, ' a b c '); + + expect(typed1).toEqual(['1a']); + expect(typed2).toEqual(['2b']); + expect(typed3).toEqual(['3c']); + expect(submitted).toBe(true); + }); + + it('should be able to navigate a radio input with click', async () => { + const selections = []; + + const rendered = render( +
selections.push(value)}> + + + +
, + ); + + await Keyboard.type(rendered, ' '); + + expect(selections).toEqual(['a', 'c']); + }); +}); diff --git a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts index 28585145ad..3e2fc2a01c 100644 --- a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts +++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts @@ -16,6 +16,11 @@ import { AnalyticsApi, AnalyticsEvent } from '@backstage/core-plugin-api'; +/** + * Mock implementation of {@link core-plugin-api#AnalyticsApi} with helpers to ensure that events are sent correctly. + * Use getEvents in tests to verify captured events. + * @public + */ export class MockAnalyticsApi implements AnalyticsApi { private events: AnalyticsEvent[] = []; diff --git a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts index 20b6bd1724..31f8aa25e3 100644 --- a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts +++ b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts @@ -16,11 +16,20 @@ import { ErrorApi, ErrorContext, Observable } from '@backstage/core-plugin-api'; -type Options = { +/** + * Constructor arguments for {@link MockErrorApi} + * @public + */ +export type MockErrorApiOptions = { + // Need to be true if getErrors is used in testing. collect?: boolean; }; -type ErrorWithContext = { +/** + * ErrorWithContext contains error and ErrorContext + * @public + */ +export type ErrorWithContext = { error: Error; context?: ErrorContext; }; @@ -38,11 +47,16 @@ const nullObservable = { }, }; +/** + * Mock implementation of the {@link core-plugin-api#ErrorApi} to be used in tests. + * Incudes withForError and getErrors methods for error testing. + * @public + */ export class MockErrorApi implements ErrorApi { private readonly errors = new Array(); private readonly waiters = new Set(); - constructor(private readonly options: Options = {}) {} + constructor(private readonly options: MockErrorApiOptions = {}) {} post(error: Error, context?: ErrorContext) { if (this.options.collect) { diff --git a/packages/test-utils/src/testUtils/apis/ErrorApi/index.ts b/packages/test-utils/src/testUtils/apis/ErrorApi/index.ts index 8a6b5031e5..1273a91531 100644 --- a/packages/test-utils/src/testUtils/apis/ErrorApi/index.ts +++ b/packages/test-utils/src/testUtils/apis/ErrorApi/index.ts @@ -15,3 +15,4 @@ */ export { MockErrorApi } from './MockErrorApi'; +export type { MockErrorApiOptions, ErrorWithContext } from './MockErrorApi'; diff --git a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts index ab5d26afc5..3e51ed579f 100644 --- a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts +++ b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts @@ -21,8 +21,16 @@ import { } from '@backstage/core-plugin-api'; import ObservableImpl from 'zen-observable'; +/** + * Type for map holding data in {@link MockStorageApi} + * @public + */ export type MockStorageBucket = { [key: string]: any }; +/** + * Mock implementation of the {@link core-plugin-api#StorageApi} to be used in tests + * @public + */ export class MockStorageApi implements StorageApi { private readonly namespace: string; private readonly data: MockStorageBucket; diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index 09c9e5d0bf..6f55cc6190 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -27,7 +27,7 @@ import { createRouteRef, } from '@backstage/core-plugin-api'; import { RenderResult } from '@testing-library/react'; -import { renderWithEffects } from '@backstage/test-utils-core'; +import { renderWithEffects } from './testingLibrary'; import { mockApis } from './mockApis'; const ErrorBoundaryFallback = ({ error }: { error: Error }) => { @@ -43,8 +43,9 @@ const Progress = () =>
; /** * Options to customize the behavior of the test app wrapper. + * @public */ -type TestAppOptions = { +export type TestAppOptions = { /** * Initial route entries to pass along as `initialEntries` to the router. */ @@ -56,11 +57,11 @@ type TestAppOptions = { * used by `useRouteRef` in the rendered elements. * * @example - * wrapInTestApp(, { - * mountedRoutes: { + * wrapInTestApp(, \{ + * mountedRoutes: \{ * '/my-path': myRouteRef, - * } - * }) + * \} + * \}) * // ... * const link = useRouteRef(myRouteRef) */ @@ -80,6 +81,7 @@ function isExternalRouteRef( * * @param Component - A component or react node to render inside the test app. * @param options - Additional options for the rendering. + * @public */ export function wrapInTestApp( Component: ComponentType | ReactNode, @@ -171,6 +173,7 @@ export function wrapInTestApp( * * @param Component - A component or react node to render inside the test app. * @param options - Additional options for the rendering. + * @public */ export async function renderInTestApp( Component: ComponentType | ReactNode, diff --git a/packages/test-utils/src/testUtils/index.tsx b/packages/test-utils/src/testUtils/index.tsx index 859315313d..7d93d606cc 100644 --- a/packages/test-utils/src/testUtils/index.tsx +++ b/packages/test-utils/src/testUtils/index.tsx @@ -17,4 +17,8 @@ export * from './apis'; export { default as mockBreakpoint } from './mockBreakpoint'; export { wrapInTestApp, renderInTestApp } from './appWrappers'; +export type { TestAppOptions } from './appWrappers'; export * from './msw'; +export * from './Keyboard'; +export * from './logCollector'; +export * from './testingLibrary'; diff --git a/packages/test-utils/src/testUtils/logCollector.test.ts b/packages/test-utils/src/testUtils/logCollector.test.ts new file mode 100644 index 0000000000..d877e4d10b --- /dev/null +++ b/packages/test-utils/src/testUtils/logCollector.test.ts @@ -0,0 +1,96 @@ +/* + * 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. + */ + +/* eslint-disable no-console */ + +import { withLogCollector } from './logCollector'; + +describe('logCollector', () => { + it('should collect some logs synchronously', () => { + const logs = withLogCollector(() => { + console.log('a'); + console.warn('b'); + console.error('c'); + console.error('3'); + console.warn('2'); + console.log('1'); + }); + + expect(logs.log).toEqual(['a', '1']); + expect(logs.warn).toEqual(['b', '2']); + expect(logs.error).toEqual(['c', '3']); + }); + + it('should collect some logs asynchrnously', async () => { + const logs = await withLogCollector(async () => { + console.log('a'); + console.warn('b'); + console.error('c'); + console.error('3'); + console.warn('2'); + console.log('1'); + }); + + expect(logs.log).toEqual(['a', '1']); + expect(logs.warn).toEqual(['b', '2']); + expect(logs.error).toEqual(['c', '3']); + }); + + it('should collect specific logs synchronously', () => { + const missedLogs = withLogCollector(() => { + const logs = withLogCollector(['warn', 'log'], () => { + console.log('a'); + console.warn('b'); + console.error('c'); + console.error('3'); + console.warn('2'); + console.log('1'); + }); + + expect(logs.log).toEqual(['a', '1']); + expect(logs.warn).toEqual(['b', '2']); + // @ts-ignore + expect(logs.error).toEqual([]); + }); + + expect(missedLogs.log).toEqual([]); + expect(missedLogs.warn).toEqual([]); + expect(missedLogs.error).toEqual(['c', '3']); + }); + + it('should collect specific logs asynchrnously', async () => { + const missedLogs = await withLogCollector(async () => { + const logs = await withLogCollector(['error'], async () => { + console.log('a'); + console.warn('b'); + console.error('c'); + console.error('3'); + console.warn('2'); + console.log('1'); + }); + + // @ts-ignore + expect(logs.log).toEqual([]); + // @ts-ignore + expect(logs.warn).toEqual([]); + expect(logs.error).toEqual(['c', '3']); + }); + + expect(missedLogs.log).toEqual(['a', '1']); + expect(missedLogs.warn).toEqual(['b', '2']); + expect(missedLogs.error).toEqual([]); + }); +}); diff --git a/packages/test-utils/src/testUtils/logCollector.ts b/packages/test-utils/src/testUtils/logCollector.ts new file mode 100644 index 0000000000..25629f2c13 --- /dev/null +++ b/packages/test-utils/src/testUtils/logCollector.ts @@ -0,0 +1,140 @@ +/* + * 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. + */ + +/* eslint-disable no-console */ + +/** + * Severity levels of {@link CollectedLogs} + * @public */ +export type LogFuncs = 'log' | 'warn' | 'error'; +/** + * AsyncLogCollector type used in {@link (withLogCollector:1)} callback function. + * @public */ +export type AsyncLogCollector = () => Promise; +/** + * SyncLogCollector type used in {@link (withLogCollector:2)} callback function. + * @public */ +export type SyncLogCollector = () => void; +/** + * Union type used in {@link (withLogCollector:3)} callback function. + * @public */ +export type LogCollector = AsyncLogCollector | SyncLogCollector; +/** + * Map of severity level and corresponding log lines. + * @public */ +export type CollectedLogs = { [key in T]: string[] }; + +const allCategories = ['log', 'warn', 'error']; + +/** + * Asynchronous log collector with that collects all categories + * @public */ +export function withLogCollector( + callback: AsyncLogCollector, +): Promise>; + +/** + * Synchronous log collector with that collects all categories + * @public */ +export function withLogCollector( + callback: SyncLogCollector, +): CollectedLogs; + +/** + * Asynchronous log collector with that only collects selected categories + * @public + */ +export function withLogCollector( + logsToCollect: T[], + callback: AsyncLogCollector, +): Promise>; + +/** + * Synchronous log collector with that only collects selected categories + * @public */ +export function withLogCollector( + logsToCollect: T[], + callback: SyncLogCollector, +): CollectedLogs; + +/** + * Log collector that collect logs either from a sync or async collector. + * @public + * @deprecated import from test-utils instead + * */ +export function withLogCollector( + logsToCollect: LogFuncs[] | LogCollector, + callback?: LogCollector, +): CollectedLogs | Promise> { + const oneArg = !callback; + const actualCallback = (oneArg ? logsToCollect : callback) as LogCollector; + const categories = (oneArg ? allCategories : logsToCollect) as LogFuncs[]; + + const logs = { + log: new Array(), + warn: new Array(), + error: new Array(), + }; + + const origLog = console.log; + const origWarn = console.warn; + const origError = console.error; + + if (categories.includes('log')) { + console.log = (message: string) => { + logs.log.push(message); + }; + } + if (categories.includes('warn')) { + console.warn = (message: string) => { + logs.warn.push(message); + }; + } + if (categories.includes('error')) { + console.error = (message: string) => { + logs.error.push(message); + }; + } + + const restore = () => { + console.log = origLog; + console.warn = origWarn; + console.error = origError; + }; + + try { + const ret = actualCallback(); + + if (!ret || !ret.then) { + restore(); + return logs; + } + + return ret.then( + () => { + restore(); + return logs; + }, + error => { + restore(); + throw error; + }, + ); + } catch (error) { + restore(); + throw error; + } +} diff --git a/packages/test-utils/src/testUtils/mockBreakpoint.ts b/packages/test-utils/src/testUtils/mockBreakpoint.ts index 3335284f88..3bc285a81d 100644 --- a/packages/test-utils/src/testUtils/mockBreakpoint.ts +++ b/packages/test-utils/src/testUtils/mockBreakpoint.ts @@ -24,7 +24,7 @@ * If there are any updates from MUI React on testing `useMediaQuery` this mock should be replaced * https://material-ui.com/components/use-media-query/#testing * - * @param matchMediaOptions + * @public */ export default function mockBreakpoint({ matches = false }) { Object.defineProperty(window, 'matchMedia', { diff --git a/packages/test-utils/src/testUtils/testingLibrary.ts b/packages/test-utils/src/testUtils/testingLibrary.ts new file mode 100644 index 0000000000..44ea20a4cc --- /dev/null +++ b/packages/test-utils/src/testUtils/testingLibrary.ts @@ -0,0 +1,42 @@ +/* + * 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 { ReactElement } from 'react'; +import { act } from 'react-dom/test-utils'; +import { render, RenderResult } from '@testing-library/react'; + +/** + * @public + * Simplifies rendering of async components in by taking care of the wrapping inside act + * + * @remarks + * + * Components using useEffect to perform an asynchronous action (such as fetch) must be rendered within an async + * act call to properly get the final state, even with mocked responses. This utility method makes the signature a bit + * cleaner, since act doesn't return the result of the evaluated function. + * https://github.com/testing-library/react-testing-library/issues/281 + * https://github.com/facebook/react/pull/14853 + */ +export async function renderWithEffects( + nodes: ReactElement, +): Promise { + let value: RenderResult; + await act(async () => { + value = render(nodes); + }); + // @ts-ignore + return value; +}