diff --git a/src/__tests__/renderToRenderStream.test.tsx b/src/__tests__/renderToRenderStream.test.tsx index e224944fd..195ed6ad4 100644 --- a/src/__tests__/renderToRenderStream.test.tsx +++ b/src/__tests__/renderToRenderStream.test.tsx @@ -58,6 +58,37 @@ describe('snapshotDOM', () => { expect(input.value).toBe('2') } }) + + test('queries option', async () => { + function Component() { + return null + } + const queries = { + foo: (_: any) => { + return null + }, + } + const {takeRender, renderResultPromise} = renderToRenderStream( + , + { + queries, + snapshotDOM: true, + }, + ) + const utils = await renderResultPromise + expect(utils.foo()).toBe(null) + const {withinDOM} = await takeRender() + expect(withinDOM().foo()).toBe(null) + function _typeTest() { + // @ts-expect-error should not be present + utils.getByText + // @ts-expect-error should not be present + withinDOM().getByText + utils.debug() + withinDOM().debug() + const _str: string = withinDOM().logTestingPlaygroundURL() + } + }) }) // for more tests, see the `createRenderStream` test suite, as `renderToRenderStream` is just a wrapper around that diff --git a/src/assertable.ts b/src/assertable.ts index b7de2d222..fdafef904 100644 --- a/src/assertable.ts +++ b/src/assertable.ts @@ -13,12 +13,12 @@ export const assertableSymbol = Symbol.for( ``` */ export type Assertable = { - [assertableSymbol]: RenderStream + [assertableSymbol]: RenderStream } export function markAssertable( assertable: T, - stream: RenderStream, + stream: RenderStream, ): T & Assertable { return Object.assign(assertable, { [assertableSymbol]: stream, diff --git a/src/renderHookToSnapshotStream.tsx b/src/renderHookToSnapshotStream.tsx index b1eb48874..3d8f6edb7 100644 --- a/src/renderHookToSnapshotStream.tsx +++ b/src/renderHookToSnapshotStream.tsx @@ -11,7 +11,7 @@ export interface SnapshotStream extends Assertable { * Errors thrown during component render will be captured here, too. */ renders: Array< - | Render<{value: Snapshot}> + | Render<{value: Snapshot}, never> | {phase: 'snapshotError'; count: number; error: unknown} > /** @@ -49,7 +49,7 @@ export function renderHookToSnapshotStream( renderCallback: (props: Props) => ReturnValue, {initialProps, ...renderOptions}: RenderHookOptions = {}, ): SnapshotStream { - const {render, ...stream} = createRenderStream<{value: ReturnValue}>() + const {render, ...stream} = createRenderStream<{value: ReturnValue}, never>() const HookComponent: React.FC<{arg: Props}> = props => { stream.replaceSnapshot({value: renderCallback(props.arg)}) diff --git a/src/renderStream/Render.tsx b/src/renderStream/Render.tsx index 078e85654..456fbc699 100644 --- a/src/renderStream/Render.tsx +++ b/src/renderStream/Render.tsx @@ -1,16 +1,10 @@ -/* istanbul ignore file */ - -/* -Something in this file does not compile correctly while measuring code coverage -and will lead to a - Uncaught [ReferenceError: cov_1zb8w312au is not defined] -if we do not ignore this file in code coverage. - -As we only use this file in our internal tests, we can safely ignore it. -*/ - -import {within, screen} from '@testing-library/dom' +import {screen, getQueriesForElement, Screen} from '@testing-library/dom' import {JSDOM, VirtualConsole} from 'jsdom' +import { + BoundSyncFunctions, + type Queries, + type SyncQueries, +} from './syncQueries.js' export interface BaseRender { id: string @@ -25,18 +19,11 @@ export interface BaseRender { count: number } -type Screen = typeof screen +export type SyncScreen = + BoundSyncFunctions & Pick -export type SyncScreen = { - [K in keyof Screen]: K extends `find${string}` - ? { - /** @deprecated A snapshot is static, so avoid async queries! */ - (...args: Parameters): ReturnType - } - : Screen[K] -} - -export interface Render extends BaseRender { +export interface Render + extends BaseRender { /** * The snapshot, as returned by the `takeSnapshot` option of `createRenderStream`. */ @@ -57,12 +44,14 @@ export interface Render extends BaseRender { * +expect(withinDOM().getByText("foo")).toBeInTheDocument(); * ``` */ - withinDOM: () => SyncScreen + withinDOM: () => SyncScreen renderedComponents: Array } -export class RenderInstance implements Render { +export class RenderInstance + implements Render +{ id: string phase: 'mount' | 'update' | 'nested-update' actualDuration: number @@ -73,12 +62,14 @@ export class RenderInstance implements Render { public snapshot: Snapshot private stringifiedDOM: string | undefined public renderedComponents: Array + private queries: Q constructor( baseRender: BaseRender, snapshot: Snapshot, stringifiedDOM: string | undefined, renderedComponents: Array, + queries: Q, ) { this.snapshot = snapshot this.stringifiedDOM = stringifiedDOM @@ -90,6 +81,7 @@ export class RenderInstance implements Render { this.startTime = baseRender.startTime this.commitTime = baseRender.commitTime this.count = baseRender.count + this.queries = queries } private _domSnapshot: HTMLElement | undefined @@ -124,17 +116,23 @@ export class RenderInstance implements Render { return (this._domSnapshot = body) } - get withinDOM(): () => SyncScreen { - const snapScreen = Object.assign(within(this.domSnapshot), { - debug: ( - ...[dom = this.domSnapshot, ...rest]: Parameters - ) => screen.debug(dom, ...rest), - logTestingPlaygroundURL: ( - ...[dom = this.domSnapshot, ...rest]: Parameters< - typeof screen.logTestingPlaygroundURL - > - ) => screen.logTestingPlaygroundURL(dom, ...rest), - }) + get withinDOM(): () => SyncScreen { + const snapScreen = Object.assign( + getQueriesForElement( + this.domSnapshot, + this.queries, + ) as any as BoundSyncFunctions, + { + debug: ( + ...[dom = this.domSnapshot, ...rest]: Parameters + ) => screen.debug(dom, ...rest), + logTestingPlaygroundURL: ( + ...[dom = this.domSnapshot, ...rest]: Parameters< + typeof screen.logTestingPlaygroundURL + > + ) => screen.logTestingPlaygroundURL(dom, ...rest), + }, + ) return () => snapScreen } } diff --git a/src/renderStream/__tests__/createRenderStream.test.tsx b/src/renderStream/__tests__/createRenderStream.test.tsx index 5c7fc6fb5..3218f5406 100644 --- a/src/renderStream/__tests__/createRenderStream.test.tsx +++ b/src/renderStream/__tests__/createRenderStream.test.tsx @@ -87,6 +87,32 @@ describe('snapshotDOM', () => { } } }) + + test('queries option', async () => { + function Component() { + return null + } + const queries = { + foo: (_: any) => { + return null + }, + } + + const {takeRender, render} = createRenderStream({ + snapshotDOM: true, + queries, + }) + render() + + const {withinDOM} = await takeRender() + expect(withinDOM().foo()).toBe(null) + function _typeTest() { + // @ts-expect-error should not be present + withinDOM().getByText + withinDOM().debug() + const _str: string = withinDOM().logTestingPlaygroundURL() + } + }) }) describe('replaceSnapshot', () => { diff --git a/src/renderStream/createRenderStream.tsx b/src/renderStream/createRenderStream.tsx index d536cac34..2c87ffe46 100644 --- a/src/renderStream/createRenderStream.tsx +++ b/src/renderStream/createRenderStream.tsx @@ -6,6 +6,7 @@ import {RenderInstance, type Render, type BaseRender} from './Render.js' import {type RenderStreamContextValue} from './context.js' import {RenderStreamContextProvider} from './context.js' import {disableActWarnings} from './disableActWarnings.js' +import {syncQueries, type Queries, type SyncQueries} from './syncQueries.js' export type ValidSnapshot = // eslint-disable-next-line @typescript-eslint/no-invalid-void-type @@ -29,7 +30,10 @@ interface MergeSnapshot { ): void } -export interface RenderStream { +export interface RenderStream< + Snapshot extends ValidSnapshot, + Q extends Queries = SyncQueries, +> { // Allows for partial updating of the snapshot by shallow merging the results mergeSnapshot: MergeSnapshot // Performs a full replacement of the snapshot @@ -39,21 +43,22 @@ export interface RenderStream { * Errors thrown during component render will be captured here, too. */ renders: Array< - Render | {phase: 'snapshotError'; count: number; error: unknown} + | Render + | {phase: 'snapshotError'; count: number; error: unknown} > /** * Peeks the next render from the current iterator position, without advancing the iterator. * If no render has happened yet, it will wait for the next render to happen. * @throws {WaitForRenderTimeoutError} if no render happens within the timeout */ - peekRender: (options?: NextRenderOptions) => Promise> + peekRender: (options?: NextRenderOptions) => Promise> /** * Iterates to the next render and returns it. * If no render has happened yet, it will wait for the next render to happen. * @throws {WaitForRenderTimeoutError} if no render happens within the timeout */ takeRender: Assertable & - ((options?: NextRenderOptions) => Promise>) + ((options?: NextRenderOptions) => Promise>) /** * Returns the total number of renders. */ @@ -62,20 +67,27 @@ export interface RenderStream { * Returns the current render. * @throws {Error} if no render has happened yet */ - getCurrentRender: () => Render + getCurrentRender: () => Render /** * Waits for the next render to happen. * Does not advance the render iterator. */ - waitForNextRender: (options?: NextRenderOptions) => Promise> + waitForNextRender: ( + options?: NextRenderOptions, + ) => Promise> } -export interface RenderStreamWithRenderFn - extends RenderStream { +export interface RenderStreamWithRenderFn< + Snapshot extends ValidSnapshot, + Q extends Queries = SyncQueries, +> extends RenderStream { render: typeof baseRender } -export type RenderStreamOptions = { +export type RenderStreamOptions< + Snapshot extends ValidSnapshot, + Q extends Queries = SyncQueries, +> = { onRender?: ( info: BaseRender & { snapshot: Snapshot @@ -90,6 +102,7 @@ export type RenderStreamOptions = { * `useTrackRenders` occured. */ skipNonTrackingRenders?: boolean + queries?: Q } export class WaitForRenderTimeoutError extends Error { @@ -100,19 +113,26 @@ export class WaitForRenderTimeoutError extends Error { } } -export function createRenderStream({ +export function createRenderStream< + Snapshot extends ValidSnapshot = void, + Q extends Queries = SyncQueries, +>({ onRender, snapshotDOM = false, initialSnapshot, skipNonTrackingRenders, -}: RenderStreamOptions = {}): RenderStreamWithRenderFn { + queries = syncQueries as any as Q, +}: RenderStreamOptions = {}): RenderStreamWithRenderFn< + Snapshot, + Q +> { // creating the object first and then assigning in all the properties // allows keeping the object instance for reference while the members are // created, which is important for the `markAssertable` function - const stream = {} as any as RenderStreamWithRenderFn + const stream = {} as any as RenderStreamWithRenderFn - let nextRender: Promise> | undefined, - resolveNextRender: ((render: Render) => void) | undefined, + let nextRender: Promise> | undefined, + resolveNextRender: ((render: Render) => void) | undefined, rejectNextRender: ((error: unknown) => void) | undefined function resetNextRender() { nextRender = undefined @@ -199,6 +219,7 @@ export function createRenderStream({ snapshot, domSnapshot, renderStreamContext.renderedComponents, + queries, ) renderStreamContext.renderedComponents = [] stream.renders.push(render) @@ -247,7 +268,8 @@ export function createRenderStream({ replaceSnapshot, mergeSnapshot, renders: new Array< - Render | {phase: 'snapshotError'; count: number; error: unknown} + | Render + | {phase: 'snapshotError'; count: number; error: unknown} >(), totalRenderCount() { return stream.renders.length @@ -316,12 +338,12 @@ export function createRenderStream({ }, waitForNextRender({timeout = 1000}: NextRenderOptions = {}) { if (!nextRender) { - nextRender = Promise.race>([ - new Promise>((resolve, reject) => { + nextRender = Promise.race>([ + new Promise>((resolve, reject) => { resolveNextRender = resolve rejectNextRender = reject }), - new Promise>((_, reject) => + new Promise>((_, reject) => setTimeout(() => { const error = new WaitForRenderTimeoutError() Error.captureStackTrace(error, stream.waitForNextRender) diff --git a/src/renderStream/syncQueries.ts b/src/renderStream/syncQueries.ts new file mode 100644 index 000000000..1055ddd90 --- /dev/null +++ b/src/renderStream/syncQueries.ts @@ -0,0 +1,135 @@ +import {queries, Query, Queries} from '@testing-library/dom' + +export {Query, Queries} + +type OriginalQueries = typeof queries + +export type SyncQueries = { + [K in keyof OriginalQueries as K extends `${'query'}${string}` + ? never + : K]: OriginalQueries[K] +} + +export const syncQueries = Object.fromEntries( + Object.entries(queries).filter( + ([key]) => key.startsWith('get') || key.startsWith('find'), + ), +) as any as SyncQueries + +export type BoundFunction = T extends ( + container: HTMLElement, + ...args: infer P +) => infer R + ? (...args: P) => R + : never + +export type BoundSyncFunctions = Q extends typeof syncQueries + ? { + getByLabelText( + ...args: Parameters>> + ): ReturnType> + getAllByLabelText( + ...args: Parameters>> + ): ReturnType> + findByLabelText( + ...args: Parameters>> + ): ReturnType> + findAllByLabelText( + ...args: Parameters>> + ): ReturnType> + getByPlaceholderText( + ...args: Parameters>> + ): ReturnType> + getAllByPlaceholderText( + ...args: Parameters>> + ): ReturnType> + findByPlaceholderText( + ...args: Parameters>> + ): ReturnType> + findAllByPlaceholderText( + ...args: Parameters>> + ): ReturnType> + getByText( + ...args: Parameters>> + ): ReturnType> + getAllByText( + ...args: Parameters>> + ): ReturnType> + findByText( + ...args: Parameters>> + ): ReturnType> + findAllByText( + ...args: Parameters>> + ): ReturnType> + getByAltText( + ...args: Parameters>> + ): ReturnType> + getAllByAltText( + ...args: Parameters>> + ): ReturnType> + findByAltText( + ...args: Parameters>> + ): ReturnType> + findAllByAltText( + ...args: Parameters>> + ): ReturnType> + getByTitle( + ...args: Parameters>> + ): ReturnType> + getAllByTitle( + ...args: Parameters>> + ): ReturnType> + findByTitle( + ...args: Parameters>> + ): ReturnType> + findAllByTitle( + ...args: Parameters>> + ): ReturnType> + getByDisplayValue( + ...args: Parameters>> + ): ReturnType> + getAllByDisplayValue( + ...args: Parameters>> + ): ReturnType> + findByDisplayValue( + ...args: Parameters>> + ): ReturnType> + findAllByDisplayValue( + ...args: Parameters>> + ): ReturnType> + getByRole( + ...args: Parameters>> + ): ReturnType> + getAllByRole( + ...args: Parameters>> + ): ReturnType> + findByRole( + ...args: Parameters>> + ): ReturnType> + findAllByRole( + ...args: Parameters>> + ): ReturnType> + getByTestId( + ...args: Parameters>> + ): ReturnType> + getAllByTestId( + ...args: Parameters>> + ): ReturnType> + queryByTestId( + ...args: Parameters>> + ): ReturnType> + queryAllByTestId( + ...args: Parameters>> + ): ReturnType> + findByTestId( + ...args: Parameters>> + ): ReturnType> + findAllByTestId( + ...args: Parameters>> + ): ReturnType> + } & { + [P in keyof Q]: BoundFunction + } + : { + [P in keyof Q]: BoundFunction + } diff --git a/src/renderToRenderStream.ts b/src/renderToRenderStream.ts index 34431d391..9692918f3 100644 --- a/src/renderToRenderStream.ts +++ b/src/renderToRenderStream.ts @@ -1,4 +1,5 @@ import { + Queries, type RenderOptions as BaseOptions, type RenderResult as BaseResult, } from '@testing-library/react' @@ -8,39 +9,49 @@ import { type RenderStream, type ValidSnapshot, } from './renderStream/createRenderStream.js' +import {SyncQueries} from './renderStream/syncQueries.js' -type RenderOptions = BaseOptions & - RenderStreamOptions +type RenderOptions< + Snapshot extends ValidSnapshot = void, + Q extends Queries = SyncQueries, +> = BaseOptions & RenderStreamOptions export interface RenderStreamWithRenderResult< Snapshot extends ValidSnapshot = void, -> extends RenderStream { - renderResultPromise: Promise + Q extends Queries = SyncQueries, +> extends RenderStream { + renderResultPromise: Promise> } /** * Render into a container which is appended to document.body. It should be used with cleanup. */ -export function renderToRenderStream( +export function renderToRenderStream< + Snapshot extends ValidSnapshot = void, + Q extends Queries = SyncQueries, +>( ui: React.ReactNode, - // TODO: add `queries` { onRender, snapshotDOM, initialSnapshot, skipNonTrackingRenders, + queries, ...options - }: RenderOptions = {}, -): RenderStreamWithRenderResult { - const {render, ...stream} = createRenderStream({ + }: RenderOptions = {}, +): RenderStreamWithRenderResult { + const {render, ...stream} = createRenderStream({ onRender, snapshotDOM, initialSnapshot, skipNonTrackingRenders, + queries, }) // `render` needs to be called asynchronously here, because the definition of `ui` // might contain components that reference the return value of `renderToRenderStream` // itself, e.g. `replaceSnapshot` or `mergeSnapshot`. - const renderResultPromise = Promise.resolve().then(() => render(ui, options)) + const renderResultPromise = Promise.resolve().then(() => + render(ui, {...options, queries}), + ) return {...stream, renderResultPromise} }